1 """
2 Plotting facilities, based on Python's MPL library.
3
4 The L{Chart} object is a facade which provides intuitive access to MPL's plotting
5 objects. The following examples show a typical use of L{Chart}:
6
7 1. Instantiation
8
9 >>> chart = Chart() # a chart with a single plot
10 >>> chart = Chart(rows=2, columns=2) # a chart with 2x2=4 plots
11
12 2. Accessing plots (equivalent to MPL's subplots)
13
14 >>> chart.plots[0] # first plot (at row=0, column=0)
15 Plot (matplotlib.axes.AxesSubplot)
16 >>> chart.plots[0, 1]
17 Plot (matplotlib.axes.AxesSubplot) # plot at row=0, column=1
18
19 3. Plotting
20
21 >>> chart.plots[0].hist(...)
22 >>> chart.plots[0].set_title('T')
23
24 >>> chart.plots[1].scatter(...)
25 >>> chart.plots[1].set_xlabel('X')
26
27 4. Using the GUI
28
29 >>> chart.show()
30 >>> chart.hide()
31
32 5. Saving as image
33
34 >>> chart.save(filename, format=chart.formats.PDF)
35
36 If using the GUI, do not forget to dispose the chart at the end:
37
38 >>> chart.dispose()
39
40 or simply use the chart in a context manager:
41
42 >>> with Chart() as chart:
43 chart...
44 """
45
46 import time
47 import csb.core
48
49 from abc import ABCMeta, abstractmethod
50
51 from threading import Thread, Event
52
53 from matplotlib.figure import Figure
54 from matplotlib.backends.backend_agg import FigureCanvasAgg
58 """
59 Abstract class defining the behavior of all Chart GUI backends.
60
61 Each backend is a 'daemon' that runs in a new thread and handles
62 all GUI requests from any L{Chart} instance. A backend service must
63 behave as a singleton - only one service of a given kind may exist at a
64 given point in time. L{Chart} clients will request GUI operations
65 on specific figures, the Backend therefore must keep track of all
66 windows opened, as well as the figure-to-window mapping.
67 """
68
69 __metaclass__ = ABCMeta
70 _instances = {}
71
72 @staticmethod
73 - def get(backend, started=True):
74 """
75 Backend factory, ensures one instance per subclass.
76
77 @param backend: one of the L{Backend} subclasses
78 @type backend: type
79 @param started: if True, ensure that the service is running
80 @type started: bool
81
82 @return: an instance of the backend. The returned service
83 instance may need to be started.
84 @rtype: L{Backend}
85 """
86
87 if not issubclass(backend, Backend):
88 raise TypeError(backend)
89
90 if backend in Backend._instances:
91 instance = Backend._instances[backend]
92 else:
93 instance = backend()
94
95 if started and not instance.started:
96 instance.start()
97 return instance
98
99 @staticmethod
101 """
102 @param backend: one of the L{Backend} subclasses
103 @type backend: type
104
105 @return: True if a service of type C{backend} is running
106 @rtype: bool
107 """
108
109 if not issubclass(backend, Backend):
110 raise TypeError(backend)
111
112 if backend in Backend._instances:
113 instance = Backend._instances[backend]
114 return instance.started
115
116 else:
117 return False
118
134
135 @property
137 """
138 True if the service had been started
139 @rtype: bool
140 """
141 return self._started.isSet()
142
143 @property
145 """
146 True if the service had been started and is currently running
147 @rtype: bool
148 """
149 return self._running.isSet()
150
151 @abstractmethod
153 """
154 Create an instance of the GUI application.
155 """
156 pass
157
158 @abstractmethod
159 - def _mainloop(self):
160 """
161 Enter the GUI main loop.
162 """
163 pass
164
165 @abstractmethod
167 """
168 Delete all frames, exit the GUI main loop and perform any cleanup
169 needed in order to unblock the thread that started the main loop.
170 """
171 pass
172
173 @abstractmethod
174 - def _add(self, figure):
175 """
176 Handle an 'Add new figure' event
177 """
178 pass
179
180 @abstractmethod
181 - def _show(self, figure):
182 """
183 Handle a 'Show existing figure' event
184 """
185 pass
186
187 @abstractmethod
189 """
190 Handle a 'Resize existing figure' event
191 """
192 pass
193
194 @abstractmethod
195 - def _hide(self, figure):
196 """
197 Handle a 'Hide existing figure' event
198 """
199 pass
200
201 @abstractmethod
203 """
204 Handle a 'Delete existing figure' event
205 """
206 pass
207
208 @abstractmethod
209 - def _invoke(self, callable, *args):
210 """
211 Pass a GUI message: invoke C{callable} in a thread-safe way
212 """
213 pass
214
215 - def invoke(self, callable, *args):
216 """
217 Invoke an asynchronous GUI operation (in a thread-safe way)
218 """
219 if not self._running.isSet():
220 raise RuntimeError('The backend service is not running')
221 else:
222 self._invoke(callable, *args)
223
224 - def add(self, figure):
225 """
226 Add a new figure.
227 """
228 self.invoke(self._add, figure)
229
230 - def show(self, figure):
231 """
232 Show existing figure.
233 """
234 self.invoke(self._show, figure)
235
237 """
238 Resize existing figure.
239 """
240 self.invoke(self._resize, figure)
241
242 - def hide(self, figure):
243 """
244 Hide existing figure.
245 """
246 self.invoke(self._hide, figure)
247
248 - def destroy(self, figure, wait=False):
249 """
250 Destroy existing figure. If C{wait} is True, make sure the asynchronous
251 figure deletion is complete before returning from the method.
252 """
253 has_figure = (figure in self._figures)
254 self.invoke(self._destroy, figure)
255
256 if has_figure and wait:
257 while figure in self._figures:
258 pass
259
261 """
262 Start the Backend service. This method can be called only once.
263 """
264 try:
265 super(Backend, self).start()
266
267 while not self._running.isSet():
268 time.sleep(0.05)
269
270 except BaseException:
271 raise RuntimeError("Failed to start the backend service")
272
274 """
275 Main service method, automatically called by C{start}.
276 """
277 self._started.set()
278
279 self._initapp()
280 self._running.set()
281
282 self._mainloop()
283
284 self._running.clear()
285 self._started.clear()
286
288 """
289 Stop the Backend service. The Backend object can be safely
290 disposed afterwards.
291 """
292 self._exit()
293 self._figures = {}
294 self.join()
295 self._running.clear()
296 self._started.clear()
297 del Backend._instances[self.__class__]
298
300 """
301 Fired when a client is being deleted. Will stop the service if no
302 active clients are remaining.
303 """
304 if self._figures is None or len(self._figures) == 0:
305 self.stop()
306
308 if self._started.isSet():
309 self.stop()
310
312 """
313 WxPython L{Backend} implementor.
314
315 @note: not meant to be instantiated directly, use L{Backend.get} instead.
316 """
317
318 _wxapp = None
319
321
322 import wx
323 from matplotlib.backends.backend_wx import FigureFrameWx
324
325 self._wx = wx
326 self._FigureFrameWx = FigureFrameWx
327
328 super(WxBackendImpl, self).__init__()
329
330 @property
335
337
338 dummy = self._app
339 frame = self._wx.Frame(None)
340 frame.Show()
341 frame.Hide()
342
343 - def _mainloop(self):
344
345 self._app.MainLoop()
346
347 - def _add(self, figure):
348
349 wx = self._wx
350 FigureFrameWx = self._FigureFrameWx
351
352 if figure not in self._figures:
353
354 frame = FigureFrameWx(figure._figure_number, figure)
355 frame.Show()
356 frame.Bind(wx.EVT_ACTIVATE, lambda e: e.GetEventObject().Layout())
357 frame.Bind(wx.EVT_CLOSE, lambda e: self.invoke(self._hide, figure))
358
359 self._figures[figure] = frame
360
361 - def _show(self, figure):
362
363 if figure not in self._figures:
364 self._add(figure)
365
366 self._figures[figure].Show()
367
369
370 if figure in self._figures:
371
372 frame = self._figures[figure]
373
374 w = figure.get_figwidth() * figure.get_dpi()
375 h = figure.get_figheight() * figure.get_dpi()
376
377 size = self._wx.Size(w, h)
378 frame.canvas.SetInitialSize(size)
379 frame.GetSizer().Fit(frame)
380
381 - def _hide(self, figure):
382
383 if figure in self._figures:
384 self._figures[figure].Hide()
385
387
388 if figure in self._figures:
389 frame = self._figures[figure]
390 if not frame.IsBeingDeleted():
391 frame.Destroy()
392 del self._figures[figure]
393
394 - def _invoke(self, callable, *args):
395
396 wx = self._wx
397 wx.CallAfter(callable, *args)
398
400
401 for frame in self._figures.values():
402 if not frame.IsBeingDeleted():
403 frame.Destroy()
404 self._app.Exit()
405
407 """
408 Enumeration of chart backends.
409 """
410
411 WX_WIDGETS = WxBackendImpl
412
415 """
416 A list-like collection of all plots in the chart (0-based).
417 """
418
419 - def __init__(self, figure, rows=1, columns=1):
420
421 assert rows >= 1 and columns >= 1
422
423 self._plots = []
424 self._figure = figure
425 self._rows = int(rows)
426 self._columns = int(columns)
427
428 for dummy in range(self._rows * self._columns):
429 self._plots.append(None)
430
431 @property
433 return [p for p in self._plots if p is not None]
434
435 - def _add(self, index=1):
436
437 assert 0 <= index < len(self._plots)
438
439 plot = self._figure.add_subplot(self._rows, self._columns, index + 1)
440 self._plots[index] = plot
441
442 return plot
443
445
446 if isinstance(location, tuple):
447 row, col = location
448 i = row * self._columns + col
449 else:
450 i = int(location)
451
452 if not (0 <= i < len(self._plots)):
453 raise IndexError("No such plot: {0}".format(location))
454
455 if self._plots[i] is None:
456 return self._add(i)
457 else:
458 return self._plots[i]
459
461 return len(self._active_plots)
462
464 return iter(self._active_plots)
465
468 """
469 Simple and clean facade to Matplotlib's plotting API.
470
471 A chart instance abstracts a plotting device, on which one or
472 multiple related plots can be drawn. Charts can be exported as images, or
473 visualized interactively. Each chart instance will always open in its own
474 GUI window, and this window will never block the execution of the rest of
475 the program, or interfere with other L{Chart}s.
476 The GUI can be safely opened in the background and closed infinite number
477 of times, as long as the client program is still running.
478
479 By default, a chart contains a single plot:
480
481 >>> chart.plot
482 matplotlib.axes.AxesSubplot
483 >>> chart.plot.hist(...)
484
485 If C{rows} and C{columns} are defined, the chart will contain
486 C{rows} x C{columns} number of plots (equivalent to MPL's sub-plots).
487 Each plot can be assessed by its index:
488
489 >>> chart.plots[0]
490 first plot
491
492 or by its position in the grid:
493
494 >>> chart.plots[0, 1]
495 plot at row=0, column=1
496
497 @param number: chart number; by default this a L{Chart.AUTONUMBER}
498 @type number: int or None
499 @param title: chart master title
500 @type title: str
501 @param rows: number of rows in the chart window
502 @type rows: int
503 @param columns: number of columns in the chart window
504 @type columns: int
505
506 @note: additional arguments are passed directly to Matplotlib's Figure
507 constructor.
508 """
509
510 AUTONUMBER = None
511
512 _serial = 0
513
514
515 - def __init__(self, number=None, title='', rows=1, columns=1, backend=Backends.WX_WIDGETS, *fa, **fk):
516
517 if number == Chart.AUTONUMBER:
518 Chart._serial += 1
519 number = Chart._serial
520
521 if rows < 1:
522 rows = 1
523 if columns < 1:
524 columns = 1
525
526 self._rows = int(rows)
527 self._columns = int(columns)
528 self._number = int(number)
529 self._title = str(title)
530 self._figure = Figure(*fa, **fk)
531 self._figure._figure_number = self._number
532 self._figure.suptitle(self._title)
533 self._beclass = backend
534 self._hasgui = False
535 self._plots = PlotsCollection(self._figure, self._rows, self._columns)
536 self._canvas = FigureCanvasAgg(self._figure)
537
538 formats = [ (f.upper(), f) for f in self._canvas.get_supported_filetypes() ]
539 self._formats = csb.core.Enum.create('OutputFormats', **dict(formats))
540
542 if i in self._plots:
543 return self._plots[i]
544 else:
545 raise KeyError('No such plot number: {0}'.format(i))
546
549
552
553 @property
556
557 @property
560
561 @property
563 """
564 Chart title
565 @rtype: str
566 """
567 return self._title
568
569 @property
571 """
572 Chart number
573 @rtype: int
574 """
575 return self._number
576
577 @property
579 """
580 Index-based access to the plots in this chart
581 @rtype: L{PlotsCollection}
582 """
583 return self._plots
584
585 @property
587 """
588 First plot
589 @rtype: matplotlib.AxesSubplot
590 """
591 return self._plots[0]
592
593 @property
595 """
596 Number of rows in this chart
597 @rtype: int
598 """
599 return self._rows
600
601 @property
603 """
604 Number of columns in this chart
605 @rtype: int
606 """
607 return self._columns
608
609 @property
611 """
612 Chart's width in inches
613 @rtype: int
614 """
615 return self._figure.get_figwidth()
616 @width.setter
617 - def width(self, inches):
618 self._figure.set_figwidth(inches)
619 if self._backend_started:
620 self._backend.resize(self._figure)
621
622 @property
624 """
625 Chart's height in inches
626 @rtype: int
627 """
628 return self._figure.get_figheight()
629 @height.setter
631 self._figure.set_figheight(inches)
632 if self._backend_started:
633 self._backend.resize(self._figure)
634
635 @property
637 """
638 Chart's DPI
639 @rtype: int
640 """
641 return self._figure.get_dpi()
642 @dpi.setter
643 - def dpi(self, dpi):
644 self._figure.set_dpi(dpi)
645 self._backend.resize(self._figure)
646
647 @property
654
656 """
657 Show the GUI window (non-blocking).
658 """
659 if not self._hasgui:
660 self._backend.add(self._figure)
661 self._hasgui = True
662
663 self._backend.show(self._figure)
664
666 """
667 Hide (but do not dispose) the GUI window.
668 """
669 self._backend.hide(self._figure)
670
672 """
673 Dispose the GUI interface. Must be called at the end if any
674 chart.show() calls have been made. Automatically called if using
675 the chart in context manager ("with" statement).
676
677 @note: Failing to call this method if show() has been called at least
678 once may cause backend-related errors.
679 """
680 if self._backend_started:
681
682 service = self._backend
683
684 if service and service.running:
685 service.destroy(self._figure, wait=True)
686 service.client_disposed(self)
687
688 - def save(self, file, format='png', crop=False, dpi=None, *a, **k):
689 """
690 Save all plots to an image.
691
692 @param file: destination file name
693 @type file: str
694 @param format: output image format; see C{chart.formats} for enumeration
695 @type format: str or L{csb.core.EnumItem}
696 @param crop: if True, crop the image (equivalent to MPL's bbox=tight)
697 @type crop: bool
698
699 @note: additional arguments are passed directly to MPL's savefig method
700 """
701 if 'bbox_inches' in k:
702 bbox = k['bbox_inches']
703 del k['bbox_inches']
704 else:
705 if crop:
706 bbox = 'tight'
707 else:
708 bbox = None
709
710 self._canvas.print_figure(file, format=str(format), bbox_inches=bbox, dpi=dpi, *a, **k)
711