Coverage for pyrc \ visualization \ plot.py: 21%
289 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-13 16:59 +0200
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-13 16:59 +0200
1# -------------------------------------------------------------------------------
2# Copyright (C) 2026 Joel Kimmich, Tim Jourdan
3# ------------------------------------------------------------------------------
4# License
5# This file is part of PyRC, distributed under GPL-3.0-or-later.
6# ------------------------------------------------------------------------------
7from __future__ import annotations
8from datetime import datetime, timedelta
9from typing import TYPE_CHECKING
11import numpy as np
12from matplotlib import pyplot as plt
13import matplotlib.dates as mdates
14import matplotlib.ticker as ticker
16from pyrc.tools.plotting import format_date_x_axis, custom_numeric_ticks_formatter
17from pyrc.tools.science import cm_to_inch, is_numeric
19if TYPE_CHECKING:
20 pass
21# plt.style.use('tableau-colorblind10')
22# print(plt.style.available)
23# load style sheet
24# plt.style.use(os.path.normpath(os.path.join(package_dir, "visualization", "plotsettings.mplstyle")))
25# plt.rcParams["axes.prop_cycle"] = plt.cycler("color", )
28class PlotMixin(object):
29 def __init__(self, x, ys, y_title="Heat Flux / W", marker_size=6, x_title=None, width_mm=160, height_mm=9):
30 self.fig, self.ax = plt.subplots(layout="constrained")
31 self.fig.set_size_inches(cm_to_inch(width_mm / 10), cm_to_inch(height_mm / 10))
33 self.x = x
34 self.ys = ys
36 if isinstance(self.x[0], datetime):
37 self.x_is_datetime = True
38 else:
39 self.x_is_datetime = False
41 self.y_title = y_title
42 self.x_title = x_title
43 self.marker_size = marker_size
45 self.lines = None
46 self.labels = []
48 self.next_color = self.color_iter()
49 self.next_line_style = self.line_style_iter()
50 self.next_marker = self.marker_iter()
52 def __del__(self):
53 if self.fig is not None:
54 plt.close(self.fig)
56 @property
57 def markers(self) -> list[str]:
58 return ["o", "s", "^", "v", "<", ">", "d", "p", "*", "h"]
60 def marker_iter(self):
61 markers = self.markers
62 i = 0
63 n = len(markers)
64 while True:
65 yield markers[i]
66 i = (i + 1) % n
68 @property
69 def colors(self) -> list[tuple[float, float, float]]:
70 return [
71 (0.0051932, 0.098238, 0.34984),
72 (0.98135, 0.80041, 0.98127),
73 (0.51125, 0.5109, 0.1933),
74 (0.1333, 0.37528, 0.3794),
75 (0.94661, 0.61422, 0.41977),
76 (0.066899, 0.26319, 0.37759),
77 (0.9929, 0.70485, 0.70411),
78 (0.30238, 0.45028, 0.30012),
79 (0.75427, 0.56503, 0.21176),
80 (0.40297, 0.48047, 0.24473),
81 ]
83 def line_style_iter(self):
84 n = len(self.colors)
85 styles = ["-", "--", ":", "-."]
86 i = 0
87 while True:
88 yield styles[i // n]
89 i += 1
90 if i >= n * len(styles):
91 i = 0
93 def color_iter(self):
94 colors = self.colors
95 i = 0
96 n = len(colors)
97 while True:
98 yield tuple(colors[i])
99 i = (i + 1) % n
101 def _add_line(self, line):
102 if self.lines is None:
103 self.lines = line
104 else:
105 self.lines = self.lines + line
107 def _add_line_and_label(self, line, label=None):
108 if self.lines is None:
109 self.lines = line
110 else:
111 self.lines = self.lines + line
112 self.labels.append(label)
114 def format(self):
115 if self.x_title is not None:
116 self.ax.set_xlabel(self.x_title)
117 if self.y_title is not None:
118 self.ax.set_ylabel(self.y_title)
119 self.ax.grid(True)
120 self.ax.set_xlim(left=self.x[0], right=self.x[-1])
121 if self.x_is_datetime:
122 self.formate_x_datetime()
124 def formate_x_datetime(self, start=None, end=None):
125 if start is None:
126 start = self.x[0]
127 if end is None:
128 end = self.x[-1]
129 format_date_x_axis(start, end, self.ax, return_version=False)
131 def format_numeric_ticks(self):
132 formatter = ticker.FuncFormatter(custom_numeric_ticks_formatter)
134 for ax in self.fig.get_axes():
135 # Check if y-axis has numeric data
136 try:
137 current_formatter = ax.yaxis.get_major_formatter()
138 if not isinstance(current_formatter, plt.matplotlib.dates.DateFormatter):
139 ax.yaxis.set_major_formatter(formatter)
140 except:
141 pass
143 # Check x-axis
144 if not self.x_is_datetime and not isinstance(self.x[0], str):
145 try:
146 current_formatter = ax.xaxis.get_major_formatter()
147 if not isinstance(current_formatter, plt.matplotlib.dates.DateFormatter):
148 ax.xaxis.set_major_formatter(formatter)
149 except:
150 pass
152 def show_legend(self, **kwargs):
153 initial_kwargs = {"loc": "outside upper right", "ncols": min(4, len(self.labels))}
154 initial_kwargs.update(kwargs)
155 self.fig.legend(handles=self.lines, labels=self.labels, **initial_kwargs)
157 def show(self):
158 self.format_numeric_ticks()
159 plt.show()
161 def save(self, path):
162 self.format_numeric_ticks()
163 self.fig.savefig(path, dpi=600)
165 def close(self):
166 if self.fig is not None:
167 plt.close(self.fig)
170class DoubleY(PlotMixin):
171 def __init__(
172 self,
173 x,
174 ys_left: list,
175 ys_right: list,
176 labels_left=None,
177 labels_right=None,
178 y_title_left="Heat Flux / W",
179 y_title_right="Temperature / K",
180 marker_size=6,
181 **kwargs,
182 ):
183 if not isinstance(ys_left, list):
184 ys_left = [ys_left]
185 if not isinstance(ys_right, list):
186 ys_right = [ys_right]
187 ys_left = [np.array(y) for y in ys_left]
188 ys_right = [np.array(y) for y in ys_right]
190 super().__init__(x=np.array(x), ys=ys_left, y_title=y_title_left, marker_size=marker_size, **kwargs)
192 self.ys_left = ys_left
193 self.ys_right = ys_right
194 self.y_title_right = y_title_right
196 self.ax_right = self.ax.twinx()
198 if labels_left is None:
199 labels_left = [None] * len(ys_left)
200 if labels_right is None:
201 labels_right = [None] * len(ys_right)
202 if not isinstance(labels_left, list):
203 labels_left = [labels_left]
204 if not isinstance(labels_right, list):
205 labels_right = [labels_right]
207 self.labels_left = labels_left
208 self.labels_right = labels_right
210 @property
211 def ax_left(self):
212 return self.ax
214 def scale_right_axis(self):
215 """
216 Scales the right axis so that the major ticks matches the left one.
217 """
218 left_ticks = self.ax.get_yticks()
219 num_ticks = len(left_ticks)
221 # Get right axis data range
222 right_data_min, right_data_max = self.ax_right.get_ylim()
223 right_range = right_data_max - right_data_min
225 # Generate nice spacings: [1,2,5] * 10^n
226 nice_spacings = []
227 for n in range(-10, 10):
228 for base in [1, 2, 3, 4, 5, 6]:
229 nice_spacings.append(base * 10**n)
231 # Find the minimum spacing that can cover the data range with num_ticks-1 intervals
232 target_spacing = right_range / (num_ticks - 1)
233 right_tick_spacing = min([s for s in nice_spacings if s >= target_spacing])
235 # Calculate new right axis limits based on nice spacing
236 right_min = np.floor(right_data_min / right_tick_spacing) * right_tick_spacing
237 right_max = right_min + (num_ticks - 1) * right_tick_spacing
239 # Create right axis ticks
240 right_ticks = np.linspace(right_min, right_max, num_ticks)
242 self.ax_right.set_ylim(right_min, right_max)
243 self.ax_right.set_yticks(right_ticks)
245 def plot(self):
247 for i, y in enumerate(self.ys_left):
248 self._add_line_and_label(
249 self.ax.plot(
250 self.x,
251 y,
252 label=self.labels_left[i],
253 color=next(self.next_color),
254 marker=next(self.next_marker),
255 markersize=self.marker_size,
256 linestyle="None",
257 ),
258 self.labels_left[i],
259 )
261 for i, y in enumerate(self.ys_right):
262 self._add_line_and_label(
263 self.ax_right.plot(
264 self.x,
265 y,
266 label=self.labels_right[i],
267 color=next(self.next_color),
268 marker=next(self.next_marker),
269 markersize=self.marker_size,
270 linestyle="None",
271 ),
272 self.labels_right[i],
273 )
275 self.format()
277 def format(self):
278 if self.x_title is not None:
279 self.ax.set_xlabel(self.x_title)
280 if self.y_title is not None:
281 self.ax.set_ylabel(self.y_title)
282 if self.y_title_right is not None:
283 self.ax_right.set_ylabel(self.y_title_right)
285 self.ax.grid(True)
287 if not isinstance(self.x[0], str):
288 dx = self.x[1] - self.x[0] if len(self.x) > 1 else 0
289 self.ax.set_xlim(left=self.x[0] - dx / 2, right=self.x[-1] + dx / 2)
291 if self.x_is_datetime:
292 self.formate_x_datetime(self.x[0] - dx / 2, self.x[-1] + dx / 2)
294 self.format_numeric_ticks()
297class DoubleYSeparated(DoubleY):
298 def __init__(
299 self,
300 x,
301 ys_left: list,
302 ys_right: list,
303 labels=None,
304 y_title_left="Heat Flux / W",
305 y_title_right="Temperature / K",
306 marker_size=6,
307 same_marker=False,
308 ):
309 super().__init__(
310 x, ys_left, ys_right, y_title_left=y_title_left, y_title_right=y_title_right, marker_size=marker_size
311 )
313 if labels is None:
314 labels = [None] * len(ys_left)
315 if not isinstance(labels, list):
316 labels = [labels]
317 self.labels = labels
318 self.same_marker = same_marker
320 def plot(self):
322 for i, (y_left, y_right) in enumerate(zip(self.ys_left, self.ys_right)):
323 marker = next(self.next_marker)
325 self._add_line(
326 self.ax.plot(
327 self.x,
328 y_left,
329 color="black",
330 marker=marker,
331 markersize=self.marker_size,
332 linestyle="None",
333 )
334 )
336 if not self.same_marker:
337 marker = next(self.next_marker)
339 self._add_line(
340 self.ax_right.plot(
341 self.x,
342 y_right,
343 color=self.colors[0],
344 marker=marker,
345 markersize=self.marker_size,
346 linestyle="None",
347 )
348 )
350 if self.labels[i] is not None and not self.same_marker:
351 self.ax.plot(
352 [],
353 [],
354 color="black",
355 marker=marker,
356 linestyle="None",
357 markersize=self.marker_size,
358 label=self.labels[i],
359 )
361 self.format()
363 def format(self):
364 super().format()
365 self.ax.yaxis.label.set_color("black")
366 self.ax.tick_params(axis="y", colors="black")
367 self.ax_right.yaxis.label.set_color(self.colors[0])
368 self.ax_right.tick_params(axis="y", colors=self.colors[0])
371class LinePlot(PlotMixin):
372 def __init__(
373 self,
374 x,
375 ys: list | np.ndarray,
376 labels=None,
377 y_scale=1,
378 y_title="Values",
379 linewidth=1.8,
380 x_title=None,
381 width_mm=160,
382 height_mm=90,
383 ):
384 if not isinstance(ys, list):
385 if not (isinstance(ys, np.ndarray) and len(ys.shape) > 1 and ys.shape[0] > 1 and ys.shape[1] > 1):
386 ys = [np.array(ys)]
387 ys = [np.array(y) for y in ys]
388 super().__init__(x=np.array(x), ys=ys, y_title=y_title, x_title=x_title, width_mm=width_mm, height_mm=height_mm)
390 if labels is None:
391 labels = [None] * len(ys)
392 if not isinstance(labels, list):
393 labels = [labels]
394 self.labels = labels
396 self.y_scale = y_scale
397 self.line_width = linewidth
399 def plot(self, x=None, ys=None, labels=None):
400 if x is None:
401 x = self.x
402 if ys is None:
403 ys = self.ys
404 if labels is None:
405 labels = self.labels
406 if not isinstance(labels, list):
407 labels = [labels]
408 for i, y in enumerate(ys):
409 self.ax.plot(
410 x,
411 np.array(y) * self.y_scale,
412 label=labels[i],
413 color=next(self.next_color),
414 linewidth=self.line_width,
415 linestyle=next(self.next_line_style),
416 )
417 self.format()
419 def plot_marker(self):
420 for i, y in enumerate(self.ys):
421 self.ax.plot(
422 self.x,
423 np.array(y) * self.y_scale,
424 label=self.labels[i],
425 color=next(self.next_color),
426 marker=next(self.next_marker),
427 )
428 self.format()
430 def plot_stack(self):
431 self.ax.stackplot(
432 self.x,
433 *[y * self.y_scale for y in self.ys],
434 labels=self.labels,
435 colors=self.colors,
436 )
437 self.format()
439 def format(self):
440 super().format()
441 self.format_numeric_ticks()
444class TimePlot(LinePlot):
445 def format(self):
446 super().format()
447 self.formate_x_datetime()
448 self.format_numeric_ticks()
451class BarPlot(PlotMixin):
452 def __init__(self, bar_values, bar_positions=None, y_title="Heat / kWh", **kwargs):
453 assert len(bar_values) > 1
454 if bar_positions is None:
455 bar_positions = range(len(bar_values))
456 super().__init__(x=bar_positions, ys=bar_values, y_title=y_title, **kwargs)
458 @property
459 def bar_width(self):
460 if len(self.x) > 1:
461 diff = np.diff(self.x)
462 min_diff = np.min(diff)
463 return min_diff * 0.8
464 return 1
466 def plot(self):
467 self.ax.bar(self.x, self.ys, width=self.bar_width, color=next(self.next_color))
468 self.format()
470 def format(self):
471 super().format()
472 if is_numeric(self.x[0]):
473 self.ax.set_xlim(left=self.x[0] - self.bar_width / 0.8 / 2, right=self.x[-1] + self.bar_width / 0.8 / 2)
474 self.format_numeric_ticks()
477class TimeBarPlot(BarPlot):
478 def __init__(self, bar_values, bar_positions, y_title="Heat / kWh", **kwargs):
479 super().__init__(bar_values, bar_positions, y_title=y_title, **kwargs)
481 @property
482 def bar_width(self) -> float:
483 if len(self.x) > 1:
484 date_nums = mdates.date2num(self.x)
485 time_diffs = np.diff(date_nums)
486 min_diff = time_diffs[0]
487 return float(min_diff * 0.8)
488 else:
489 return 1 / 24
491 def format(self):
492 super().format()
493 format_date_x_axis(self.x[0], self.x[-1], self.ax, return_version=False)
494 self.ax.set_xlim(
495 left=self.x[0] - timedelta(days=self.bar_width / 0.8 / 2),
496 right=self.x[-1] + timedelta(days=self.bar_width / 0.8 / 2),
497 )
498 self.format_numeric_ticks()
501def seconds_to_dates(seconds: list, start_date=datetime(2023, 1, 1), return_array=False) -> list | np.ndarray:
502 result = [start_date + timedelta(seconds=int(s)) for s in seconds]
503 if return_array:
504 return np.array(result)
505 return result
508def format_x_axis_to_date(fig, ax):
509 ax.xaxis.set_major_locator(mdates.DayLocator(bymonthday=(1, 7, 14, 21, 28)))
510 ax.xaxis.set_minor_locator(mdates.DayLocator(interval=1))
511 ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
512 ax.grid(True)
513 # for label in ax.get_xticklabels(which="major"):
514 # label.set(rotation=30, horizontalalignment="right")
515 ax.tick_params(axis="x", which="minor", bottom=True)
516 fig.autofmt_xdate()
517 return fig, ax