Source code for bdsim.blocks.displays

"""
Sink blocks:

- have inputs but no outputs
- have no state variables
- are a subclass of ``SinkBlock`` |rarr| ``Block``
- that perform graphics are a subclass of  ``GraphicsBlock`` |rarr| ``SinkBlock`` |rarr| ``Block``

"""

import numpy as np
from math import pi, sqrt, sin, cos, atan2

import matplotlib.pyplot as plt
from matplotlib.pyplot import Polygon
from numpy.lib.shape_base import expand_dims


import spatialmath.base as sm

from bdsim.components import SinkBlock
from bdsim.graphics import GraphicsBlock


# ------------------------------------------------------------------------ #


[docs]class Scope(GraphicsBlock): """ :blockname:`SCOPE` Plot input signals against time. :inputs: N :outputs: 0 :states: 0 .. list-table:: :header-rows: 1 * - Port type - Port number - Types - Description * - Input - i - float - :math:`x_i` is the i'th line Create a scope block that plots multiple signals against time. For each line plotted we can specify the: * line style as a heterogeneous list of: * Matplotlib `fmt` string comprising a color and line style, eg. ``"k"`` or ``"r:"`` * a dict of Matplotlib line style options for `Line2D <https://matplotlib.org/3.2.2/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D>`_ , eg. ``{"color": "k", "linewidth": 3, "alpha": 0.5)`` * line label, used in the legend and vertical axis. This can include math mode notation or unicode characters. The vertical scale factor defaults to auto-scaling but can be fixed by providing a 2-tuple ``[ymin, ymax]``. All lines are plotted against the same vertical scale. .. figure:: ../../figs/Figure_1.png :width: 500px :alt: example of generated graphic Example of scope display. **Scalar input ports against time** The number of lines to plot will be inferred from: * the length of the ``labels`` list if specified * the length of the ``styles`` list if specified * ``nin`` if specified, it defaults to 1 These numbers must be consistent. Examples:: bd.SCOPE() # a scope with 1 input port bd.SCOPE(nin=3) # a scope with 3 input ports bd.SCOPE(styles=["k", "r--"]) # a scope with 2 input ports bd.SCOPE(labels=["x", r"$\gamma$"]) # a scope with 2 input ports bd.SCOPE(styles=[{'color': 'blue'}, {'color': 'red', 'linestyle': '--'}]) **Single input port with NumPy array** The port is fed with a 1D-array, and ``vector`` is an: * int, this is the expected width of the array, all its elements will be plotted * a list of ints, interpretted as indices of the elements to plot. Examples:: bd.SCOPE(vector=[0,1,2]) # display elements 0, 1, 2 of array on port 0 bd.SCOPE(vector=[0,1], styles=[{'color': 'blue'}, {'color': 'red', 'linestyle': '--'}]) .. note:: * If the vector is of width 3, by default the inputs are plotted as red, green and blue lines. * If the vector is of width 6, by default the first three inputs are plotted as solid red, green and blue lines and the last three inputs are plotted as dashed red, green and blue lines. """ nin = -1 nout = 0
[docs] def __init__( self, nin=1, vector=None, styles=None, stairs=False, scale="auto", labels=None, grid=True, watch=False, title=None, loc="best", **blockargs, ): """ :param nin: number of inputs, defaults to 1 or if given, the length of style vector :type nin: int, optional :param vector: vector signal on single input port, defaults to None :type vector: int or list, optional :param styles: styles for each line to be plotted :type styles: str or dict, list of strings or dicts; one per line, optional :param stairs: force staircase style plot for all lines, defaults to False :type stairs: bool, optional :param scale: fixed y-axis scale or defaults to 'auto' :type scale: str or array_like(2) :param labels: vertical axis labels :type labels: sequence of strings :param grid: draw a grid, defaults to True. Can be boolean or a tuple of options for grid() :type grid: bool or sequence :param watch: add these signals to the watchlist, defaults to False :type watch: bool, optional :param title: title of plot :type title: str :param loc: location of legend, see :meth:`matplotlib.pyplot.legend`, defaults to "best" :type loc: str :param blockargs: |BlockOptions| :type blockargs: dict """ def listify(s): # guarantee that result is a list if isinstance(s, str): return [s] elif isinstance(s, (list, tuple)): return s else: raise ValueError("unknown argument to listify") # number of lines plotted (nplots) is inferred from the number of labels # or linestyles nplots = None if vector is not None: # vector argument is given # block has single input which is an array # vector is int, width of vector # vector is a list of ints, select those inputs from the input vector if nin != 1: raise ValueError("if vector is given, nin must be 1") if isinstance(vector, int): nplots = vector elif isinstance(vector, list): nplots = len(vector) else: raise ValueError("vector must be an int or list of indices") if styles is not None: self.styles = listify(styles) if nplots is None: nplots = len(self.styles) else: assert nplots == len(self.styles), "need one style per plot" else: self.styles = None if labels is not None: self.labels = listify(labels) if nplots is None: nplots = len(self.labels) else: assert nplots == len(self.labels), "need one label per plot" else: self.labels = None if nplots is None: # nplots has not been determined from styles or labels, so use nin nplots = nin elif nin == 1 and vector is None: # nplots is different to the default nin value, override it nin = nplots self.nplots = nplots self.vector = vector super().__init__(nin=nin, **blockargs) self.xlabel = "Time (s)" self.grid = grid self.stairs = stairs self.line = [None] * nplots self.scale = scale self.watch = watch self.title = title self.loc = loc
# TODO, wire width # inherit names from wires, block needs to be able to introspect def start(self, simstate): super().start(simstate) if not self._enabled: return # init the arrays that hold the data self.tdata = np.array([]) self.ydata = [ np.array([]), ] * self.nplots # create the figures self.fig = self.create_figure(simstate) self.ax = self.fig.add_subplot(111) # get labels if not provided if self.labels is None: if self.vector is None: self.labels = [self.sourcename(i) for i in range(self.nin)] else: self.labels = [str(i) for i in range(self.vector)] if self.styles is None: if self.vector == 3: self.styles = ["r", "g", "b"] elif self.vector == 6: self.styles = ["r", "g", "b", "r--", "g--", "b--"] if self.styles is None: self.styles = [None] * self.nplots # create empty lines with defined styles for i in range(0, self.nplots): args = [] kwargs = {} style = self.styles[i] if isinstance(style, dict): kwargs = style elif isinstance(style, str): args = [style] if self.stairs: kwargs["drawstyle"] = "steps" # force steppy plot (self.line[i],) = self.ax.plot( self.tdata, self.ydata[i], *args, label=self.styles[i], linewidth=2, **kwargs, ) # label the axes if self.labels is not None: self.ax.set_ylabel(",".join(self.labels)) self.ax.set_xlabel(self.xlabel) if self.title is not None: name = self.title else: name = self.name_tex self.ax.set_title(name) # grid control if self.grid is True: self.ax.grid(self.grid) elif isinstance(self.grid, (list, tuple)): self.ax.grid(True, *self.grid) # set limits self.ax.set_xlim(0, simstate.T) if self.scale != "auto": self.ax.set_ylim(*self.scale) if self.labels is not None: def fix_underscore(s): if s[0] == "_": return "-" + s[1:] else: return s self.ax.legend([fix_underscore(label) for label in self.labels], loc=self.loc) if self.watch: for wire in self.input_wires: plug = wire.start # start plug for input wire # append to the watchlist, bdsim.run() will do the rest simstate.watchlist.append(plug) simstate.watchnamelist.append(str(plug)) plt.draw() plt.show(block=False) def step(self, t, inports): if not self._enabled: return # inputs are set self.tdata = np.append(self.tdata, t) if self.vector is None: # take data from multiple inputs as a list data = inports if len(data) != self.nplots: raise RuntimeError( "number of signals to plot doesnt match init parameters" ) else: # single input with vector data data = self.inputs[0] if isinstance(self.vector, list): data = data[self.vector] # append new data to the set for i, y in enumerate(data): self.ydata[i] = np.append(self.ydata[i], y) # plot the data for i in range(0, self.nplots): self.line[i].set_data(self.tdata, self.ydata[i]) if self.scale == "auto": self.ax.relim() self.ax.autoscale_view(scalex=False, scaley=True) super().step(t, inports)
# ------------------------------------------------------------------------ #
[docs]class ScopeXY(GraphicsBlock): """ :blockname:`SCOPEXY` Plot X against Y. :inputs: 2 :outputs: 0 :states: 0 .. list-table:: :header-rows: 1 * - Port type - Port number - Types - Description * - Input - 0 - float - :math:`x` * - Input - 1 - float - :math:`y` Create an XY scope where input :math:`y` (vertical axis) is plotted against :math:`x` (horizontal axis). Line style is one of: * Matplotlib `fmt` string comprising a color and line style, eg. ``"k"`` or ``"r:"`` * a dict of Matplotlib line style options for `Line2D <https://matplotlib.org/3.2.2/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D>`_ , eg. ``dict(color="k", linewidth=3, alpha=0.5)`` The scale factor defaults to auto-scaling but can be fixed by providing either: - a 2-tuple ``[min, max]`` which is used for the x- and y-axes - a 4-tuple ``[xmin, xmax, ymin, ymax]`` """ nin = 2 nout = 0
[docs] def __init__( self, style=None, scale="auto", aspect="equal", labels=["X", "Y"], init=None, nin=2, **blockargs, ): """ :param style: line style, defaults to None :type style: optional str or dict :param scale: fixed y-axis scale or defaults to 'auto' :type scale: str or array_like(2) or array_like(4) :param labels: axis labels (xlabel, ylabel), defaults to ["X","Y"] :type labels: 2-element tuple or list :param init: function to initialize the graphics, defaults to None :type init: callable :param blockargs: |BlockOptions| :type blockargs: dict """ super().__init__(**blockargs) self.xdata = [] self.ydata = [] if init is not None: assert callable(init), "graphics init function must be callable" self.init = init self.styles = style if scale != "auto": scale = sm.expand_dims(scale, 2) self.scale = scale self.aspect = aspect self.labels = labels self.inport_names(("x", "y"))
def start(self, simstate): super().start(simstate) if not self._enabled: return # create the plot super().reset() self.fig = self.create_figure(simstate) self.ax = self.fig.gca() args = [] blockargs = {} style = self.styles if isinstance(style, dict): blockargs = style elif isinstance(style, str): args = [style] (self.line,) = self.ax.plot(self.xdata, self.ydata, *args) self.ax.grid(True) self.ax.set_xlabel(self.labels[0]) self.ax.set_ylabel(self.labels[1]) self.ax.set_title(self.name) if not (isinstance(self.scale, str) and self.scale == "auto"): self.ax.set_xlim(*self.scale[0:2]) self.ax.set_ylim(*self.scale[2:4]) self.ax.set_aspect(self.aspect) if self.init is not None: self.init(self.ax) plt.draw() plt.show(block=False) def step(self, t, inports): if not self._enabled: return self._step(inports[0], inports[1], t) def _step(self, x, y, t): self.xdata.append(x) self.ydata.append(y) plt.figure(self.fig.number) self.line.set_data(self.xdata, self.ydata) if self.bd.runtime.options.animation: self.fig.canvas.flush_events() if isinstance(self.scale, str) and self.scale == "auto": self.ax.relim() self.ax.autoscale_view() super().step(t, None)
# def done(self, block=False, **blockargs): # if self.bd.runtime.options.graphics: # plt.show(block=block) # super().done()
[docs]class ScopeXY1(ScopeXY): """ :blockname:`SCOPEXY1` Plot X[0] against X[1]. :inputs: 1 :outputs: 0 :states: 0 .. list-table:: :header-rows: 1 * - Port type - Port number - Types - Description * - Input - 0 - ndarray - :math:`x` Create an XY scope where input :math:`x_j` (vertical axis) is plotted against :math:`x_i` (horizontal axis). This block has one vector input and the elements to be plotted are given by a 2-element iterable :math:`(i, j)`. Line style is one of: * Matplotlib `fmt` string comprising a color and line style, eg. ``"k"`` or ``"r:"`` * a dict of Matplotlib line style options for `Line2D <https://matplotlib.org/3.2.2/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D>`_ , eg. ``dict(color="k", linewidth=3, alpha=0.5)`` The scale factor defaults to auto-scaling but can be fixed by providing either: - a 2-tuple ``[min, max]`` which is used for the x- and y-axes - a 4-tuple ``[xmin, xmax, ymin, ymax]`` """ nin = 1 nout = 0
[docs] def __init__(self, indices=[0, 1], **blockargs): """ :param indices: indices of elements to select from block input vector, defaults to [0,1] :type indices: array_like(2) :param style: line style :type style: optional str or dict :param scale: fixed y-axis scale or defaults to 'auto' :type scale: str or array_like(2) or array_like(4) :param labels: axis labels (xlabel, ylabel) :type labels: 2-element tuple or list :param init: function to initialize the graphics, defaults to None :type init: callable :param blockargs: |BlockOptions| :type blockargs: dict """ super().__init__(**blockargs) self.inport_names(("xy",)) if len(indices) != 2: raise ValueError("indices must have 2 elements") self.indices = [int(x) for x in indices]
def step(self, t, inports): if not self._enabled: return # inputs are set x = inports[0][self.indices[0]] y = inports[0][self.indices[1]] super()._step(x, y, t)
# ------------------------------------------------------------------------ # if __name__ == "__main__": # pragma: no cover from pathlib import Path exec( open(Path(__file__).parent.parent.parent / "tests" / "test_displays.py").read() )