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

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. 

5 

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: 

8 

9.. figure:: ../.assets/img/006_example_complex_plot_0.png 

10 :width: 600 

11 :align: center 

12  

13 Plot of three different signals with synchronized edges, with cursors, state levels and points of the edges. 

14  

15.. figure:: ../.assets/img/007_example_complex_plot_1.png 

16 :width: 600 

17 :align: center 

18  

19 The same plot as before but with the original unfiltered signals in grey. 

20 

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.""" 

23 

24import os 

25 

26import matplotlib 

27import matplotlib.axes 

28import matplotlib.figure 

29import matplotlib.patheffects 

30import matplotlib.pyplot 

31import numpy as np 

32 

33from ..definitions import get_logger 

34from ..exceptions import PlotterError 

35from .definitions import Cursor, Mode, Plot, PlotArea, Subplot 

36 

37 

38class Plotter: 

39 """Implementation of an plotter based on `matplotlib`.""" 

40 

41 #: Path to the matplotlib style file. 

42 _style = os.path.join(os.path.normpath(os.path.dirname(__file__)), "style", "style.mplstyle") 

43 

44 ## Private API ##################################################################################################### 

45 def __init__(self, *args, mode: Mode = Mode.LINEAR, rows: int = 1, columns: int = 1, **kwargs) -> None: 

46 """Class constructor. 

47 

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 

55 

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 

64 

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.") 

72 

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] = [] 

77 

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. 

81 

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] 

88 

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. 

91 

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 

99 

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. 

103 

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.") 

136 

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.") 

146 

147 self._area[row][column].append(subplot) 

148 

149 return self 

150 

151 def add_cursor(self, cursor: Cursor) -> "Plotter": 

152 """Adds a cursor to the plot. 

153 

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.") 

172 

173 self._cursors.append(cursor) 

174 

175 return self 

176 

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. 

186 

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 

194 

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) 

201 

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) 

209 

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") 

214 

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) 

223 

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 

231 

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) 

236 

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 

241 

242 # Get the values to plot from the horizontal and vertical axis. 

243 hvalues = subplot.hvalues[indices] 

244 vvalues = subplot.vvalues[indices] 

245 

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 ) 

257 

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 ) 

268 

269 # Disable margins on the X axis, as we control them above. 

270 mpl_subplot.margins(x=0) 

271 

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") 

276 

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") 

280 

281 # Give some padding to labels. 

282 mpl_subplot.xaxis.labelpad = 10 

283 mpl_subplot.yaxis.labelpad = 10 

284 

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 ) 

296 

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] 

305 

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 += "]" 

311 

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 ) 

322 

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 ) 

340 

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 ) 

356 

357 # Append subplot to list of subplots. 

358 all_mpl_subplots.append(mpl_subplot) 

359 

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("") 

370 

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 

373 

374 # Configure backends. 

375 if backend == "Agg": 

376 matplotlib.rcParams["agg.path.chunksize"] = 100000 

377 

378 # Save to file or display on screen. 

379 figure.savefig(path, format="png", backend=backend) 

380 

381 return self