"""
This module contains all classes and structures needed for live plotting of data via
polling an object. While specifically made for the CBD simulator, it is independent
of any internal workings.
"""
# import CBD.realtime.accurate_time as time
import time
import threading
try:
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib.patches import Arrow as mplArrow
_MPL_FOUND = True
try:
# Note: Seaborn is built on top of matplotlib
import seaborn as sns
_SNS_FOUND = True
except ImportError:
_SNS_FOUND = False
except ImportError:
_MPL_FOUND = False
_SNS_FOUND = False
try:
import bokeh.plotting as bkplt
import bokeh.io
import bokeh.models
_BOKEH_FOUND = True
except ImportError:
_BOKEH_FOUND = False
# __all__ = ['Backend', 'PlotKind', 'PlotHandler', 'PlotManager', 'plot', 'follow', 'set_xlim', 'set_ylim',
# 'Arrow', 'StepPlot', 'ScatterPlot', 'LinePlot']
# TODO: More Plot Kinds
[docs]class Backend:
"""
In which framework the plots need to be made.
Note:
Some attributes won't be available if the corresponding framework is not installed.
"""
if _MPL_FOUND:
MPL = 1
""" : : Use `Matplotlib <https://matplotlib.org/>`_."""
MATPLOTLIB = 1
""" : : Use `Matplotlib <https://matplotlib.org/>`_."""
if _BOKEH_FOUND:
BKH = 2
BOKEH = 2
"""
: : Use `Bokeh <//docs.bokeh.org/en/latest/index.html>`_.
Important:
For :code:`BOKEH`, a server must be started with:
.. code-block:: bash
bokeh serve <experiment file>
Warning:
Because of how Bokeh works, it is currently impossible to capture premature
close events on a system. The plots will always remain active once started.
"""
if _SNS_FOUND:
SNS = 3
""" : : Use `Seaborn <https://seaborn.pydata.org/>`_."""
SEABORN = 3
""" : : Use `Seaborn <https://seaborn.pydata.org/>`_."""
[docs] @staticmethod
def exists(value):
"""
Checks that a given backend exists.
"""
return value in [getattr(Backend, x) for x in dir(Backend) if not x.startswith("_") and \
not callable(getattr(Backend, x))]
[docs] @staticmethod
def compare(name, value):
"""
Compares the value against a backend name.
Args:
name (str): The name of the backend to check for.
value (int): The value to compare.
"""
return name in dir(Backend) and Backend.exists(value) and getattr(Backend, name) == value
[docs] @staticmethod
def get(name):
"""
Gets the backend with a specific name if it exists and if it's installed.
Args:
name (str): The name of the backend to get.
"""
return getattr(Backend, name) if name in dir(Backend) else None
[docs]class PlotHandler:
"""
Handles Real-Time plotting, independent of a plotting framework.
Every :code:`interval` time, data will be polled from the :code:`object`,
w.r.t. the given plotting :code:`backend`. The handler will use the knowledge
of a backend to determine how the given :code:`figure` must be updated.
This class can be used in any simulation context, where plotting data can be
polled from an object or a resource.
Note:
While technically framework-independent, matplotlib has the least external
overhead in your code.
Tip:
When using multiple plots/plotting instances, you should make use of the
:class:`PlotManager`.
Warning:
Bokeh is still in active development. Not all features will work as required.
Raises:
AssertionError: if the backend cannot be located.
Args:
object (Any): The object from which data needs to be polled. By default,
the :code:`data_xy` attribute will be used on the object,
which should result in a 2xN array in the form of
:code:`[[x1, x2, x3...], [y1, y2, y3...]]`. Change this
behaviour with the :func:`set_data_getter` function.
figure: The figure object required for plotting. This has been made
externally to allow for full manipulation of the plotting
framework itself. The following explains the axxepted data
types:
- **matplotlib:** Tuple (pair) of :code:`(Figure, Axes)`
- **bokeh:** :code:`Figure`
- **seaborn:** Tuple (pair) of :code:`(Figure, Axes)`
kind (PlotKind): What needs to be plotted. See :class:`PlotKind` for more
info.
backend (Backend): The backend to use for plotting. Defaults to
:attr:`Backend.MPL` (= matplotlib).
interval (int): Amount of milliseconds between plot refreshes.
frames (int): The amount of frames for the animation. Only used if the
animation needs to be saved.
static (bool): When :code:`True`, no animation will be created.
See Also:
- :class:`Backend`
- :class:`PlotKind`
- :class:`PlotManager`
"""
def __init__(self, object, figure, kind, backend=Backend.MPL, interval=10, frames=None, static=False):
assert Backend.exists(backend), "Invalid backend."
self.object = object
self.kind = kind
self.figure = figure
self.interval = interval
self.kind._backend = backend
self.elm = self.kind.create(figure)
self.__opened = True
self.__finished = False
self.__hidden = False
# obtaining information
self.__get_data = lambda obj: obj.data_xy
self.__event_bus = []
self.__event_thread = threading.Thread(target=self.__event_thread_loop)
self.__event_thread.start()
self.__events = {
"preupdate": [],
"update": [],
"closed": []
}
# backend info:
if not static:
if Backend.compare("MPL", backend) or Backend.compare("SNS", backend):
self.__ani = animation.FuncAnimation(figure[0], lambda _: self.update(),
interval=self.interval, frames=frames)
figure[0].canvas.mpl_connect('close_event', lambda _: self.__close_event())
elif Backend.compare("BOKEH", backend):
self.__periodic_callback = bokeh.io.curdoc().add_periodic_callback(lambda: self.update(), interval)
# No need to do closing callback -- automatically done by simulator
[docs] def signal(self, name, *args):
"""
Raise a signal with a specific name and arguments.
The accepted signals are:
- :code:`preupdate(data)`: Raised whenever the plot is about to update.
It takes the new plot data that has been polled.
- :code:`update(data)`: Raised whenever the plot updates. It takes the
new plot data that has been polled.
- :code:`closed`: Raised whenever the plot closes. No additional arguments
are associated with this event.
Note:
Normally, users do not need to call this function.
Args:
name (str): The name of the signal to raise.
*args: Additional arguments to pass to the connected events.
See Also:
- :func:`PlotHandler.connect`
- :func:`PlotManager.connect`
- :func:`follow`
"""
if name not in self.__events:
raise ValueError("Invalid signal '%s' in PlotHandler." % name)
self.__event_bus.append((name, args))
[docs] def connect(self, name, function):
"""
Connect an event with an additional function.
Useful for e.g. updating the plot axes w.r.t. the data.
The functions will be called in the order they were connected to the
events, with the associated arguments. The accepted signals are:
- :code:`preupdate(data)`: Raised whenever the plot is about to update.
It takes the new plot data that has been polled.
- :code:`update(data)`: Raised whenever the plot updates. It takes the
new plot data that has been polled.
- :code:`closed`: Raised whenever the plot closes. No additional arguments
are associated with this event.
Args:
name (str): The name of the signal to raise.
function: A function that will be called with the optional arguments
whenever the event is raised.
See Also:
- :func:`PlotHandler.signal`
- :func:`PlotManager.connect`
- :func:`follow`
"""
if name not in self.__events:
raise ValueError("Invalid signal '%s' in PlotHandler." % name)
self.__events[name].append(function)
def __event_thread_loop(self):
while not self.__finished:
while len(self.__event_bus) > 0:
name, args = self.__event_bus.pop()
for evt in self.__events[name]:
evt(*args)
time.sleep(self.interval / 1000)
[docs] def set_data_getter(self, function):
"""
Sets the data getter function call. By default, the :code:`data_xy` attribute
is polled, but this function provides the flexibility to alter this behaviour.
Args:
function: The function to obtain the data. Takes the :code:`object` as an
input and should return a 2xN array in the form of
:code:`[[x1, x2, x3...], [y1, y2, y3...]]`.
"""
self.__get_data = function
[docs] def get_data(self):
"""
Actually gets the data from the object.
"""
return self.__get_data(self.object)
[docs] def get_animation(self):
"""
Returns the animation object, if MatPlotLib is used as a backend.
"""
return self.__ani
[docs] def close_event(self):
"""
Closes the plot/figure associated with this handler.
See Also:
:func:`PlotHandler.close_event`
"""
if self.kind.is_backend("MPL", "SNS"):
plt.close(self.figure[0])
# Make sure we close the plot if the backend fails to do so
self.__close_event()
def __close_event(self):
"""
Raises the `closed` signal.
Note:
This function is only used in the backend to prevent double
closing of the plots.
"""
if self.__opened:
self.__opened = False
self.stop()
self.signal('closed')
# self.__event_thread.join()
[docs] def is_opened(self):
"""
Checks if the plots are still open.
"""
return self.__opened
[docs] def update(self):
"""
Updates the plot information.
"""
# print("update")
data = self.get_data()
if not self.__hidden:
self.signal('preupdate', data)
res = self.kind.update(self.elm, *data)
if res is not None:
self.elm = res
self.signal('update', data)
else:
self.signal('preupdate', data)
res = self.kind.update(self.elm, [], [])
if res is not None:
self.elm = res
self.signal('update', data)
if self.__finished:
self._stop()
[docs] def stop(self):
"""
Requests the process to finish polling.
"""
self.__finished = True
def _stop(self):
"""
Stops polling for updates, but keeps the plot alive.
"""
if self.kind.is_backend("MPL", "SNS"):
self.__ani.event_source.stop()
elif self.kind.is_backend("BOKEH"):
bokeh.io.curdoc().remove_periodic_callback(self.__periodic_callback)
[docs] def terminate(self):
"""
Terminate the animation and close the plot.
"""
self._stop()
self.close_event()
[docs] def set_kind_args(self, *args, **kwargs):
"""
Sets the (keyword) arguments used for the plot.
"""
self.kind.args = args
self.kind.kwargs = kwargs
# TODO: pass the args on for the full plot.
# Redrawing only changes the x, y coords!
[docs] def hide(self):
"""
Hides the plot in the view.
See Also:
- :func:`show`
- :func:`toggle`
"""
self.__hidden = True
[docs] def show(self):
"""
Shows the plot in the view.
See Also:
- :func:`hide`
- :func:`toggle`
"""
self.__hidden = False
[docs] def toggle(self):
"""
Toggles between hiding and showing the plot in the view.
See Also:
- :func:`hide`
- :func:`show`
"""
self.__hidden = not self.__hidden
[docs]def follow(data, size=None, lower_bound=float('-inf'), upper_bound=float('inf'),
lower_lim=0.0, upper_lim=0.0, perc_keep=0.5):
"""
Compute the new limits for the given dataset if the last data point must be followed.
This is a convenience function for updating the plotting axes' limits. Whenever not
enough data is available, the axis won't scale down.
Args:
data (list): The data that must be plotted on the axes. Can be a
shortened version of only the final n values (n > 0).
size (float): The total size of the axis to show, even if the data
did not get there. When :code:`None`, the size is taken
from the bounds or limits. Defaults to :code:`None`.
lower_bound (float): The lower bound of the axis, i.e. the minimal value to
show, even if the data lies outside of this interval.
Defaults to :code:`-float('inf')` (-infinity = no limit).
upper_bound (float): The upper bound of the axis, i.e. the maximal value to
show, even if the data lies outside of this interval.
Defaults to :code:`float('inf')` (infinity = no limit).
lower_lim (float): Lowest value to show, even if this value is not reached.
While :attr:`lower_bound` bounds this point, so no smaller
values can be shown, :attr:`lower_limit` makes it so this
valus is always shown, but smaller values may still rescale
beyond this point. Will be ignored when :attr:`size` is set.
Defaults to :code:`0.0`.
upper_lim (float): Highest value to show, even if this value is not reached.
While :attr:`upper_bound` bounds this point, so no higher
values can be shown, :attr:`upper_limit` makes it so this
valus is always shown, but larger values may still rescale
beyond this point. Will be ignored when :attr:`size` is set.
Defaults to :code:`0.0`.
perc_keep (float): The percentage at which the final data point must be
shown. When :code:`0.0`, this point is the lowest value
shown. When :code:`1.0`, this point is the highest value
shown. Use this value to change how "centered" the last
datapoint will be. When the axis grows in a positive
direction, a value of :code:`0.0` will never show the
data. When growing negatively, the opposite is true:
the data will not be shown for a value of :code:`1.0`.
Defaults to :code:`0.5` (= the middle).
Warning:
* The lower bound must be strictly smaller than the upper bound.
* The size is :code:`None` and the bounds are infinity and the limits are equal.
* The size cannot be larger than the distance between the bounds.
* The lower limit must be smaller than (or equal to) the upper limit.
Examples:
- Follow a sine wave in the positive-x direction, always keeping a width of 10, in matplotlib.
.. code-block:: python
manager = PlotManager()
manager.register("sin", cbd.find("plot")[0], (fig, ax), LinePlot())
manager.connect('sin', 'update', lambda d, a=ax: a.set_xlim(follow(d[0], 10.0, lower_bound=0.0)))
- Follow a sine wave in the negative-x direction, always keeping a width of 10, in matplotlib.
.. code-block:: python
manager = PlotManager()
manager.register("sin", cbd.find("plot")[0], (fig, ax), LinePlot())
manager.connect('sin', 'update', lambda d, a=ax: a.set_xlim(follow(d[0], 10.0, upper_bound=0.0)))
- Follow a positive sine-wave, but keep a 10% margin from the right edge of the plot, in matplotlib.
.. code-block:: python
manager = PlotManager()
manager.register("sin", cbd.find("plot")[0], (fig, ax), LinePlot())
manager.connect('sin', 'update', lambda d, a=ax: a.set_xlim(follow(d[0], 10.0, 0.0, perc_keep=0.9)))
"""
if lower_bound >= upper_bound:
raise ValueError("Lower bound must be strictly smaller than the upper bound.")
if size is None and (lower_bound == float('-inf') or upper_bound == float('inf')) and lower_lim == upper_lim:
raise ValueError("When size is unset, the bounds may not be infinity and the limits may not equal each other.")
if size is not None and upper_bound - lower_bound < size:
raise ValueError("Invalid size: outside bounds.")
if lower_lim > upper_lim:
raise ValueError("Lower limit must be smaller than (or equal to) the upper limit.")
if size is None:
if len(data) == 0:
return lower_lim, upper_lim
return min(min(data), lower_lim), max(max(data), upper_lim)
if upper_bound - lower_bound == size:
return lower_bound, upper_bound
if len(data) == 0:
return -size / 2.0, size / 2.0
value = data[-1]
low = max(value - size * perc_keep, lower_bound)
high = min(low + size, upper_bound)
if high == upper_bound:
low = high - size
return low, high
[docs]class PlotKind:
"""
The kind of plot that needs to be rendered. This class is an abstract superclass.
Not all backends provide the same functionality for plotting the same shape.
This class provides a wrapper around all those functionalities to allow for users
that they only need to change the figure information.
Args:
*args: The arguments to add to the 'shape', excluding the data points.
.. note::
The :code:`seaborn` backend does not require any normal args.
**kwargs: The keyword arguments to add to the 'shape'.
See Also:
- :class:`LinePlot`
- :class:`StepPlot`
- :class:`ScatterPlot`
- Information on the individual plot instances in the corresponding frameworks.
"""
def __init__(self, *args, **kwargs):
self._backend = None
self.args = args
self.kwargs = kwargs
[docs] def is_backend(self, *backends):
"""
Checks the backend for the plot.
"""
for back in backends:
if Backend.compare(back, self._backend):
return True
return False
[docs] def create(self, figure):
"""
Called when the figure is first created in the manager.
Returns:
The 'shape' information that is created on the plot.
"""
raise NotImplementedError()
[docs] def update(self, element, *data):
"""
Called when the figure is updated in the manager.
Args:
element: The shape that was returned from the :func:`PlotKind.create`
method.
*data: The data with which to update the plot.
"""
raise NotImplementedError()
[docs]class LinePlot(PlotKind):
"""
Draws a line between all given coordinates.
Backend Information:
- **matplotlib:** :func:`matplotlib.axes.Axes.plot`
- **bokeh:** :func:`bokeh.plotting.Figure.line`
- **seaborn:** :func:`seaborn.lineplot`
"""
[docs] def create(self, figure):
if self.is_backend("MPL"):
# matplotlib: figure[1] is the axis
line, = figure[1].plot([], [], *self.args, **self.kwargs)
return line
elif self.is_backend("BOKEH"):
return figure.line([], [], *self.args, **self.kwargs)
elif self.is_backend("SNS"):
# matplotlib: figure[1] is the axis
a = sns.lineplot(x=[0], y=[0], ax=figure[1], **self.kwargs)
return a.get_lines()[-1]
[docs] def update(self, element, *data):
if self.is_backend("MPL", "SNS"):
element.set_data(data[0], data[1])
elif self.is_backend("BOKEH"):
element.data_source.data.update(x=data[0], y=data[1])
[docs]class StepPlot(PlotKind):
"""
Draws a stepped line between all given coordinates.
See the corresponding backend information to change the step "levels".
Backend Information:
- **matplotlib:** :func:`matplotlib.axes.Axes.step`
- **bokeh:** :func:`bokeh.plotting.Figure.step`
- **seaborn:** :func:`seaborn.lineplot` with :code:`drawstyle='steps-pre'`
"""
[docs] def create(self, figure):
if self.is_backend("MPL"):
# matplotlib: figure[1] is the axis
line, = figure[1].step([], [], *self.args, **self.kwargs)
return line
elif self.is_backend("BOKEH"):
return figure.step([], [], *self.args, **self.kwargs)
elif self.is_backend("SNS"):
# matplotlib: figure[1] is the axis
a = sns.lineplot(x=[], y=[], ax=figure[1], drawstyle='steps-pre', **self.kwargs)
return a.get_lines()[-1]
[docs] def update(self, element, *data):
if self.is_backend("MPL", "SNS"):
element.set_data(data[0], data[1])
elif self.is_backend("BOKEH"):
element.data_source.data.update(x=data[0], y=data[1])
[docs]class ScatterPlot(PlotKind):
"""
Draws all given coordinates as points in a 2D space.
See the corresponding backend information to change the shape of the indicators.
Backend Information:
- **matplotlib:** :func:`matplotlib.axes.Axes.scatter`
- **bokeh:** :func:`bokeh.plotting.Figure.scatter`
- **seaborn:** :func:`seaborn.scatterplot`
"""
[docs] def create(self, figure):
if self.is_backend("MPL"):
# matplotlib: figure[1] is the axis
pathc = figure[1].scatter([], [], *self.args, **self.kwargs)
return pathc
elif self.is_backend("BOKEH"):
return figure.scatter([], [], *self.args, **self.kwargs)
elif self.is_backend("SNS"):
# matplotlib: figure[1] is the axis
a = sns.scatterplot(x=[0], y=[0], ax=figure[1], **self.kwargs)
return a.findobj(matplotlib.collections.PathCollection)[-1]
[docs] def update(self, element, *data):
if self.is_backend("MPL", "SNS"):
element.set_offsets(list(zip(*data)))
elif self.is_backend("BOKEH"):
element.data_source.data.update(x=data[0], y=data[1])
import math
[docs]class Arrow(PlotKind):
"""
Draws a "direction" arrow at a position, indicative of an angle.
This :class:`PlotKind` assumes that the y-component indicates the
angle.
Backend Information:
- **matplotlib:** :func:`matplotlib.axes.Axes.scatter`
- **bokeh:** Not available.
- **seaborn:** Not available.
"""
def __init__(self, size, position, *args, **kwargs):
PlotKind.__init__(self, *args, **kwargs)
self.position = position
self.size = size
[docs] def create(self, figure):
x, y = self.position
if self.is_backend("MPL", "SNS"):
# matplotlib: figure[1] is the axis
arrow = mplArrow(x, y, 0, 0, *self.args, **self.kwargs)
line = figure[1].add_patch(arrow)
line.set_zorder(20)
return line
elif self.is_backend("BKH"):
arrow = bokeh.models.Arrow(x_start=x, y_start=y, x_end=x, y_end=y, **self.kwargs)
figure.add_layout(arrow)
return arrow
[docs] def update(self, element, *data):
heading = data[1]
dx = math.cos(heading[-1]) * self.size
dy = math.sin(heading[-1]) * self.size
x, y = self.position
if self.is_backend("MPL", "SNS"):
ax = element.axes
element.remove()
arrow = mplArrow(x, y, dx, dy, *self.args, **self.kwargs)
line = ax.add_patch(arrow)
line.set_zorder(20)
return line
elif self.is_backend("BKH"):
element.x_start = x
element.y_start = y
element.x_end = x + dx
element.y_end = y + dy
return element
[docs]class PlotManager:
"""
Manages multiple :class:`PlotHandler` instances.
Use this class as a centralized unit to manage one or more plots in the system.
Args:
backend (Backend): The backend to use.
Raises:
AssertionError: if the backend cannot be located.
See Also:
- :class:`Backend`
- :class:`PlotHandler`
"""
def __init__(self, backend=Backend.get("MPL")):
assert Backend.exists(backend), "Invalid backend."
self.__handlers = {}
self.backend = backend
[docs] def is_opened(self):
"""
Checks if there is at least a single plot left open.
"""
return any([h.is_opened() for h in self.__handlers.values()])
[docs] def all_opened(self):
"""
Checks if all plots are still open.
"""
return all([h.is_opened() for h in self.__handlers.values()])
[docs] def register(self, name, object, figure, kind, **kwargs):
"""
Register a single :class:`PlotHandler` to plot information.
Args:
name (str): The name for this handler. Will be used to optionally
access it afterwards.
object (Any): The object from which data needs to be polled. By default,
the :code:`data_xy` attribute will be used on the object,
which should result in a 2xN array in the form of
:code:`([x1, x2, x3...], [y1, y2, y3...])`.
figure: The figure object required for plotting. This has been made
externally to allow for full manipulation of the plotting
framework itself.
kind (PlotKind): What needs to be plotted. See :class:`PlotKind` for more
info.
**kwargs: Additional arguments to pass to the :class:`PlotHandler`
constructor.
See Also:
:class:`PlotHandler`
"""
if name in self.__handlers:
raise ValueError("PlotManager: PlotHandler '%s' already registered." % name)
self.__handlers[name] = PlotHandler(object, figure, kind, self.backend, **kwargs)
[docs] def unregister(self, name):
"""
Removes a single :class:`PlotHandler` from the manager.
Args:
name (str): The identifying name for the handler.
"""
h = self.get(name)
h.close_event()
del self.__handlers[name]
[docs] def get(self, name):
"""
Obtains a single :class:`PlotHandler`, given its name.
Args:
name (str): The identifying name for the handler to obtain.
"""
if name not in self.__handlers:
raise ValueError("PlotManager: No PlotHandler exists with name '%s'." % name)
return self.__handlers[name]
[docs] def connect(self, handler_name, event_name, function):
"""
Connect an event to a specific handler.
Args:
handler_name (str): The identifying name for the handler.
event_name (str): The name for the event.
function: A function to execute whenever the event is raised.
See Also:
- :func:`PlotHandler.connect`
- :func:`PlotHandler.signal`
"""
self.get(handler_name).connect(event_name, function)
[docs] def terminate(self):
"""
Forcibly terminate the manager and all its handlers.
"""
for handler in self.__handlers.values():
handler.terminate()
[docs] def stop(self):
"""
Stop polling for all updates.
"""
for handler in self.__handlers.values():
handler.stop()
[docs] def set_xlim(self, figure, values):
"""
Shorthand method for setting the x limits of the figure.
Warning:
Bokeh requires this method to be called in a "clean" tick. Call
:meth:`bokeh_set_xlim` instead.
Args:
figure: The figure to alter.
values (tuple): The new limits for the x-axis.
"""
backend = self.backend
if Backend.compare("MPL", backend) or Backend.compare("SNS", backend):
figure[1].set_xlim(values)
elif Backend.compare("BKH", backend):
lower, upper = values
figure.x_range.start = lower
figure.x_range.end = upper
[docs] def set_ylim(self, figure, values):
"""
Shorthand method for setting the y limits of the figure.
Warning:
Bokeh requires this method to be called in a "clean" tick. Call
:meth:`bokeh_set_ylim` instead.
Args:
figure: The figure to alter.
values (tuple): The new limits for the y-axis.
"""
backend = self.backend
if Backend.compare("MPL", backend) or Backend.compare("SNS"):
figure[1].set_ylim(values)
elif Backend.compare("BKH", backend):
lower, upper = values
figure.y_range.start = lower
figure.y_range.end = upper
if _BOKEH_FOUND:
[docs] def bokeh_set_xlim(self, figure, doc, values):
"""
Helper function to set the x limits in Bokeh, as it should be called in a "safe" tick.
Args:
figure: The figure to set the x limits for.
doc: The Bokeh document to which the figure belongs.
values: The new x limits.
"""
assert Backend.compare("BKH", self.backend), "Can only be called for a Bokeh backend!"
doc.add_next_tick_callback(lambda lim=values: self.set_xlim(figure, lim))
[docs] def bokeh_set_ylim(self, figure, doc, values):
"""
Helper function to set the y limits in Bokeh, as it should be called in a "safe" tick.
Args:
figure: The figure to set the y limits for.
doc: The Bokeh document to which the figure belongs.
values: The new y limits.
"""
assert Backend.compare("BKH", self.backend), "Can only be called for a Bokeh backend!"
doc.add_next_tick_callback(lambda lim=values: self.set_ylim(figure, lim))
[docs]def plot(object, figure, kind, backend=Backend.get("MPL"), margin=(0.02, 0.02)):
"""
Plot data on a figure after the simulation has finished.
This will automatically plot the full graph, so all axis scaling needs to be done afterwards.
Args:
object (Any): The object from which data needs to be polled. By default,
the :code:`data_xy` attribute will be used on the object,
which should result in a 2xN array in the form of
:code:`([x1, x2, x3...], [y1, y2, y3...])`.
figure: The figure object required for plotting. This has been made
externally to allow for full manipulation of the plotting
framework itself.
kind (PlotKind): What needs to be plotted. See :class:`PlotKind` for more
info.
backend (Backend): The backend to use.
margin (tuple): A margin for the limits of the axes.
See Also:
:class:`PlotHandler`
"""
assert Backend.exists(backend), "Invalid backend."
ph = PlotHandler(object, figure, kind, backend, static=True)
ph.update()
x, y = ph.get_data()
xlim = min(x), max(x)
ylim = min(y), max(y)
xlim = xlim[0] - margin[0], xlim[1] + margin[0]
ylim = ylim[0] - margin[1], ylim[1] + margin[1]
set_xlim(figure, backend, xlim)
set_ylim(figure, backend, ylim)