Coverage for src/signal_edges/plotter/plotter.py: 29%
130 statements
« prev ^ index » next coverage.py v7.4.3, created at 2024-04-21 11:16 +0000
« prev ^ index » next coverage.py v7.4.3, created at 2024-04-21 11:16 +0000
1"""The plotter, :class:`.Plotter`, is an opinionated and customized plotter based on `matplotlib` suitable for the
2plotting of signals and their generated artifacts in |ProjectName|. It supports additional features such as the use
3of rasters when plotting millions of samples, shared horizontal axis between plots or plot of cursors for points
4of interest.
6The plotter is used throughout the package to generate the plots included in this documentation, some other more
7complex plots that can be achieved with this plotter are shown below:
9.. figure:: ../.assets/img/006_example_complex_plot_0.png
10 :width: 600
11 :align: center
13 Plot of three different signals with synchronized edges, with cursors, state levels and points of the edges.
15.. figure:: ../.assets/img/007_example_complex_plot_1.png
16 :width: 600
17 :align: center
19 The same plot as before but with the original unfiltered signals in grey.
21The plotter supports many different kinds of plots to explicitly document them all, for code snippets and examples,
22refer to the existing uses of the plotter throughout |ProjectName| and the `API` described below."""
24import os
26import matplotlib
27import matplotlib.axes
28import matplotlib.figure
29import matplotlib.patheffects
30import matplotlib.pyplot
31import numpy as np
33from ..definitions import get_logger
34from ..exceptions import PlotterError
35from .definitions import Cursor, Mode, Plot, PlotArea, Subplot
38class Plotter:
39 """Implementation of an plotter based on `matplotlib`."""
41 #: Path to the matplotlib style file.
42 _style = os.path.join(os.path.normpath(os.path.dirname(__file__)), "style", "style.mplstyle")
44 ## Private API #####################################################################################################
45 def __init__(self, *args, mode: Mode = Mode.LINEAR, rows: int = 1, columns: int = 1, **kwargs) -> None:
46 """Class constructor.
48 :param mode: The plotter mode.
49 :param rows: Number of rows in the plot.
50 :param columns: Number of colums in the plot.
51 :raise PlotterError: The number of rows must be higher than zero.
52 :raise PlotterError: The number of columns must be higher than zero.
53 :raise PlotterError: The number of columns for an horizontal shared axis plot must be one."""
54 # pylint: disable=unused-argument
56 #: Logger.
57 self._logger = get_logger()
58 #: Plotter mode.
59 self._mode = mode
60 #: Number of rows.
61 self._rows = rows
62 #: Number of columns.
63 self._columns = columns
65 # Sanity check on values.
66 if self._rows <= 0: 66 ↛ 67line 66 didn't jump to line 67, because the condition on line 66 was never true
67 raise PlotterError("At least one row must be specified for plotter.")
68 if self._columns <= 0: 68 ↛ 69line 68 didn't jump to line 69, because the condition on line 68 was never true
69 raise PlotterError("At least one column must be specified for plotter.")
70 if all([self._mode is Mode.SHARED_H_AXIS, self._columns > 1]): 70 ↛ 71line 70 didn't jump to line 71, because the condition on line 70 was never true
71 raise PlotterError("For a plotter with shared horizontal axis, the number of columns must be one.")
73 #: Plot area.
74 self._area: PlotArea = {j: {i: [] for i in range(self._columns)} for j in range(self._rows)}
75 #: Cursors.
76 self._cursors: list[Cursor] = []
78 ## Protected API ###################################################################################################
79 def _get_plot(self, row: int, column: int) -> Plot | None:
80 """Obtains a plot from its row and column indices, with all its subplots.
82 :param row: The index of the row, must be zero or a positive number.
83 :param column: The index of the column, must be zero or a positive number.
84 :return: The plot, or ``None`` if the row and columns are not valid."""
85 if any([row >= self._rows, row < 0, column >= self._columns, column < 0]): 85 ↛ 86line 85 didn't jump to line 86, because the condition on line 85 was never true
86 return None
87 return self._area[row][column]
89 def _get_plot_coords(self, subplot_id: str) -> tuple[int, int] | None:
90 """Obtains the row and column indices for a plot from one of its subplot identifiers.
92 :param subplot_id: Subplot identifier to identify the plot.
93 :return: The row and column indices, or ``None`` if no plot found for subplot identifier."""
94 for row_key, row in self._area.items():
95 for column_key, column in row.items():
96 if subplot_id in tuple(i.name for i in column):
97 return (row_key, column_key)
98 return None
100 ## Public API ######################################################################################################
101 def add_plot(self, row: int, column: int, subplot: Subplot) -> "Plotter":
102 """Adds a subplot to the plot at specified row and column indices.
104 :param row: The index of the row to the the plot where to add the subplot.
105 :param column: The index of the column to the plot where to add the subplot.
106 :param subplot: The definition of the subplot to add.
107 :raise PlotterError: The begin and end value of the subplot to add are inconsistent.
108 :raise PlotterError: The values provided for one of the axis are empty, not the same length or invalid.
109 :raise PlotterError: The row and column indices given do not map to a plot.
110 :raise PlotterError: The subplot identifier provided already exists in the plot.
111 :raise PlotterError: The units of the horizontal and vertical axes of the subplot must match current plot.
112 :raise PlotterError: When sharing an horizontal axis, all horizontal axis and margin units must be the same.
113 :return: Instance of the class."""
114 # Check that the begin is before the end value in the subplot.
115 if subplot.begin > subplot.end: 115 ↛ 116line 115 didn't jump to line 116, because the condition on line 115 was never true
116 raise PlotterError("Subplot begin value must be less than the subplot end value.")
117 # Check that the horizontal values and vertical values of the subplot to add satisfy requirements.
118 if any( 118 ↛ 126line 118 didn't jump to line 126, because the condition on line 118 was never true
119 [
120 len(subplot.hvalues) != len(subplot.vvalues),
121 len(subplot.vvalues) == 0,
122 len(subplot.hvalues) == 0,
123 len(np.where(np.diff(subplot.hvalues) <= 0)[0]) > 0, # Check x[n] < x[n+1].
124 ]
125 ):
126 raise PlotterError("Values of the axis of the subplot to add are invalid.")
127 # Check if the coordinates are valid.
128 if (plot := self._get_plot(row, column)) is None: 128 ↛ 129line 128 didn't jump to line 129, because the condition on line 128 was never true
129 raise PlotterError("The row and column indices given do not map to a plot.")
130 # Check that there is no subplot of given identifier in plot.
131 if any(i.name == subplot.name for i in plot): 131 ↛ 132line 131 didn't jump to line 132, because the condition on line 131 was never true
132 raise PlotterError("The subplot identifier provided already exists in plot.")
133 # Check that the units of the plot to add and the current plots in the slot are all the same.
134 if not all(subplot.hunits == i.hunits and subplot.vunits == i.vunits for i in plot): 134 ↛ 135line 134 didn't jump to line 135, because the condition on line 134 was never true
135 raise PlotterError("Horizontal or vertical axis units in subplot to add do not match existing plot.")
137 # Check that if running in common axis mode.
138 if self._mode is Mode.SHARED_H_AXIS: 138 ↛ 140line 138 didn't jump to line 140, because the condition on line 138 was never true
139 # Check that the horizontal axis and margin units are all the same in all the subplots in every plots.
140 if not all(
141 i.hunits == subplot.hunits and i.munits == subplot.munits
142 for (_, row) in self._area.items()
143 for i in row[0]
144 ):
145 raise PlotterError("All subplots in every plot must have the same horizontal axis and margin units.")
147 self._area[row][column].append(subplot)
149 return self
151 def add_cursor(self, cursor: Cursor) -> "Plotter":
152 """Adds a cursor to the plot.
154 :param cursor: The definition of the cursor to add.
155 :raise PlotterError: The row and column indices given do not map to a plot.
156 :raise PlotterError: No subplot identifiers were given for cursor.
157 :raise PlotterError: At least one of the subplot identifiers in the cursor is invalid.
158 :raise PlotterError: The index of the cursor does not exist in all the subplots specified.
159 :return: Instance of the class."""
160 # Check if row and column are consistent.
161 if (plot := self._get_plot(cursor.row, cursor.column)) is None: 161 ↛ 162line 161 didn't jump to line 162, because the condition on line 161 was never true
162 raise PlotterError("The row and column indices given do not map to a plot.")
163 # Check if all the subplot identifiers exist in the plot.
164 if len(cursor.subplot_ids) == 0: 164 ↛ 165line 164 didn't jump to line 165, because the condition on line 164 was never true
165 raise PlotterError("No subplot identifiers were given for cursor.")
166 # Check if all the subplot identifiers exist in the plot.
167 if not all(subplot_id in [i.name for i in plot] for subplot_id in cursor.subplot_ids): 167 ↛ 168line 167 didn't jump to line 168, because the condition on line 167 was never true
168 raise PlotterError("At least one of the subplot identifiers does not exist in plot.")
169 # Check that all the indices exist in all the subplots.
170 if not all(0 <= cursor.hindex < len(i.hvalues) for i in plot if i.name in cursor.subplot_ids): 170 ↛ 171line 170 didn't jump to line 171, because the condition on line 170 was never true
171 raise PlotterError("The cursor horizontal axis index value does not exist in all subplots in plot.")
173 self._cursors.append(cursor)
175 return self
177 def plot(
178 self,
179 path: str,
180 dpi: float = 300.0,
181 figsize: tuple[float, float] = (19.20, 10.80),
182 raster_limit: int = 1920,
183 backend: str = "Agg",
184 ) -> "Plotter":
185 """Runs the plotter and saves the results to file.
187 :param path: Path where to store the resulting ``.png`` file with the plot.
188 :param dpi: See :class:`matplotlib.figure.Figure` for details.
189 :param figsize: See :class:`matplotlib.figure.Figure` for details, defaults to 1080p.
190 :param raster_limit: Uses rasters and pixel markers below this number of values to optimize plotting speed.
191 :param backend: See :meth:`matplotlib.figure.Figure.savefig` for details.
192 :return: Instance of the class."""
193 # pylint: disable=too-complex,too-many-arguments,too-many-locals,too-many-branches,too-many-statements
195 # Check if file exists, and if so, delete it.
196 if os.path.exists(path):
197 os.unlink(path)
198 # Check if directory name exists, if not, create directory structure.
199 if not os.path.exists(dir_path := os.path.dirname(path)):
200 os.makedirs(dir_path)
202 # If dealing with a common axis, then analyze all the current plots and calculate common begin and end values.
203 common_begin, common_end = 0, 0
204 if self._mode is Mode.SHARED_H_AXIS:
205 # Calculate the lowest start value, and use that for all plots.
206 common_begin = min(subplot.begin for (_, i) in self._area.items() for (_, j) in i.items() for subplot in j)
207 # Calculate the highest end value, and use that for all plots.
208 common_end = max(subplot.end for (_, i) in self._area.items() for (_, j) in i.items() for subplot in j)
210 # Create plots with specified custom style.
211 with matplotlib.pyplot.style.context(self._style): # type: ignore
212 # Create figure.
213 figure: matplotlib.figure.Figure = matplotlib.figure.Figure(dpi=dpi, figsize=figsize, layout="constrained")
215 # Create subplots per plot.
216 all_mpl_subplots = []
217 for row_i, row in self._area.items():
218 for column_i, column in row.items():
219 # If no subplots in current plot, then continue with the next.
220 if len(column) == 0:
221 continue
222 mpl_subplot = figure.add_subplot(self._rows, self._columns, row_i * self._columns + column_i + 1)
224 # Loop plots in the column.
225 for subplot in column:
226 # Calculate the begin and end values.
227 if self._mode is Mode.SHARED_H_AXIS:
228 begin, end = common_begin, common_end
229 else:
230 begin, end = subplot.begin, subplot.end
232 # Calculate begin and end values keeping into account the margin, with limits check.
233 mval = (end - begin) * subplot.munits
234 begin = subplot.hvalues[0] if (begin - mval) < subplot.hvalues[0] else (begin - mval)
235 end = subplot.hvalues[-1] if (end + mval) > subplot.hvalues[-1] else (end + mval)
237 # Get the relevand indices of the data to plot, if there is no data, the continue with the next.
238 indices = np.where((subplot.hvalues >= begin) & (subplot.hvalues <= end))[0]
239 if len(indices) == 0:
240 continue
242 # Get the values to plot from the horizontal and vertical axis.
243 hvalues = subplot.hvalues[indices]
244 vvalues = subplot.vvalues[indices]
246 # Plot, use rasters and use pixels as markers after the raster limit to speed up plotting.
247 marker = subplot.marker if any([len(hvalues) < raster_limit, subplot.marker == "none"]) else ","
248 mpl_subplot.plot(
249 hvalues,
250 vvalues,
251 label=subplot.name,
252 marker=marker,
253 linestyle=subplot.linestyle,
254 color=subplot.color,
255 rasterized=not len(hvalues) < raster_limit,
256 )
258 # Configure labels on both axes, this is redundant past the first call since we've
259 # already checked all the units are the same, but keep it here for type checkers.
260 mpl_subplot.set_xlabel(
261 f"{subplot.hunits.magnitude} / {subplot.hunits.name} ({subplot.hunits.symbol})",
262 fontsize="large",
263 )
264 mpl_subplot.set_ylabel(
265 f"{subplot.vunits.magnitude} / {subplot.vunits.name} ({subplot.vunits.symbol})",
266 fontsize="large",
267 )
269 # Disable margins on the X axis, as we control them above.
270 mpl_subplot.margins(x=0)
272 # Only have major X and major Y axis active.
273 mpl_subplot.grid(False, "both", "both")
274 mpl_subplot.grid(True, "major", "x")
275 mpl_subplot.grid(True, "major", "y")
277 # Configure fontsize of the major ticks numbers on the X and Y axis.
278 mpl_subplot.tick_params(axis="x", which="major", labelsize="medium")
279 mpl_subplot.tick_params(axis="y", which="major", labelsize="medium")
281 # Give some padding to labels.
282 mpl_subplot.xaxis.labelpad = 10
283 mpl_subplot.yaxis.labelpad = 10
285 # Set legend for the subplots with some transparency.
286 mpl_subplot.add_artist(
287 mpl_subplot.legend(
288 title="Plots",
289 title_fontsize="medium",
290 loc="lower left",
291 frameon=True,
292 framealpha=0.60,
293 fontsize="small",
294 )
295 )
297 # Collect relevant cursors for this plot and handle them.
298 all_mpl_cursors = []
299 cursors = tuple(i for i in self._cursors if all([i.row == row_i, i.column == column_i]))
300 for _, cursor in enumerate(cursors):
301 # Get relevant subplots for cursor, at least one exists.
302 subplots = [subplot for subplot in column if subplot.name in cursor.subplot_ids]
303 # Get horizontal value from first subplot as a reference.
304 cursor_hvalue = subplots[0].hvalues[cursor.hindex]
306 # Build label for cursor.
307 label = f"{cursor.name}: [X: {np.round(cursor_hvalue, cursor.hvdec)}"
308 for subplot in subplots:
309 label += f", {subplot.name}: {np.round(subplot.vvalues[cursor.hindex], cursor.vvdec)}"
310 label += "]"
312 # Plot cursor as a vertical line.
313 all_mpl_cursors.append(
314 mpl_subplot.axvline(
315 cursor_hvalue,
316 linewidth=1,
317 linestyle=cursor.linestyle,
318 color=cursor.color,
319 label=label,
320 )
321 )
323 # Annotate the name of the cursor on top of it, if overlaps occur, that is on the user
324 # who should place cursors not too close to each other or with shorter names.
325 mpl_subplot.annotate(
326 cursor.name,
327 (cursor_hvalue, mpl_subplot.get_ylim()[1]),
328 ha="left",
329 va="center",
330 annotation_clip=False,
331 xytext=(-3, 8),
332 textcoords="offset points",
333 fontsize="medium",
334 color=cursor.color,
335 path_effects=[
336 matplotlib.patheffects.Stroke(linewidth=1, foreground="black"),
337 matplotlib.patheffects.Normal(),
338 ],
339 )
341 # If cursors were added, then also add the legend for cursors with some transparency.
342 if len(all_mpl_cursors) > 0:
343 mpl_subplot.add_artist(
344 mpl_subplot.legend(
345 handles=all_mpl_cursors,
346 title="Cursors",
347 title_fontsize="medium",
348 loc="lower right",
349 frameon=True,
350 framealpha=0.60,
351 fontsize="small",
352 handletextpad=0,
353 handlelength=0,
354 )
355 )
357 # Append subplot to list of subplots.
358 all_mpl_subplots.append(mpl_subplot)
360 # Adjust shared axis on all subplots if set for that mode.
361 if self._mode is Mode.SHARED_H_AXIS:
362 for mpl_subplot_i, mpl_subplot in enumerate(all_mpl_subplots):
363 # Create the shared axis when there is at least two subplots.
364 if mpl_subplot_i > 0:
365 mpl_subplot.sharex(all_mpl_subplots[mpl_subplot_i - 1])
366 # Hide all horizontal axis, except for the last one, but keep grids visible.
367 if mpl_subplot_i != (len(all_mpl_subplots) - 1):
368 mpl_subplot.tick_params(axis="x", which="both", labelcolor="None", labelsize=0)
369 mpl_subplot.set_xlabel("")
371 # Adjust padding between subplots.
372 figure.get_layout_engine().set(w_pad=1 / 10, h_pad=1 / 10, hspace=1 / 20, wspace=1 / 20) # type: ignore
374 # Configure backends.
375 if backend == "Agg":
376 matplotlib.rcParams["agg.path.chunksize"] = 100000
378 # Save to file or display on screen.
379 figure.savefig(path, format="png", backend=backend)
381 return self