Package csb :: Package io :: Module plots |
[frames] | no frames]

# Source Code for Module csb.io.plots

      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
     55
     56
     57 -class Backend(Thread):
     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
    100 -    def query(backend):
    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
    119 -    def __init__(self):
    120
    121          name = self.__class__
    122          if name in Backend._instances:
    123              raise RuntimeError('Backend {0} has already been initialized'.format(name))
    124          else:
    125              Backend._instances[name] = self
    126
    127          super(Backend, self).__init__()
    128
    129          self._figures = {}
    130          self._started = Event()
    131          self._running = Event()
    132
    133          self.setDaemon(True)
    134
    135      @property
    136 -    def started(self):
    137          """
    138          True if the service had been started
    139          @rtype: bool
    140          """
    141          return self._started.isSet()
    142
    143      @property
    144 -    def running(self):
    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
    152 -    def _initapp(self):
    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
    166 -    def _exit(self):
    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
    188 -    def _resize(self, figure):
    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
    202 -    def _destroy(self, figure):
    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
    236 -    def resize(self, figure):
    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
    260 -    def start(self):
    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
    273 -    def run(self):
    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
    287 -    def stop(self):
    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
    299 -    def client_disposed(self, client):
    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
    307 -    def __del__(self):
    308          if self._started.isSet():
    309              self.stop()
    310
    311 -class WxBackendImpl(Backend):
    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
    320 -    def __init__(self):
    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
    331 -    def _app(self):
    332          if WxBackendImpl._wxapp is None:
    333              WxBackendImpl._wxapp = self._wx.PySimpleApp()
    334          return WxBackendImpl._wxapp
    335
    336 -    def _initapp(self):
    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
    368 -    def _resize(self, figure):
    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
    386 -    def _destroy(self, figure):
    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
    399 -    def _exit(self):
    400
    401          for frame in self._figures.values():
    402              if not frame.IsBeingDeleted():
    403                  frame.Destroy()
    404          self._app.Exit()
    405
    406 -class Backends(object):
    407      """
    408      Enumeration of chart backends.
    409      """
    410
    411      WX_WIDGETS = WxBackendImpl
    412
    413
    414 -class PlotsCollection(object):
    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
    432 -    def _active_plots(self):
    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
    444 -    def __getitem__(self, location):
    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
    460 -    def __len__(self):
    461          return len(self._active_plots)
    462
    463 -    def __iter__(self):
    464          return iter(self._active_plots)
    465
    466
    467 -class Chart(object):
    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
    541 -    def __getitem__(self, i):
    542          if i in self._plots:
    543              return self._plots[i]
    544          else:
    545              raise KeyError('No such plot number: {0}'.format(i))
    546
    547 -    def __enter__(self):
    548          return self
    549
    550 -    def __exit__(self, *a, **k):
    551          self.dispose()
    552
    553      @property
    554 -    def _backend(self):
    555          return Backend.get(self._beclass, started=True)
    556
    557      @property
    558 -    def _backend_started(self):
    559          return Backend.query(self._beclass)
    560
    561      @property
    562 -    def title(self):
    563          """
    564          Chart title
    565          @rtype: str
    566          """
    567          return self._title
    568
    569      @property
    570 -    def number(self):
    571          """
    572          Chart number
    573          @rtype: int
    574          """
    575          return self._number
    576
    577      @property
    578 -    def plots(self):
    579          """
    580          Index-based access to the plots in this chart
    581          @rtype: L{PlotsCollection}
    582          """
    583          return self._plots
    584
    585      @property
    586 -    def plot(self):
    587          """
    588          First plot
    589          @rtype: matplotlib.AxesSubplot
    590          """
    591          return self._plots[0]
    592
    593      @property
    594 -    def rows(self):
    595          """
    596          Number of rows in this chart
    597          @rtype: int
    598          """
    599          return self._rows
    600
    601      @property
    602 -    def columns(self):
    603          """
    604          Number of columns in this chart
    605          @rtype: int
    606          """
    607          return self._columns
    608
    609      @property
    610 -    def width(self):
    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
    623 -    def height(self):
    624          """
    625          Chart's height in inches
    626          @rtype: int
    627          """
    628          return self._figure.get_figheight()
    629      @height.setter
    630 -    def height(self, inches):
    631          self._figure.set_figheight(inches)
    632          if self._backend_started:
    633              self._backend.resize(self._figure)
    634
    635      @property
    636 -    def dpi(self):
    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
    648 -    def formats(self):
    649          """
    650          Supported output file formats
    651          @rtype: L{csb.core.enum}
    652          """
    653          return self._formats
    654
    655 -    def show(self):
    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
    665 -    def hide(self):
    666          """
    667          Hide (but do not dispose) the GUI window.
    668          """
    669          self._backend.hide(self._figure)
    670
    671 -    def dispose(self):
    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