Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plotting module - Backend support (1.7 branch) #20463

Merged
merged 1 commit into from
Nov 21, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/src/modules/plotting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,17 @@ Series Classes
.. autoclass:: sympy.plotting.plot_implicit::ImplicitSeries
:members:

Backends
--------

.. autoclass:: sympy.plotting.plot::BaseBackend
:members:

.. autoclass:: sympy.plotting.plot::MatplotlibBackend
:members:

.. autoclass:: sympy.plotting.plot::TextBackend
:members:

Pyglet Plotting
---------------
Expand Down
150 changes: 127 additions & 23 deletions sympy/plotting/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ class Plot:
- aspect_ratio : tuple of two floats or {'auto'}
- autoscale : bool
- margin : float in [0, 1]
- backend : {'default', 'matplotlib', 'text'}
- backend : {'default', 'matplotlib', 'text'} or a subclass of BaseBackend
- size : optional tuple of two floats, (width, height); default: None

The per data series options and aesthetics are:
There are none in the base series. See below for options for subclasses.
Expand Down Expand Up @@ -148,7 +149,7 @@ def __init__(self, *args,
xlim=None, ylim=None, axis_center='auto', axis=True,
xscale='linear', yscale='linear', legend=False, autoscale=True,
margin=0, annotations=None, markers=None, rectangles=None,
fill=None, backend='default', **kwargs):
fill=None, backend='default', size=None, **kwargs):
super().__init__()

# Options for the graph as a whole.
Expand Down Expand Up @@ -178,31 +179,36 @@ def __init__(self, *args,
# The backend type. On every show() a new backend instance is created
# in self._backend which is tightly coupled to the Plot instance
# (thanks to the parent attribute of the backend).
self.backend = plot_backends[backend]
if isinstance(backend, str):
self.backend = plot_backends[backend]
elif (type(backend) == type) and issubclass(backend, BaseBackend):
self.backend = backend
else:
raise TypeError(
"backend must be either a string or a subclass of BaseBackend")

is_real = \
lambda lim: all(getattr(i, 'is_real', True) for i in lim)
is_finite = \
lambda lim: all(getattr(i, 'is_finite', True) for i in lim)

# reduce code repetition
def check_and_set(t_name, t):
if t:
if not is_real(t):
raise ValueError(
"All numbers from {}={} must be real".format(t_name, t))
if not is_finite(t):
raise ValueError(
"All numbers from {}={} must be finite".format(t_name, t))
setattr(self, t_name, (float(t[0]), float(t[1])))

self.xlim = None
check_and_set("xlim", xlim)
self.ylim = None
if xlim:
if not is_real(xlim):
raise ValueError(
"All numbers from xlim={} must be real".format(xlim))
if not is_finite(xlim):
raise ValueError(
"All numbers from xlim={} must be finite".format(xlim))
self.xlim = (float(xlim[0]), float(xlim[1]))
if ylim:
if not is_real(ylim):
raise ValueError(
"All numbers from ylim={} must be real".format(ylim))
if not is_finite(ylim):
raise ValueError(
"All numbers from ylim={} must be finite".format(ylim))
self.ylim = (float(ylim[0]), float(ylim[1]))
check_and_set("ylim", ylim)
self.size = None
check_and_set("size", size)


def show(self):
Expand Down Expand Up @@ -381,7 +387,7 @@ class PlotGrid:
[0]: cartesian surface: x*y for x over (-5.0, 5.0) and y over (-5.0, 5.0)

"""
def __init__(self, nrows, ncolumns, *args, show=True, **kwargs):
def __init__(self, nrows, ncolumns, *args, show=True, size=None, **kwargs):
"""
Parameters
==========
Expand Down Expand Up @@ -409,6 +415,10 @@ def __init__(self, nrows, ncolumns, *args, show=True, **kwargs):
of the ``PlotGrid`` class can then be used to save or display the
plot by calling the ``save()`` and ``show()`` methods
respectively.
size : (float, float), optional
A tuple in the form (width, height) in inches to specify the size of
the overall figure. The default value is set to ``None``, meaning
the size will be set by the default backend.
"""
self.nrows = nrows
self.ncolumns = ncolumns
Expand All @@ -417,6 +427,7 @@ def __init__(self, nrows, ncolumns, *args, show=True, **kwargs):
for arg in args:
self._series.append(arg._series)
self.backend = DefaultBackend
self.size = size
if show:
self.show()

Expand Down Expand Up @@ -1082,15 +1093,81 @@ def get_meshes(self):
# Backends
##############################################################################

class BaseBackend:
class BaseBackend(object):
"""Base class for all backends. A backend represents the plotting library,
which implements the necessary functionalities in order to use SymPy
plotting functions.

How the plotting module works:

1. Whenever a plotting function is called, the provided expressions are
processed and a list of instances of the `BaseSeries` class is created,
containing the necessary information to plot the expressions (eg the
expression, ranges, series name, ...). Eventually, these objects will
generate the numerical data to be plotted.
2. A Plot object is instantiated, which stores the list of series and the
main attributes of the plot (eg axis labels, title, ...).
3. When the "show" command is executed, a new backend is instantiated,
which loops through each series object to generate and plot the
numerical data. The backend is also going to set the axis labels, title,
..., according to the values stored in the Plot instance.

The backend should check if it supports the data series that it's given
(eg TextBackend supports only LineOver1DRange).

It's the backend responsibility to know how to use the class of data series
that it's given. Note that the current implementation of the `*Series`
classes is "matplotlib-centric": the numerical data returned by the
`get_points` and `get_meshes` methods is meant to be used directly by
Matplotlib. Therefore, the new backend will have to pre-process the
numerical data to make it compatible with the chosen plotting library.
Keep in mind that future SymPy versions may improve the `*Series` classes in
order to return numerical data "non-matplotlib-centric", hence if you code
a new backend you have the responsibility to check if its working on each
SymPy release.

Please, explore the `MatplotlibBackend` source code to understand how a
backend should be coded.

Methods
=======

In order to be used by SymPy plotting functions, a backend must implement
the following methods:

* `show(self)`: used to loop over the data series, generate the numerical
data, plot it and set the axis labels, title, ...
* save(self, path): used to save the current plot to the specified file
path.
* close(self): used to close the current plot backend (note: some plotting
library doesn't support this functionality. In that case, just raise a
warning).

See also
========

MatplotlibBackend
"""
def __init__(self, parent):
super().__init__()
super(BaseBackend, self).__init__()
self.parent = parent

def show(self):
raise NotImplementedError

def save(self, path):
raise NotImplementedError

def close(self):
raise NotImplementedError


# Don't have to check for the success of importing matplotlib in each case;
# we will only be using this backend if we can successfully import matploblib
class MatplotlibBackend(BaseBackend):
""" This class implements the functionalities to use Matplotlib with SymPy
plotting functions.
"""
def __init__(self, parent):
super().__init__(parent)
self.matplotlib = import_module('matplotlib',
Expand All @@ -1111,7 +1188,7 @@ def __init__(self, parent):
series_list = self.parent._series

self.ax = []
self.fig = self.plt.figure()
self.fig = self.plt.figure(figsize=parent.size)

for i, series in enumerate(series_list):
are_3D = [s.is_3D for s in series]
Expand Down Expand Up @@ -1557,6 +1634,11 @@ def plot(*args, show=True, **kwargs):
If the ``adaptive`` flag is set to ``True``, this will be
ignored.

size : (float, float), optional
A tuple in the form (width, height) in inches to specify the size of
the overall figure. The default value is set to ``None``, meaning
the size will be set by the default backend.

Examples
========

Expand Down Expand Up @@ -1730,6 +1812,11 @@ def plot_parametric(*args, show=True, **kwargs):
ylim : (float, float), optional
Denotes the y-axis limits, ``(min, max)```.

size : (float, float), optional
A tuple in the form (width, height) in inches to specify the size of
the overall figure. The default value is set to ``None``, meaning
the size will be set by the default backend.

Examples
========

Expand Down Expand Up @@ -1891,6 +1978,11 @@ def plot3d_parametric_line(*args, show=True, **kwargs):

``title`` : str. Title of the plot.

``size`` : (float, float), optional
A tuple in the form (width, height) in inches to specify the size of
the overall figure. The default value is set to ``None``, meaning
the size will be set by the default backend.

Examples
========

Expand Down Expand Up @@ -2007,6 +2099,10 @@ def plot3d(*args, show=True, **kwargs):
Arguments for ``Plot`` class:

``title`` : str. Title of the plot.
``size`` : (float, float), optional
A tuple in the form (width, height) in inches to specify the size of the
overall figure. The default value is set to ``None``, meaning the size will
be set by the default backend.

Examples
========
Expand Down Expand Up @@ -2137,6 +2233,10 @@ def plot3d_parametric_surface(*args, show=True, **kwargs):
Arguments for ``Plot`` class:

``title`` : str. Title of the plot.
``size`` : (float, float), optional
A tuple in the form (width, height) in inches to specify the size of the
overall figure. The default value is set to ``None``, meaning the size will
be set by the default backend.

Examples
========
Expand Down Expand Up @@ -2241,6 +2341,10 @@ def plot_contour(*args, show=True, **kwargs):
Arguments for ``Plot`` class:

``title`` : str. Title of the plot.
``size`` : (float, float), optional
A tuple in the form (width, height) in inches to specify the size of
the overall figure. The default value is set to ``None``, meaning
the size will be set by the default backend.

See Also
========
Expand Down
70 changes: 69 additions & 1 deletion sympy/plotting/tests/test_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
plot3d_parametric_surface)
from sympy.plotting.plot import (
unset_show, plot_contour, PlotGrid, DefaultBackend, MatplotlibBackend,
TextBackend)
TextBackend, BaseBackend)
from sympy.testing.pytest import skip, raises, warns
from sympy.utilities import lambdify as lambdify_

Expand All @@ -24,6 +24,28 @@
'matplotlib', min_module_version='1.1.0', catch=(RuntimeError,))


class DummyBackendNotOk(BaseBackend):
""" Used to verify if users can create their own backends.
This backend is meant to raise NotImplementedError for methods `show`,
`save`, `close`.
"""
pass


class DummyBackendOk(BaseBackend):
""" Used to verify if users can create their own backends.
This backend is meant to pass all tests.
"""
def show(self):
pass

def save(self):
pass

def close(self):
pass


def test_plot_and_save_1():
if not matplotlib:
skip("Matplotlib not the default backend")
Expand Down Expand Up @@ -631,3 +653,49 @@ def test_plot3d_parametric_line_limits():
zmin, zmax = backend.ax[0].get_zlim()
assert abs(zmin + 10) < 1e-2
assert abs(zmax - 10) < 1e-2

def test_plot_size():
if not matplotlib:
skip("Matplotlib not the default backend")

x = Symbol('x')

p1 = plot(sin(x), backend="matplotlib", size=(8, 4))
s1 = p1._backend.fig.get_size_inches()
assert (s1[0] == 8) and (s1[1] == 4)
p2 = plot(sin(x), backend="matplotlib", size=(5, 10))
s2 = p2._backend.fig.get_size_inches()
assert (s2[0] == 5) and (s2[1] == 10)
p3 = PlotGrid(2, 1, p1, p2, size=(6, 2))
s3 = p3._backend.fig.get_size_inches()
assert (s3[0] == 6) and (s3[1] == 2)

with raises(ValueError):
plot(sin(x), backend="matplotlib", size=(-1, 3))

def test_issue_20113():
if not matplotlib:
skip("Matplotlib not the default backend")

x = Symbol('x')

# verify the capability to use custom backends
with raises(TypeError):
plot(sin(x), backend=Plot, show=False)
p2 = plot(sin(x), backend=MatplotlibBackend, show=False)
assert p2.backend == MatplotlibBackend
assert len(p2[0].get_segments()) >= 30
p3 = plot(sin(x), backend=DummyBackendOk, show=False)
assert p3.backend == DummyBackendOk
assert len(p3[0].get_segments()) >= 30

# test for an improper coded backend
p4 = plot(sin(x), backend=DummyBackendNotOk, show=False)
assert p4.backend == DummyBackendNotOk
assert len(p4[0].get_segments()) >= 30
with raises(NotImplementedError):
p4.show()
with raises(NotImplementedError):
p4.save("test/path")
with raises(NotImplementedError):
p4._backend.close()