Skip to content

Latest commit

 

History

History
503 lines (361 loc) · 18.4 KB

File metadata and controls

503 lines (361 loc) · 18.4 KB

How to implement your own plotters and plugins

New plotters and plugins to the psyplot framework are highly welcomed. In this guide, we present :ref:`how to create new plotters <new_plotters>` and explain to you how you can :ref:`include them as a plugin in psyplot <new_plugins>`.

Creating plotters

.. currentmodule:: psyplot.plotter

Implementing new plotters can be very easy or quite an effort depending on how sophisticated you want to do it. In principle, you only have to implement the :meth:`Formatoption.update` method and a default value. I.e., one simple formatoption would be

.. ipython::

    In [1]: from psyplot.plotter import Formatoption, Plotter

    In [2]: class MyFormatoption(Formatoption):
       ...:     default = 'my text'
       ...:     def update(self, value):
       ...:         self.ax.text(0.5, 0.5, value, fontsize='xx-large')

together with a plotter

.. ipython::

    In [3]: class MyPlotter(Plotter):
       ...:     my_fmt = MyFormatoption('my_fmt')

and your done. Now you can make a simple plot

.. ipython::

    In [4]: from psyplot import open_dataset

    In [5]: ds = open_dataset('demo.nc')

    @savefig docs_demo_MyPlotter_simple.png width=4in
    In [6]: plotter = MyPlotter(ds.t2m)

However, if you're using the psyplot framework, you probably will be a bit more advanced so let's talk about attributes and methods of the :class:`Formatoption` class.

If you look into the documentation of the :class:`Formatoption` class, you find quite a lot of attributes and methods which probably is a bit depressing and confusing. But in principle, we can group them into 4 categories, the interface to the data, to the plotter and to other formatoptions. Plus an additional category for some Formatoption internals you definitely have to care about.

Interface for the plotter

The first interface is the one, that interfaces to the plotter. The most important attributes in this group are the :attr:`~Formatoption.key`, :attr:`~Formatoption.priority`, :attr:`~Formatoption.plot_fmt`, :meth:`~Formatoption.initialize_plot` and most important the :meth:`~Formatoption.update` method.

The :attr:`~Formatoption.key` is the unique key for the formatoption inside the plotter. In our example above, we assign the 'my_fmt' key to the MyFormatoption class in MyPlotter. Hence, this key is defined when the plotter class is defined and will be automatically assigned to the formatoption.

The next important attribute is the :attr:`priority` attribute. There are three stages in the update of a plotter:

  1. The stage with data manipulation. If formatoptions manipulate the data that shall be visualized (the :attr:`~Formatoption.data` attribute), those formatoptions are updated first. They have the :attr:`psyplot.plotter.START` priority
  2. The stage of the plot. Formatoptions that influence how the data is visualized are updated here (e.g. the colormap or formatoptions that do the plotting). They have the :attr:`psyplot.plotter.BEFOREPLOTTING` priority.
  3. The stage of the plot where additional informations are inserted. Here all the labels are updated, e.g. the title, xlabel, etc.. This is the default priority of the :class:`Formatoption.priority` attribute, the :attr:`psyplot.plotter.END` priority.

If there is any formatoption updated within the first two groups, the plot of the plotter is updated. This brings us to the third important attribute, the :attr:`~Formatoption.plot_fmt`. This boolean tells the plotter, whether the corresponding formatoption is assumed to make a plot at the end of the second stage (the :attr:`~psyplot.plotter.BEFOREPLOTTING` stage). If this attribute is True, then the plotter will call the :meth:`Formatoption.make_plot` method of the formatoption instance.

Finally, the :meth:`~Formatoption.initialize_plot` and :meth:`~Formatoption.update` methods, this is were your contribution really is required. The :meth:`~Formatoption.initialize_plot` method is called when the plot is created for the first time, the :meth:`~Formatoption.update` method when it is updated (the default implementation of the :meth:`~Formatoption.initialize_plot` simply calls the :meth:`~Formatoption.update` method). Implement these methods in your formatoption and thereby make use of the interface to the :ref:`data <fmt_data_interface>` and other :ref:`formatoptions <fmt_fmt_interface>`.

Interface to the data

The next set of attributes help you to interface to the data. There are two important parts in this section the interface to the data and the interpretation of the data.

The first part is mainly represented to the :attr:`Formatoption.data` and :attr:`Formatoption.raw_data` attributes. The plotter that contains the formatoption often creates a copy of the data because the data for the visualization might be modified (see for example the :class:`psy_reg.plotter.LinRegPlotter`). This modified data can be accessed through the :attr:`Formatoption.data` and should be the standard approach to access the data within a formatoption. Nevertheless, the original data can be accessed through the :attr:`Formatoption.raw_data` attribute. However, it only makes sense to access this data for formatoption with :attr:`START` :attr:`~Formatoption.priority`.

The result of these two attributes depend on the :attr:`Formatoption.index_in_list` attribute. The data objects in the psyplot framework are either a :class:`xarray.DataArray` or a list of those in a :class:`psyplot.data.InteractiveList`. If the :attr:`~Formatoption.index_in_list` attribute is not None, and the data object is an :class:`~psyplot.data.InteractiveList`, then only the array at the specified position is returned. To completely avoid this issue, you might also use the :attr:`~Formatoption.iter_data` or :attr:`~Formatoption.iter_raw_data` attributes.

The second part in this section is the interpretation of the data and here, the formatoption can use the :attr:`Formatoption.decoder` attribute. This subclass of the :class:`psyplot.data.CFDecoder` helps you to identify the x- and y-variables in the data.

Interfacing to other formatoptions

A formatoption is the lowest level in the psyplot framework. It is represented at multiple levels:

  1. at the lowest level through the subclass of the :class:`Formatoption` class
  2. at the :class:`Plotter` class level which includes the formatoption class as a descriptor (in our example above it's MyPlotter.my_fmt)
  3. at the :class:`Plotter` instance level through
    1. a personalized instance of the corresponding :class:`Formatoption` class (i.e. plotter = MyPlotter(); plotter.my_fmt is not MyPlotter.my_fmt)
    2. an item in the plotter (i.e. plotter = MyPlotter(); plotter['my_fmt'])
  4. In the update methods of the :class:`Plotter`, :class:`psyplot.data.InteractiveBase` and :class:`psyplot.data.ArrayList` as a keyword (i.e. plotter = MyPlotter(); plotter.update(my_fmt='new value'))

Hence, there is one big to the entire framework, that is: the functionality of a new formatoption has to be completely defined through exactly one argument, i.e. it must be possible to assign a value to the formatoption in the plotter.

For complex formatoption, this might indeed be quite a challenge for the developer and there are two solutions to it:

  1. The simple solution for the developer: Allow a dictionary as a formatoption, here we also have the :class:`psyplot.plotter.DictFormatoption` to help you.
  2. Interface to other formatoptions

First solution: Use a :class:`dict`

That said, to implement a formatoption that inserts a custom text and let the user define the size of the text, you either create a formatoption that accepts a text via

class CustomText(DictFormatoption):

    default = {'text': ''}

    text = None

    def validate(self, value):
        if not isinstance(value, dict):
            return {'text': value}
        return value

    def initialize_plot(self, value):
        self.text = self.ax.text(0.2, 0.2, value['text'],
                                 fontsize=value.get('size', 'large'))

    def update(self, value):
        self.text.set_text(value['text'])
        self.text.set_fontsize(value.get('size', 'large'))


class MyPlotter(Plotter):

    my_fmt = CustomText('my_fmt')

and then you could create and update a plotter via

p = MyPlotter(xarray.DataArray([]))
p.update(my_fmt='my text')  # updates the text
p.update(my_fmt={'size': 14})  # updates the size
p.update(my_fmt={'size': 14, 'text': 'Something'})  # updates text and size

This solution has the several advantages:

  • The user does not get confused through too many formatoptions
  • It is easy to allow more keywords for this formatoption

Indeed, the :class:`psy_simple.plotter.Legend` formatoption uses this framework since the :func:`matplotlib.pyplot.legend` function accepts that many keywords that it would be not informative to create a formatoption for each of them.

Otherwise you could of course avoid the :class:`DictFormatoption` and just force the user to always provide a new dictionary.

Second solution: Interact with other formatoptions

Another possibility is to implement a second formatoption for the size of the text. And here, the psyplot framework helps you with several attributes of the :class:`Formatoption` class:

the :attr:`~Formatoption.children` attribute
Forces the listed formatoptions in this list to be updated before the current formatoption is updated
the :attr:`~Formatoption.dependencies` attributes
Same as :attr:`~Formatoption.children` but also forces an update if one of the named formatoptions are updated
the :attr:`~Formatoption.parents` attribute
Skip the update if one of the :attr:`~Formatoption.parents` is updated
the :attr:`~Formatoption.connections` attribute
just provides connections to the listed formatoptions

Each of those attributes accept a list of strings that represent the formatoption keys of other formatoptions. Those formatoptions are then accessible within the formatoption via the usual :func:`getattr`. I.e. if you list a formatoption in the :attr:`~Formatoption.children` attribute, you can access it inside the formatoption (self) via self.other_formatoption.

In our example of the CustomText, this could be implemented via

class CustomTextSize(Formatoption):
    """
    Set the fontsize of the custom text

    Possible types
    --------------
    int
        The fontsize of the text
    """

    default = int

    def validate(self, value):
        return int(value)

    # this text has not to be updated if the custom text is updated
    children = ['text']

    def update(self, value):
        self.text.text.set_fontsize(value)


class CustomText(Formatoption):
    """
    Place a text

    Possible types
    --------------
    str
        The text to display""""

    def initialize_plot(self, value):
        self.text = self.ax.text(0.2, 0.2, value['text'])

    def update(self, value):
        self.text.set_text(value)


class MyPlotter(Plotter):

    my_fmt = CustomText('my_fmt')
    my_fmtsize = CustomTextSize('my_fmtsize', text='my_fmt')

the update in that sense would be like

and then you could create and update a plotter via

p = MyPlotter(xarray.DataArray([]))
p.update(my_fmt='my text')  # updates the text
p.update(my_fmtsize=14)  # updates the size
p.update(my_fmt='Something', my_fmtsize=14)  # updates text and size

The advantages of this methodology are basically:

  • The user straight away sees two formatoptions that can be interpreted easiliy

  • The formatoption that controls the font size could easily be subclassed and replaced in a subclass of MyPlotter. In the first framework using the :class:`DictFormatoption`, this would mean that the entire process has to be rewritten.

    As you see in the above definition my_fmtsize = CustomTextSize('my_fmtsize', text='my_fmt'), we provide an additional text keyword. That is because we explicitly named the text key in the children attribute of the CustomTextSize formatoption. In that way we can tell the my_fmtsize formatoption how to find the necessary formatoption. That works for all keys listed in the :attr:`~Formatoption.children`, :attr:`~Formatoption.dependencies`, :attr:`~Formatoption.parents` and :attr:`~Formatoption.connections` attributes.

Creating new plugins

.. todo::

    The plugin generation needs to be revised

Now that you have created your plotter, you may want to include it in the plot methods of the :class:`~psyplot.project.Project` class such that you can do something like

import psyplot.project as psy
psy.plot.my_plotter('netcdf-file.nc', name='varname')

There are three possibilities how you can do this:

  1. The easy and fast solution for one session: register the plotter using the :func:`psyplot.project.register_plotter` function
  2. The easy and steady solution: Save the calls you used in step 1 in the 'project.plotter.user' key of the :attr:`~psyplot.config.rcsetup.rcParams`
  3. The steady and shareable solution: Create a new plugin

The third solution has been used for the psy-maps and psy-simple plugins. To create a skeleton for your plugin, you can use the psyplot-plugin command that is installed when you install psyplot.

For our demonstration, let's create a plugin named my-plugin. This is simply done via

.. ipython::
    :verbatim:

    In [1]: !psyplot-plugin my-plugin

    In [2]: import glob

    In [3]: glob.glob('my-plugin/**', recursive=True)

The following files are created in a directory named 'my-plugin':

'setup.py'
The installation script
'my_plugin/plugin.py'
The file that sets up the configuration of our plugin. This file should define the rcParams for the plugin (see also :ref:`plugins-rcParams`)
'my_plugin/plotters.py'
The file in which we define the plotters. This file should define the plotters and formatoptions.

If you want to see more, look into the comments in the created files.

rcParams handling in plugins

Every formatoption does have default values. In :ref:`our example above <new_plotters>`, we simply set it via the :attr:`~psyplot.plotter.Formatoption.default` attribute. This is a hard-coded, but easy, stable and quick solution.

However, your formatoption could also be used in different plotters, each requiring a different default value. Or you want to give the user the possibility to set his own default value. For this, we implemented the

.. autosummary::

    psyplot.plotter.Plotter._rcparams_string

attribute. Here you can specify a string for this plotter which is used to get the default value of the formatoptions in this plotter from the :attr:`rcParams <psyplot.config.rcsetup.rcParams>`. The expected :attr:`~psyplot.plotter.Formatoption.default_key` for one formatoption would then be the_chosen_string + fmt_key.

The following example illustrates this:

.. ipython::

    In [1]: from psyplot.config.rcsetup import rcParams
       ...: from psyplot.plotter import Plotter, Formatoption


First we define our defaultParams, a mapping from default key to the default value, a validation function, and a description (see the :data:`psyplot.config.rcsetup.defaultParams` dictionary).

.. ipython::

    In [2]: defaultParams = {
       ...:     'plotter.example_plotter.fmt1': [
       ...:         1, lambda val: int(val), 'Example formatoption']
       ...:     }

Then we update the :attr:`~psyplot.config.rcsetup.RcParams.defaultParams` of the :attr:`psyplot.rcParams <psyplot.config.rcsetup.rcParams>` and set the value

.. ipython::

    In [3]: rcParams.defaultParams.update(defaultParams)

    In [4]: rcParams.update_from_defaultParams(defaultParams)
       ...: print(rcParams['plotter.example_plotter.fmt1'])

Now we define a formatoption for our new plotter class and implement it in a new plotter object.

.. ipython::

    In [5]: class ExampleFmt(Formatoption):
       ...:     def update(self, value):
       ...:         pass

    In [6]: class ExamplePlotter(Plotter):
       ...:     # we use our base string, 'plotter.example_plotter.'
       ...:     _rcparams_string = ['plotter.example_plotter.']
       ...:     # and register a formatoption for the plotter
       ...:     fmt1 = ExampleFmt('fmt1')

If we now create a new instance of this ExamplePlotter, the fmt1 formatoption will have a value of 1, as we defined it in the above defaultParams:

.. ipython::

    In [7]: plotter = ExamplePlotter()

    In [8]: print(plotter['fmt1'])

    # and the default_key is our string in the defaultParams, a combination
    # of the _rcparams_string and the formatoption key
    In [9]: print(plotter.fmt1.default_key)

    In [10]: print(plotter.fmt1.default)

Changing the value in the :attr:`~psyplot.config.rcsetup.rcParams`, also changes the default value for the plotter

.. ipython::

    In [11]: rcParams['plotter.example_plotter.fmt1'] = 2

    In [12]: print(plotter.fmt1.default)

Also, if we subclass this plotter, the default_key will not change

.. ipython::

    In [13]: class SecondPlotter(ExamplePlotter):
       ....:     # we set a new _rcparams_string
       ....:     _rcparams_string = ['plotter.another_plotter.']

    In [14]: plotter = SecondPlotter()

    # still the same key, although we defined a different _rcparams_string
    In [15]: print(plotter.fmt1.default_key)

    @suppress
    In [10]: rcParams.pop('plotter.example_plotter.fmt1', None)
       ....: rcParams.defaultParams.pop('plotter.example_plotter.fmt1', None)

If you're developing a new plugin you would then have to define the rcParams and defaultParams in the plugin.py script (see :ref:`new_plugins`) and they will then be automatically implemented in :attr:`psyplot.rcParams <psyplot.config.rcsetup.rcParams>`.