from spb.defaults import TWO_D_B, THREE_D_B, cfg
from spb.ccomplex.complex import _build_series as _build_complex_series
from spb.functions import _set_labels, _plot3d_wireframe_helper
from spb.series import InteractiveSeries, _set_discretization_points
from spb.vectors import _preprocess, _build_series as _build_vector_series
from spb.utils import _plot_sympify, _unpack_args_extended, _validate_kwargs
from sympy import latex, Tuple
from sympy.external import import_module
import warnings
param = import_module(
'param',
min_module_version='1.11.0')
pn = import_module(
'panel',
min_module_version='0.12.0')
pn.extension("plotly", sizing_mode="stretch_width")
class MyList(param.ObjectSelector):
"""Represent a list of numbers discretizing a log-spaced slider.
This parameter will be rendered by pn.widgets.DiscreteSlider
"""
pass
# explicitely ask panel to use DiscreteSlider when it encounters a
# MyList object
pn.Param.mapping[MyList] = pn.widgets.DiscreteSlider
# Define a few CSS rules that are going to overwrite the template's ones.
# They are only going to be used when the interactive application will be
# served to a new browser window.
_CUSTOM_CSS = """
#header {padding: 0}
.title {
font-size: 1em;
font-weight: bold;
padding-left: 10px;
}
"""
_CUSTOM_CSS_NO_HEADER = """
#header {display: none}
"""
class DynamicParam(param.Parameterized):
"""Dynamically add parameters based on the user-provided dictionary.
Also, generate the lambda functions to be evaluated at a later stage.
"""
# NOTE: why DynamicParam is a child class of param.Parameterized?
# param is a full-python library, doesn't depend on anything else.
# In theory, by using a parameterized class it should be possible to
# create an InteractivePlotGUI class targeting a specific GUI.
# At this moment, InteractivePlot is built on top of 'panel', so it only
# works inside a Jupyter Notebook. Maybe it's possible to use PyQt or Tk.
# Each one of the dynamically added parameters (widgets) will execute a
# function that modify this parameter, which in turns will trigger an
# overall update.
check_val = param.Integer(default=0)
def _tuple_to_dict(self, k, v):
"""The user can provide a variable length tuple/list containing:
(default, min, max, N [optional], tick_format [optional],
label [optional], spacing [optional])
where:
default : float
Default value of the slider
min : float
Minimum value of the slider.
max : float
Maximum value of the slider.
N : int
Number of increments in the slider.
(start - end) / N represents the step increment. Default to 40.
Set N=-1 to have unit step increments.
tick_format : bokeh.models.formatters.TickFormatter or None
Default to None. Provide a formatter for the tick value of the
slider.
label : str
Label of the slider. Default to None. If None, the string or
latex representation will be used. See use_latex for more
information.
spacing : str
Discretization spacing. Can be "linear" or "log".
Default to "linear".
"""
np = import_module('numpy')
if not hasattr(v, "__iter__"):
raise TypeError(
"Provide a tuple or list for the parameter {}".format(k))
if len(v) >= 5:
# remove tick_format, as it won't be used for the creation of the
# parameter. Its value has already been stored.
v = list(v)
v.pop(4)
N = 40
defaults_keys = ["default", "softbounds", "step", "label", "type"]
defaults_values = [
1,
0,
2,
N,
"$%s$" % latex(k) if self._use_latex else str(k),
"linear",
]
values = defaults_values.copy()
values[: len(v)] = v
# set the step increment for the slider
_min, _max = float(values[1]), float(values[2])
if values[3] > 0:
N = int(values[3])
values[3] = (_max - _min) / N
else:
values[3] = 1
if values[-1] == "log":
# In case of a logarithm slider, we need to instantiate the
# custom parameter MyList.
# # divide the range in N steps evenly spaced in a log scale
options = np.geomspace(_min, _max, N)
# the provided default value may not be in the computed options.
# If that's the case, I chose the closest value
default = values[0]
if default not in options:
default = min(options, key=lambda x: abs(x - default))
return MyList(default=default, objects=list(options), label=values[4])
# combine _min, _max into softbounds tuple
values = [
float(values[0]),
(_min, _max),
*values[3:]
]
return {k: v for k, v in zip(defaults_keys, values)}
def __init__(self, *args, name="", params=None, **kwargs):
bokeh = import_module(
'bokeh',
import_kwargs={'fromlist': ['models']},
min_module_version='2.3.0')
TickFormatter = bokeh.models.formatters.TickFormatter
# use latex on control labels and legends
self._use_latex = kwargs.pop("use_latex", True)
self._name = name
# remove the previous class attributes added by the previous instances
cls_name = type(self).__name__
prev_params = [k for k in type(self).__dict__.keys() if "dyn_param_" in k]
for p in prev_params:
delattr(type(self), p)
# this must be present in order to assure correct behaviour
super().__init__(name=name, **kwargs)
if not params:
raise ValueError("`params` must be provided.")
self._original_params = params
# The following dictionary will be used to create the appropriate
# lambda function arguments:
# key: the provided symbol
# val: name of the associated parameter
self.mapping = {}
# NOTE: unfortunately, parameters from the param library do not
# provide a keyword argument to set a formatter for the slider's tick
# value. As a workaround, the user can provide a formatter for each
# parameter, which will be stored in the following dictionary and
# later used in the instantiation of the widgets.
self.formatters = {}
# create and attach the params to the class
for i, (k, v) in enumerate(params.items()):
# store the formatter
formatter = None
if isinstance(v, (list, tuple)) and (len(v) >= 5):
if (v[4] is not None) and (not isinstance(v[4], TickFormatter)):
raise TypeError(
"To format the tick value of the widget associated " +
"to the symbol {}, an instance of ".format(k) +
"bokeh.models.formatters.TickFormatter is expected. " +
"Instead, an instance of {} was given.".format(
type(v[4])))
formatter = v[4]
self.formatters[k] = formatter
if not isinstance(v, param.parameterized.Parameter):
v = self._tuple_to_dict(k, v)
# at this stage, v could be a dictionary representing a number,
# or a MyList parameter, representing a log slider
if not isinstance(v, param.parameterized.Parameter):
v.pop("type", None)
v = param.Number(**v)
param_name = "dyn_param_{}".format(i)
# TODO: using a private method: not the smartest thing to do
self.param._add_parameter(param_name, v)
self.param.watch(self._increment_val, param_name)
self.mapping[k] = param_name
def _increment_val(self, *depends):
self.check_val += 1
def read_parameters(self):
# TODO: check if param is still available, otherwise raise error
readout = dict()
for k, v in self.mapping.items():
readout[k] = getattr(self, v)
return readout
@param.depends("check_val", watch=True)
def update(self):
params = self.read_parameters()
# NOTE: in case _backend is not an attribute, it means that this
# class has been instantiated by create_widgets
if hasattr(self, "_backend"):
self._backend._update_interactive(params)
self._action_post_update()
def _new_class(cls, **kwargs):
"Creates a new class which overrides parameter defaults."
return type(type(cls).__name__, (cls,), kwargs)
class PanelLayout:
"""Mixin class to group together the layout functionalities related to
the library panel.
"""
def __init__(self, layout, ncols, throttled=False, servable=False, custom_css="", pane_kw=None):
"""
Parameters
==========
layout : str
The layout for the controls/plot. Possible values:
'tb': controls in the top bar.
'bb': controls in the bottom bar.
'sbl': controls in the left side bar.
'sbr': controls in the right side bar.
Default layout to 'tb'.
ncols : int
Number of columns to lay out the widgets. Default to 2.
throttled : boolean, optional
Default to False. If True the recompute will be done at
mouse-up event on sliders. If False, every slider tick will
force a recompute.
servable : boolean, optional
Default to False. If True the application will be served on
a new browser window and a template will be applied to it.
custom_css : str, optional
This functionality is not yet fully implemented, please don't
use it.
pane_kw : dict, optional
A dictionary of keyword arguments that are going to be passed
to the pane containing the chart.
"""
# NOTE: More often than not, the numerical evaluation is going to be
# resource-intensive. By default, panel's sliders will force a
# recompute at every step change. As a consequence, the user
# experience will be laggy. To solve this problem, the update must
# be triggered on mouse-up event, which is set using throttled=True.
#
# https://panel.holoviz.org/reference/panes/Param.html#disabling-continuous-updates-for-slider-widgets
layouts = ["tb", "bb", "sbl", "sbr"]
layout = layout.lower()
if layout not in layouts:
warnings.warn(
"`layout` must be one of the following: {}\n".format(layouts)
+ "Falling back to layout='tb'."
)
layout = "tb"
self._layout = layout
self._ncols = ncols
self._throttled = throttled
self._servable = servable
self._custom_css = custom_css
self._pane_kw = pane_kw
# NOTE: here I create a temporary panel.Param object in order to
# reuse the code from the pn.Param.widget method, which returns the
# correct widget associated to a given parameter's type.
# Alternatively, I would need to copy parts of that method in order
# to deal with the different representations of param.Integer and
# param.Number depending if the bounds are None values.
# Note that I'm only interested in the widget type: panel is then
# going to recreate the widgets and setting the proper throttled
# value. This is definitely not an optimal procedure, as we are
# creating the "same" widget two times, but it works :)
tmp_panel = pn.Param(self)
widgets = dict()
for k, v in self.mapping.items():
widgets[v] = {"type": type(tmp_panel.widget(v))}
t = getattr(self.param, v)
if isinstance(t, param.Number):
widgets[v]["throttled"] = throttled
if self.formatters[k] is not None:
widgets[v]["format"] = self.formatters[k]
self.controls = pn.Param(
self,
parameters=list(self.mapping.values()),
widgets=widgets,
default_layout=_new_class(pn.GridBox, ncols=ncols),
show_name=False,
sizing_mode="stretch_width",
)
def _init_pane(self):
"""Here we wrap the figure exposed by the backend with a Pane, which
allows to set useful properties.
"""
# NOTE: If the following import statement was located at the
# beginning of the file, there would be a circular import.
from spb import KB, MB
default_kw = dict()
if isinstance(self._backend, KB):
# TODO: for some reason, panel is going to set width=0
# if K3D-Jupyter is used.
# Temporary workaround: create a Pane with a default width.
# Long term solution: create a PR on panel to create a K3DPane
# so that panel will automatically deal with K3D, in the same
# way it does with Bokeh, Plotly, Matplotlib, ...
default_kw["width"] = 800
elif isinstance(self._backend, MB):
# since we are using Jupyter and interactivity, it is useful to
# activate ipympl interactive frame, as well as setting a lower
# dpi resolution of the matplotlib image
default_kw["dpi"] = 96
# NOTE: the following must be set to False in order for the
# example outputs to become visible on Sphinx.
default_kw["interactive"] = False
merge = self._backend.merge
kw = merge({}, default_kw, self._pane_kw)
# NOTE: If the following import statement was located at the
# beginning of the file, there would be a circular import.
from spb import PB
if isinstance(self._backend, PB):
# NOTE: while pn.pane.Plotly can receive an instance of go.Figure,
# the update will be extremely slow, because after each trace
# is updated it will trigger an update event on the javascript
# side. Instead, by providing the following dictionary, first the
# traces are updated, then the pane creates the figure which will
# only be at last.
# TODO: can the backend be modified by adding data and layout
# attributes, avoiding the creation of the figure? The figure could
# be created inside the fig getter.
d = dict(data=list(self.fig.data), layout=self.fig.layout)
self.pane = pn.pane.Plotly(d, **kw)
else:
self.pane = pn.pane.Pane(self.fig, **kw)
def layout_controls(self):
return self.controls
def _action_post_update(self):
# NOTE: If the following import statement was located at the
# beginning of the file, there would be a circular import.
from spb import KB, PB
if not isinstance(self._backend, KB):
# KB exhibits a strange behavior when executing the following
# lines. For the moment, do not execute them with KB
self.pane.param.trigger("object")
# self.pane.object = self.fig
if not isinstance(self._backend, PB):
self.pane.object = self.fig
else:
# NOTE: sadly, there is a bug with Panel and Plotly: if the
# user modifies the layout of the chart (for example zoom or
# rotate the view), the next update will reset them.
# https://github.com/holoviz/panel/issues/1801
d = dict(data=list(self.fig.data), layout=self.fig.layout)
self.pane.object = d
def show(self):
self._init_pane()
if self._layout == "tb":
content = pn.Column(self.layout_controls, self.pane)
elif self._layout == "bb":
content = pn.Column(self.pane, self.layout_controls)
elif self._layout == "sbl":
content = pn.Row(pn.Column(self.layout_controls, css_classes=["iplot-controls"], width=250, sizing_mode="fixed"), pn.Column(self.pane), width_policy="max")
elif self._layout == "sbr":
content = pn.Row(pn.Column(self.pane), pn.Column(self.layout_controls, css_classes=["iplot-controls"]))
if not self._servable:
return content
css = _CUSTOM_CSS + self._custom_css
if len(self._name.strip()) == 0:
css = _CUSTOM_CSS_NO_HEADER + self._custom_css
# theme = pn.template.vanilla.VanillaDarkTheme if cfg["interactive"]["theme"] == "dark" else pn.template.vanilla.VanillaDefaultTheme
# vanilla = pn.template.VanillaTemplate(title=self._name, theme=theme)
# vanilla.main.append(content)
# vanilla.config.raw_css.append(css)
theme = pn.template.bootstrap.BootstrapDarkTheme if cfg["interactive"]["theme"] == "dark" else pn.template.bootstrap.BootstrapDefaultTheme
vanilla = pn.template.BootstrapTemplate(title=self._name, theme=theme)
vanilla.main.append(content)
vanilla.config.raw_css.append(css)
return vanilla.servable().show()
[docs]def create_series(*args, **kwargs):
"""
Create interactive data series, ie. series whose numerical data depends
on the expression and all its parameters.
Typical usage examples are in the followings:
- Create a single interactive series:
`create_series([expr, range], **kwargs)`
- Create multiple interactive series:
`create_series([(expr1, range1), (expr2, range2)], **kwargs)`
The correct series type is instantiated only if all ranges are specified.
So, to create an interactive line series, one range must be specified.
To create an interactive surface series, two ranges must be provided, and
so on.
Notes
=====
The keyword arguments to be provided depends on the interested data series
to be created. For example, if we are trying to plot a line, then the
same keyword argument associated to the ``plot()`` function can be used.
Similarly, if we are trying to create vector-related interactive series,
the same keyword arguments associated to ``plot_vector()`` can be used.
And so on.
However, interactive data series do not support adaptive algorithms.
Hence, adaptive-related keyword arguments will not be used.
Parameters
==========
args : list/tuple
A list or tuple of the form ``[(expr1, range1), ...]``, where:
expr : Expr
Expression (or expressions) representing the function to evaluate.
range: (symbol, min, max)
A 3-tuple (or multiple 3-tuple) denoting the range of the
variable. For the function to work properly, all ranges must be
provided.
params : dict
A dictionary mapping symbols to numerical values. If not specified,
``iplot`` should be provided instead.
iplot : InteractivePlot, optional
An existing instance of ``InteractivePlot`` from which the parameters
will be extracted. If both ``params`` and ``iplot`` are provided, then
``iplot`` has the precedence.
is_complex : boolean, optional
Default to False. If True, it directs the internal algorithm to
create all the necessary series to create a complex plot (for example,
one for the real part, one for the imaginary part, ...).
is_polar : boolean, optional
Default to False. If True:
* for a 2D line plot requests the backend to use a polar chart.
* for a 3D surface (or contour) requests a polar discretization.
In this case, the first range represents the radius, the second one
represents the angle.
is_vector : boolean, optional
Default to False. If True, it directs the internal algorithm to
create all the necessary series to create a vector plot (for example,
plotting the magnitude of the vector field as a contour plot, ...).
n1, n2, n3 : int, optional
Number of discretization points in the 3 directions.
n : int, optional
Set the same number of discretization points on all directions.
Returns
=======
s : list
A list of interactive data series.
Examples
========
>>> from sympy import (symbols, pi, sin, cos, exp, Plane,
... Matrix, gamma, I, sqrt, Abs)
>>> from spb.interactive import create_series
>>> u, v, x, y, z = symbols('u, v, x:z')
2D line interactive series:
>>> s = create_series((u * sqrt(x), (x, -5, 5)), params={u: 1})
>>> print(len(s), type(s[0]))
(1, spb.series.LineInteractiveSeries)
2D parametric line interactive series:
>>> s = create_series((u * cos(x), u * sin(x), (x, -5, 5)), params={u: 1})
>>> len(s), type(s[0])
(1, spb.series.Parametric2DLineInteractiveSeries)
Multiple 2D lines interactive series:
>>> s = create_series(
... (u * sqrt(x), (x, -5, 5)),
... (cos(u * x), (x, -3, 3)),
... params={u: 1})
>>> len(s), type(s[0]), type(s[1])
(2, spb.series.LineInteractiveSeries, spb.series.LineInteractiveSeries)
Surface interactive series:
>>> s = create_series((cos(x**2 + u * y**2), (x, -5, 5), (y, -5, 5)),
... params={u: 1}, threed=True)
>>> len(s), type(s[0])
(1, spb.series.SurfaceInteractiveSeries)
Contour interactive series:
>>> s = create_series((cos(x**2 + u * y**2), (x, -5, 5), (y, -5, 5)),
... params={u: 1}, threed=False)
>>> len(s), type(s[0])
(1, spb.series.ContourInteractiveSeries)
Interactive series of the absolute value of a complex function colored
by its argument over a real domain. Note that we are passing
``is_complex=True``:
>>> s = create_series((u * sqrt(x), (x, -5, 5)), params={u: 1},
... is_complex=True)
>>> len(s), type(s[0])
(1, spb.series.AbsArgLineInteractiveSeries)
Real and imaginary parts of a complex function over a real domain:
>>> s = create_series((u * sqrt(x), (x, -5, 5)), params={u: 1},
... is_complex=True, absarg=False, real=True, imag=True)
>>> len(s), type(s[0]), type(s[1])
(2, spb.series.LineInteractiveSeries, spb.series.LineInteractiveSeries)
Complex domain coloring interactive series:
>>> s = create_series((u * sqrt(x), (x, -5-5j, 5+5j)), params={u: 1},
... is_complex=True)
>>> len(s), type(s[0])
Real and imaginary parts of a complex function over a complex domain:
>>> s = create_series((u * sqrt(x), (x, -5-5j, 5+5j)), params={u: 1},
... is_complex=True, threed=True, absarg=False, real=True, imag=True)
>>> len(s), type(s[0]), type(s[1])
(2, spb.series.ComplexSurfaceInteractiveSeries, spb.series.ComplexSurfaceInteractiveSeries)
2D vector interactive series (only quivers):
>>> from sympy.vector import CoordSys3D
>>> N = CoordSys3D("N")
>>> i, j, k = N.base_vectors()
>>> x, y, z = N.base_scalars()
>>> a, b, c = symbols("a:c")
>>> v1 = -a * sin(y) * i + b * cos(x) * j
>>> s = create_series((v1, (x, -5, 5), (y, -4, 4)),
... params={a: 2, b: 3}, is_vector=False)
>>> len(s), type(s[0])
(1, spb.series.Vector2DInteractiveSeries)
2D vector interactive series (contour + quivers):
>>> s = create_series((v1, (x, -5, 5), (y, -4, 4)),
... params={a: 2, b: 3}, is_vector=True)
>>> len(s), type(s[0]), type(s[1])
(2, spb.series.ContourInteractiveSeries, spb.series.Vector2DInteractiveSeries)
Sliced 3D vector (single slice):
>>> from sympy import Plane
>>> v3 = a * z * i + b * y * j + c * x * k
>>> s = create_series((v3, (x, -5, 5), (y, -4, 4), (z, -6, 6)),
... params={a: 2, b: 3, c: 1}, slice=Plane((1, 2, 3), (1, 0, 0)))
>>> len(s), type(s[0])
(1, spb.series.SliceVector3DInteractiveSeries)
Geometry interactive series:
>>> from sympy import Circle
>>> s = create_series((Circle((0, 0), u), ), params={u: 1})
>>> len(s), type(s[0])
(1, spb.series.GeometryInteractiveSeries)
See also
========
iplot, create_widgets
"""
args = list(map(_plot_sympify, args))
iplot_obj = kwargs.pop("iplot", None)
if iplot_obj is not None:
# read the parameters to generate the initial numerical data for
# the interactive series
kwargs["params"] = iplot_obj.read_parameters()
kwargs = _set_discretization_points(kwargs, InteractiveSeries)
_slice = kwargs.get("slice", None)
is_complex = kwargs.get("is_complex", False)
is_vector = kwargs.get("is_vector", False)
series = []
if is_complex:
new_args = []
for a in args:
exprs, ranges, label, rkw = _unpack_args_extended(
*a, matrices=False, fill_ranges=False
)
new_args.append(Tuple(exprs[0], *ranges, label, rkw, sympify=False))
series = _build_complex_series(*new_args, interactive=True, **kwargs)
elif is_vector:
args = _preprocess(*args, matrices=False, fill_ranges=False)
series = _build_vector_series(*args, interactive=True, **kwargs)
else:
for a in args:
# with interactive-parametric plots, vectors could have more
# free symbols than the number of dimensions. We set
# fill_ranges=False in order to not fill ranges, otherwise
# ranges will be created also for parameters. This means
# the user must provided all the necessary ranges.
exprs, ranges, label, rkw = _unpack_args_extended(
*a, matrices=True, fill_ranges=False
)
kwargs["rendering_kw"] = rkw
if isinstance(_slice, (tuple, list)):
# Sliced 3D vector field: each slice creates a
# unique series
kwargs2 = kwargs.copy()
kwargs2.pop("slice")
for s in _slice:
kwargs2["slice"] = s
series.append(
InteractiveSeries(exprs, ranges, label, **kwargs2)
)
else:
series.append(InteractiveSeries(exprs, ranges, label, **kwargs))
return series
class InteractivePlot(DynamicParam, PanelLayout):
def __new__(cls, *args, **kwargs):
return object.__new__(cls)
def __init__(self, *args, name="", params=None, **kwargs):
"""
Parameters
==========
args : tuple
The usual plot arguments
name : str
Name of the interactive application
params : dict
In the keys there will be the symbols, in the values there
will be parameters to create the slider associated to
a symbol.
kwargs : dict
Usual keyword arguments to be used by the backends and series.
"""
original_kwargs = kwargs.copy()
layout = kwargs.pop("layout", "tb")
ncols = kwargs.pop("ncols", 2)
throttled = kwargs.pop("throttled", cfg["interactive"]["throttled"])
servable = kwargs.pop("servable", cfg["interactive"]["servable"])
use_latex = kwargs.pop("use_latex", cfg["interactive"]["use_latex"])
pane_kw = kwargs.pop("pane_kw", dict())
# NOTE: do not document these arguments yet, they might change in the
# future.
custom_css = kwargs.pop("custom_css", "")
self._name = name
super().__init__(*args, name=self._name, params=params, use_latex=use_latex)
PanelLayout.__init__(self, layout, ncols, throttled, servable, custom_css, pane_kw)
# create the series and apply the global labels and rendering keywords
labels = kwargs.pop("label", [])
rendering_kw = kwargs.pop("rendering_kw", None)
# NOTE: plot_list and List2DSeries are different then the other series.
# There is no List2DInteractiveSeries (as a design choice, for
# simplicity). Hence, that function is going to create the necessary
# series.
series = kwargs.pop("series", None)
if series is None:
series = create_series(*args, iplot=self, **kwargs)
_set_labels(series, labels, rendering_kw)
series += _plot3d_wireframe_helper(series, **kwargs)
if kwargs.get("is_complex", False):
from spb.ccomplex.complex import _set_axis_labels
_set_axis_labels(series, kwargs)
is_3D = all([s.is_3D for s in series])
# create the plot
Backend = kwargs.pop("backend", THREE_D_B if is_3D else TWO_D_B)
kwargs["is_iplot"] = True
self._backend = Backend(*series, **kwargs)
_validate_kwargs(self._backend, **original_kwargs)
@property
def fig(self):
"""Return the plot object"""
return self._backend.fig
@property
def backend(self):
"""Return the backend"""
return self._backend
def save(self, *args, **kwargs):
"""Save the current figure.
This is a wrapper to the backend's `save` function. Refer to the
backend's documentation to learn more about arguments and keyword
arguments.
"""
self._backend.save(*args, **kwargs)
def __add__(self, other):
return self._do_sum(other)
def __radd__(self, other):
return other._do_sum(self)
def _do_sum(self, other):
"""Differently from Plot.extend, this method creates a new plot object,
which uses the series of both plots and merges the _kwargs dictionary
of `self` with the one of `other`.
"""
from spb.backends.base_backend import Plot
mergedeep = import_module('mergedeep')
merge = mergedeep.merge
if not isinstance(other, (Plot, InteractivePlot)):
raise TypeError(
"Both sides of the `+` operator must be instances of the "
"InteractivePlot or Plot class.\n"
"Received: {} + {}".format(type(self), type(other)))
series = self._backend.series
if isinstance(other, Plot):
series.extend(other.series)
else:
series.extend(other._backend.series)
# check that the interactive series uses the same parameters
symbols = []
for s in series:
if s.is_interactive:
symbols.append(list(s.params.keys()))
if not all(t == symbols[0] for t in symbols):
raise ValueError(
"The same parameters must be used when summing up multiple "
"interactive plots.")
backend_kw = self._backend._copy_kwargs()
panel_kw = {
"backend": type(self._backend),
"layout": self._layout,
"ncols": self._ncols,
"throttled": self._throttled,
"use_latex": self._use_latex,
"params": self._original_params,
"show": False
}
new_iplot = type(self)(**merge({}, backend_kw, panel_kw))
new_iplot._backend.series.extend(series)
return new_iplot
[docs]def iplot(*args, show=True, **kwargs):
"""Create an interactive application containing widgets and charts in order
to study symbolic expressions.
Note: this function is already integrated with many of the usual
plotting functions: since their documentation is more specific, it is
highly recommended to use those instead.
However, the following documentation explains in details the main features
exposed by the interactive module, which might not be included on the
documentation of those other functions.
Parameters
==========
args : tuples
Each tuple represents an expression. Depending on the type of
expression, the tuple should have the following forms:
1. line:
`(expr, range, label [optional])`
2. parametric line:
`(expr1, expr2, expr3 [optional], range, label [optional])`
3. surface:
`(expr, range1, range2, label [optional])`
4. parametric surface:
`(expr1, expr2, expr3, range1, range2, label [optional])`
The label is always optional, whereas the ranges must always be
specified. The ranges will create the discretized domain.
params : dict
A dictionary mapping the symbols to a parameter. The parameter can be:
1. an instance of `param.parameterized.Parameter`.
2. a tuple of the form:
`(default, min, max, N, tick_format, label, spacing)`
where:
- default, min, max : float
Default value, minimum value and maximum value of the slider,
respectively. Must be finite numbers.
- N : int, optional
Number of steps of the slider.
- tick_format : TickFormatter or None, optional
Provide a formatter for the tick value of the slider. If None,
`panel` will automatically apply a default formatter.
Alternatively, an instance of
`bokeh.models.formatters.TickFormatter` can be used.
Default to None.
- label: str, optional
Custom text associated to the slider.
- spacing : str, optional
Specify the discretization spacing. Default to `"linear"`, can
be changed to `"log"`.
Note that the parameters cannot be linked together (ie, one parameter
cannot depend on another one).
aspect : (float, float) or str, optional
Set the aspect ratio of the plot. The value depends on the backend
being used. Read that backend's documentation to find out the
possible values.
backend : Plot, optional
The backend to be used to generate the plot. It must be a subclass of
`spb.backends.base_backend.Plot`. If not provided, the module will
use the default backend.
color_func : callable, optional
A numerical function which defines the 2D line color or the surface
color:
- For 2D plots, a function of two variables is needed: x, y (the
points computed by the internal algorithm).
- For 3D surfaces, a function of three variables is needed: x, y, z
(the points computed by the internal algorithm).
- For 3D parametric surfaces, a function of five variables is needed:
x, y, z, u, v (the points computed by the internal algorithm and the
parameters).
For surface plots, the coloring is applied only if ``use_cm=True``.
label : list/tuple, optional
The labels to be shown in the legend. If not provided, the string
representation of `expr` will be used. The number of labels must be
equal to the number of series generated by the plotting function.
layout : str, optional
The layout for the controls/plot. Possible values:
- `'tb'`: controls in the top bar.
- `'bb'`: controls in the bottom bar.
- `'sbl'`: controls in the left side bar.
- `'sbr'`: controls in the right side bar.
Default layout to `'tb'`. Note that side bar layouts may not
work well with some backends.
ncols : int, optional
Number of columns to lay out the widgets. Default to 2.
is_complex : boolean, optional
Default to False. If True, it directs the internal algorithm to
create all the necessary series to create a complex plot (for example,
one for the real part, one for the imaginary part).
is_vector : boolean, optional
Default to False. If True, it directs the internal algorithm to
create all the necessary series to create a vector plot (for example,
plotting the magnitude of the vector field as a contour plot).
name : str, optional
The name to be shown on top of the interactive application, when
served on a new browser window. Refer to ``servable`` to learn more.
Default to an empty string.
pane_kw : dict, optional
A dictionary of keyword/values which is passed to the pane containing
the chart in order to further customize the output (read the Notes
section to understand how the interactive plot is built).
The following web pages shows the available options:
* Refer to [#fn2]_ for ``MatplotlibBackend``. Two interesting options
are:
* ``interactive``: wheter to activate the ipympl interactive backend.
* ``dpi``: set the dots per inch of the output png. Default to 96.
* Refer to [#fn3]_ for ``PlotlyBackend``.
rendering_kw : dict or list of dicts, optional
A dictionary of keywords/values which is passed to the backend's
function to customize the appearance of lines, surfaces, etc.
Refer to the plotting library (backend) manual for more informations.
If a list of dictionaries is provided, the number of dictionaries must
be equal to the number of series generated by the plotting function.
servable : bool, optional
Default to False, which will show the interactive application on the
output cell of a Jupyter Notebook. If True, the application will be
served on a new browser window.
show : bool, optional
Default to True.
If True, it will return an object that will be rendered on the
output cell of a Jupyter Notebook. If False, it returns an instance
of `InteractivePlot`, which can later be be shown by calling the
`show()` method.
use_latex : bool, optional
Default to True.
If True, the latex representation of the symbols will be used in the
labels of the parameter-controls. If False, the string
representation will be used instead.
detect_poles : boolean
Chose whether to detect and correctly plot poles in line plots.
Defaulto to `False`. To improve detection, increase the number of
discretization points `n` and/or change the value of `eps`.
eps : float
An arbitrary small value used by the `detect_poles` algorithm.
Default value to 0.1. Before changing this value, it is recommended to
increase the number of discretization points.
n1, n2, n3 : int, optional
Set the number of discretization points in the three directions,
respectively.
n : int, optional
Set the number of discretization points on all directions.
It overrides `n1, n2, n3`.
nc : int, optional
Number of discretization points for the contour plot when
`is_vector=True`.
throttled : boolean, optional
Default to False. If True the recompute will be done at mouse-up event
on sliders. If False, every slider tick will force a recompute.
title : str, optional
Title of the plot.
xlabel : str, optional
Label for the x-axis.
ylabel : str, optional
Label for the y-axis.
zlabel : str, optional
Label for the z-axis.
xlim : (float, float), optional
Denotes the x-axis limits, `(min, max)`.
ylim : (float, float), optional
Denotes the y-axis limits, `(min, max)`.
zlim : (float, float), optional
Denotes the z-axis limits, `(min, max)`.
Examples
========
Surface plot between -10 <= x, y <= 10 with a damping parameter varying
from 0 to 1, with a default value of 0.15, discretized with 50 points
on both directions. Note the use of `threed=True` to specify a 3D plot.
If `threed=False`, a contour plot will be generated.
.. code-block:: python
from sympy import (symbols, sqrt, cos, exp, sin, pi, re, im,
Matrix, Plane, Polygon, I, log)
from spb.interactive import iplot
from spb import PB
x, y, z = symbols("x, y, z")
r = sqrt(x**2 + y**2)
d = symbols('d')
expr = 10 * cos(r) * exp(-r * d)
iplot(
(expr, (x, -10, 10), (y, -10, 10)),
params = { d: (0.15, 0, 1) },
title = "My Title",
xlabel = "x axis",
ylabel = "y axis",
zlabel = "z axis",
backend = PB,
n = 51,
threed = True,
use_cm = True,
use_latex=False,
wireframe = True, wf_n1=15, wf_n2=15,
wf_rendering_kw={"line_color": "#003428", "line_width": 0.75}
)
A line plot of the magnitude of a transfer function, illustrating the use
of multiple expressions and:
1. some expression may not use all the parameters.
2. custom labeling of the expressions.
3. custom rendering of the expressions.
4. custom number of steps in the slider.
5. custom format of the value shown on the slider. This might be useful to
correctly visualize very small or very big numbers.
6. custom labeling of the parameter-sliders.
.. code-block:: python
from sympy import (symbols, sqrt, cos, exp, sin, pi, re, im,
Matrix, Plane, Polygon, I, log)
from spb.interactive import iplot
from spb import MB
from bokeh.models.formatters import PrintfTickFormatter
formatter = PrintfTickFormatter(format="%.3f")
kp, t, z, o = symbols("k_P, tau, zeta, omega")
G = kp / (I**2 * t**2 * o**2 + 2 * z * t * o * I + 1)
mod = lambda x: 20 * log(sqrt(re(x)**2 + im(x)**2), 10)
iplot(
(mod(G.subs(z, 0)), (o, 0.1, 100), "G(z=0)", {"linestyle": ":"}),
(mod(G.subs(z, 1)), (o, 0.1, 100), "G(z=1)", {"linestyle": ":"}),
(mod(G), (o, 0.1, 100), "G"),
params = {
kp: (1, 0, 3),
t: (1, 0, 3),
z: (0.2, 0, 1, 200, formatter, "z")
},
backend = MB,
n = 2000,
xscale = "log",
xlabel = "Frequency, omega, [rad/s]",
ylabel = "Magnitude [dB]",
use_latex = False,
)
A line plot with a parameter representing an angle in radians, but
showing the value in degrees on its label:
.. code-block:: python
from sympy import sin, pi, symbols
from spb import MB
from spb.interactive import iplot
from bokeh.models.formatters import FuncTickFormatter
# Javascript code is passed to `code=`
formatter = FuncTickFormatter(code="return (180./3.1415926 * tick).toFixed(2)")
x, t = symbols("x, t")
iplot(
(1 + x * sin(t), (x, -5, 5)),
params = {
t: (1, -2 * pi, 2 * pi, 100, formatter, "theta [deg]")
},
backend = MB,
xlabel = "x", ylabel = "y",
ylim = (-3, 4),
use_latex = False,
)
Combine together `InteractivePlot` and ``Plot`` instances. The same
parameters dictionary must be used for every ``iplot`` command. Note:
1. the first plot dictates the labels, title and wheter to show the legend
or not.
2. Instances of ``Plot`` class must be place on the right side of the `+`
sign.
3. `show=False` has been set in order for ``iplot`` to return an instance
of ``InteractivePlot``, which supports addition.
4. Once we are done playing with parameters, we can access the backend
with ``p.backend``. Then, we can use the ``p.backend.fig`` attribute
to retrieve the figure, or ``p.backend.save()`` to save the figure.
.. code-block:: python
from sympy import sin, cos, symbols
from spb import plot, MB
from spb.interactive import iplot
x, u = symbols("x, u")
params = {
u: (1, 0, 2)
}
p1 = iplot(
(cos(u * x), (x, -5, 5)),
params = params,
backend = MB,
xlabel = "x1",
ylabel = "y1",
title = "title 1",
legend = True,
show = False,
use_latex = False
)
p2 = iplot(
(sin(u * x), (x, -5, 5)),
params = params,
backend = MB,
xlabel = "x2",
ylabel = "y2",
title = "title 2",
show = False
)
p3 = plot(sin(x)*cos(x), (x, -5, 5), dict(marker="^"), backend=MB,
adaptive=False, n=50,
is_point=True, is_filled=True, show=False)
p = p1 + p2 + p3
p.show()
Serves the interactive plot to a separate browser window. Note that
``K3DBackend`` is not supported for this operation mode. Also note the
two ways to create a integer sliders.
.. code-block:: python
import param
from spb.backends.bokeh import BB
from bokeh.models.formatters import PrintfTickFormatter
formatter = PrintfTickFormatter(format='%.4f')
p1, p2, t, r, c = symbols("p1, p2, t, r, c")
phi = - (r * t + p1 * sin(c * r * t) + p2 * sin(2 * c * r * t))
phip = phi.diff(t)
r1 = phip / (1 + phip)
iplot(
(r1, (t, 0, 2*pi)),
params = {
p1: (0.035, -0.035, 0.035, 50, formatter),
p2: (0.005, -0.02, 0.02, 50, formatter),
# integer parameter created with param
r: param.Integer(2, softbounds=(2, 5), label="r"),
# integer parameter created with usual syntax
c: (3, 1, 5, 4)
},
is_polar = True,
use_latex = False,
backend = BB,
aspect = "equal",
n = 5000,
layout = "sbl",
ncols = 1,
servable = True,
name = "Non Circular Planetary Drive - Ring Profile"
)
Notes
=====
1. This function is specifically designed to work within Jupyter Notebook.
It is also possible to use it from a regular Python console,
by executing: ``iplot(..., servable=True)``, which will create a server
process loading the interactive plot on the browser.
However, ``K3DBackend`` is not supported in this mode of operation.
2. The interactive application generated by ``iplot`` consists of two main
containers:
* a pane containing the widgets.
* a pane containing the chart. We can further customize this container
by setting the ``pane_kw`` dictionary. Please, read its documentation
to understand the available options.
3. Some examples use an instance of ``PrintfTickFormatter`` to format the
value shown by a slider. This class is exposed by Bokeh, but can be
used by ``iplot`` with any backend. Refer to [#fn1]_ for more
information about tick formatting.
4. It has been observed that Dark Reader (or other night-mode-enabling
browser extensions) might interfere with the correct behaviour of
the output of ``iplot``. Please, consider adding ``localhost`` to the
exclusion list of such browser extensions.
5. Say we are creating two different interactive plots and capturing
their output on two variables, using ``show=False``. For example,
``p1 = iplot(..., show=False)`` and ``p2 = iplot(..., show=False)``.
Then, running ``p1.show()`` on the screen will result in an error.
This is standard behaviour that can't be changed, as `panel's`
parameters are class attributes that gets deleted each time a new
instance is created.
6. ``MatplotlibBackend`` can be used, but the resulting figure is just a
PNG image without any interactive frame. Thus, data exploration is not
great. Therefore, the use of ``PlotlyBackend`` or ``BokehBackend`` is
encouraged.
7. Once this module has been loaded, there could be problems with all
other plotting functions when using ``BokehBackend``, namely the
figure won't show up in the output cell. If that is the case, we might
try to turn off automatic updates on panning by setting
``update_event=False`` in the function call.
8. When ``BokehBackend`` is used:
* the user-defined theme won't be applied.
* rendering of gradient lines is slow.
* color bars might not update their ranges.
9. Once this module has been loaded and ``iplot`` has been executed, the
safest procedure to restart Jupyter Notebook's kernel is the following:
* save the current notebook.
* close the notebook and Jupyter server.
* restart Jupyter server and open the notebook.
* reload the cells.
Failing to follow this procedure might results in the notebook to
become unresponsive once the module has been reloaded, with several
errors appearing on the output cell.
References
==========
.. [#fn1] https://docs.bokeh.org/en/latest/docs/user_guide/styling.html#tick-label-formats
.. [#fn2] https://panel.holoviz.org/reference/panes/Matplotlib.html
.. [#fn3] https://panel.holoviz.org/reference/panes/Plotly.html
See also
========
create_series, create_widgets
"""
i = InteractivePlot(*args, **kwargs)
if show:
return i.show()
return i