Coverage for pyrc\visualization\plot.py: 20%
303 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-29 15:57 +0200
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-29 15:57 +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
23# plt.style.use('tableau-colorblind10')
24# print(plt.style.available)
25# load style sheet
26# plt.style.use(os.path.normpath(os.path.join(package_dir, "visualization", "plotsettings.mplstyle")))
27# plt.rcParams["axes.prop_cycle"] = plt.cycler("color", )
30class PlotMixin(object):
31 def __init__(self, x, ys, y_title="Heat Flux / W", marker_size=6, x_title=None, width_mm=160, height_mm=90):
32 self.fig, self.ax = plt.subplots(layout="constrained")
33 self.fig.set_size_inches(cm_to_inch(width_mm / 10), cm_to_inch(height_mm / 10))
35 self.x = x
36 self.ys = ys
38 if isinstance(self.x[0], datetime):
39 self.x_is_datetime = True
40 else:
41 self.x_is_datetime = False
43 self.y_title = y_title
44 self.x_title = x_title
45 self.marker_size = marker_size
47 self.lines = None
48 self.labels = []
50 self.next_color = self.color_iter()
51 self.next_line_style = self.line_style_iter()
52 self.next_marker = self.marker_iter()
54 def __del__(self):
55 if self.fig is not None:
56 plt.close(self.fig)
58 @property
59 def markers(self) -> list[str]:
60 return ["o", "s", "^", "v", "<", ">", "d", "p", "*", "h"]
62 def marker_iter(self):
63 markers = self.markers
64 i = 0
65 n = len(markers)
66 while True:
67 yield markers[i]
68 i = (i + 1) % n
70 @property
71 def colors(self) -> list[tuple[float, float, float]]:
72 return [
73 (0.0051932, 0.098238, 0.34984),
74 (0.98135, 0.80041, 0.98127),
75 (0.51125, 0.5109, 0.1933),
76 (0.1333, 0.37528, 0.3794),
77 (0.94661, 0.61422, 0.41977),
78 (0.066899, 0.26319, 0.37759),
79 (0.9929, 0.70485, 0.70411),
80 (0.30238, 0.45028, 0.30012),
81 (0.75427, 0.56503, 0.21176),
82 (0.40297, 0.48047, 0.24473),
83 ]
85 def line_style_iter(self):
86 n = len(self.colors)
87 styles = ["-", "--", ":", "-."]
88 i = 0
89 while True:
90 yield styles[i // n]
91 i += 1
92 if i >= n * len(styles):
93 i = 0
95 def color_iter(self):
96 colors = self.colors
97 i = 0
98 n = len(colors)
99 while True:
100 yield tuple(colors[i])
101 i = (i + 1) % n
103 def _add_line(self, line):
104 if self.lines is None:
105 self.lines = line
106 else:
107 self.lines = self.lines + line
109 def _add_line_and_label(self, line, label=None):
110 if self.lines is None:
111 self.lines = line
112 else:
113 self.lines = self.lines + line
114 self.labels.append(label)
116 def format(self):
117 if self.x_title is not None:
118 self.ax.set_xlabel(self.x_title)
119 if self.y_title is not None:
120 self.ax.set_ylabel(self.y_title)
121 self.ax.grid(True)
122 self.ax.set_xlim(left=self.x[0], right=self.x[-1])
123 if self.x_is_datetime:
124 self.formate_x_datetime()
126 def formate_x_datetime(self, start=None, end=None):
127 if start is None:
128 start = self.x[0]
129 if end is None:
130 end = self.x[-1]
131 format_date_x_axis(start, end, self.ax, return_version=False)
133 def format_numeric_ticks(self):
134 formatter = ticker.FuncFormatter(custom_numeric_ticks_formatter)
136 for ax in self.fig.get_axes():
137 # Check if y-axis has numeric data
138 try:
139 current_formatter = ax.yaxis.get_major_formatter()
140 if not isinstance(current_formatter, plt.matplotlib.dates.DateFormatter):
141 ax.yaxis.set_major_formatter(formatter)
142 except:
143 pass
145 # Check x-axis
146 if not self.x_is_datetime and not isinstance(self.x[0], str):
147 try:
148 current_formatter = ax.xaxis.get_major_formatter()
149 if not isinstance(current_formatter, plt.matplotlib.dates.DateFormatter):
150 ax.xaxis.set_major_formatter(formatter)
151 except:
152 pass
154 def show_legend(self, **kwargs):
155 initial_kwargs = {"loc": "outside upper right", "ncols": min(4, len(self.labels))}
156 initial_kwargs.update(kwargs)
157 self.fig.legend(handles=self.lines, labels=self.labels, **initial_kwargs)
159 def show(self):
160 self.format_numeric_ticks()
161 plt.show()
163 def save(self, path):
164 self.format_numeric_ticks()
165 self.fig.savefig(path, dpi=600)
167 def close(self):
168 if self.fig is not None:
169 plt.close(self.fig)
172class DoubleY(PlotMixin):
173 def __init__(
174 self,
175 x,
176 ys_left: list,
177 ys_right: list,
178 labels_left=None,
179 labels_right=None,
180 y_title_left="Heat Flux / W",
181 y_title_right="Temperature / K",
182 marker_size=6,
183 **kwargs,
184 ):
185 if not isinstance(ys_left, list):
186 ys_left = [ys_left]
187 if not isinstance(ys_right, list):
188 ys_right = [ys_right]
189 ys_left = [np.array(y) for y in ys_left]
190 ys_right = [np.array(y) for y in ys_right]
192 super().__init__(x=np.array(x), ys=ys_left, y_title=y_title_left, marker_size=marker_size, **kwargs)
194 self.ys_left = ys_left
195 self.ys_right = ys_right
196 self.y_title_right = y_title_right
198 self.ax_right = self.ax.twinx()
200 if labels_left is None:
201 labels_left = [None] * len(ys_left)
202 if labels_right is None:
203 labels_right = [None] * len(ys_right)
204 if not isinstance(labels_left, list):
205 labels_left = [labels_left]
206 if not isinstance(labels_right, list):
207 labels_right = [labels_right]
209 self.labels_left = labels_left
210 self.labels_right = labels_right
212 @property
213 def ax_left(self):
214 return self.ax
216 def scale_right_axis(self):
217 """
218 Scales the right axis so that the major ticks matches the left one.
219 """
220 left_ticks = self.ax.get_yticks()
221 num_ticks = len(left_ticks)
223 # Get right axis data range
224 right_data_min, right_data_max = self.ax_right.get_ylim()
225 right_range = right_data_max - right_data_min
227 # Generate nice spacings: [1,2,5] * 10^n
228 nice_spacings = []
229 for n in range(-10, 10):
230 for base in [1, 2, 3, 4, 5, 6]:
231 nice_spacings.append(base * 10 ** n)
233 # Find the minimum spacing that can cover the data range with num_ticks-1 intervals
234 target_spacing = right_range / (num_ticks - 1)
235 right_tick_spacing = min([s for s in nice_spacings if s >= target_spacing])
237 # Calculate new right axis limits based on nice spacing
238 right_min = np.floor(right_data_min / right_tick_spacing) * right_tick_spacing
239 right_max = right_min + (num_ticks - 1) * right_tick_spacing
241 # Create right axis ticks
242 right_ticks = np.linspace(right_min, right_max, num_ticks)
244 self.ax_right.set_ylim(right_min, right_max)
245 self.ax_right.set_yticks(right_ticks)
247 def plot(self):
249 for i, y in enumerate(self.ys_left):
250 self._add_line_and_label(
251 self.ax.plot(
252 self.x,
253 y,
254 label=self.labels_left[i],
255 color=next(self.next_color),
256 marker=next(self.next_marker),
257 markersize=self.marker_size,
258 linestyle="None",
259 ),
260 self.labels_left[i],
261 )
263 for i, y in enumerate(self.ys_right):
264 self._add_line_and_label(
265 self.ax_right.plot(
266 self.x,
267 y,
268 label=self.labels_right[i],
269 color=next(self.next_color),
270 marker=next(self.next_marker),
271 markersize=self.marker_size,
272 linestyle="None",
273 ),
274 self.labels_right[i],
275 )
277 self.format()
279 def format(self):
280 if self.x_title is not None:
281 self.ax.set_xlabel(self.x_title)
282 if self.y_title is not None:
283 self.ax.set_ylabel(self.y_title)
284 if self.y_title_right is not None:
285 self.ax_right.set_ylabel(self.y_title_right)
287 self.ax.grid(True)
289 if not isinstance(self.x[0], str):
290 dx = self.x[1] - self.x[0] if len(self.x) > 1 else 0
291 self.ax.set_xlim(left=self.x[0] - dx / 2, right=self.x[-1] + dx / 2)
293 if self.x_is_datetime:
294 self.formate_x_datetime(self.x[0] - dx / 2, self.x[-1] + dx / 2)
296 self.format_numeric_ticks()
299class DoubleYSeparated(DoubleY):
300 def __init__(
301 self,
302 x,
303 ys_left: list,
304 ys_right: list,
305 labels=None,
306 y_title_left="Heat Flux / W",
307 y_title_right="Temperature / K",
308 marker_size=6,
309 same_marker=False,
310 ):
311 super().__init__(
312 x, ys_left, ys_right, y_title_left=y_title_left, y_title_right=y_title_right, marker_size=marker_size
313 )
315 if labels is None:
316 labels = [None] * len(ys_left)
317 if not isinstance(labels, list):
318 labels = [labels]
319 self.labels = labels
320 self.same_marker = same_marker
322 def plot(self):
324 for i, (y_left, y_right) in enumerate(zip(self.ys_left, self.ys_right)):
325 marker = next(self.next_marker)
327 self._add_line(
328 self.ax.plot(
329 self.x,
330 y_left,
331 color="black",
332 marker=marker,
333 markersize=self.marker_size,
334 linestyle="None",
335 )
336 )
338 if not self.same_marker:
339 marker = next(self.next_marker)
341 self._add_line(
342 self.ax_right.plot(
343 self.x,
344 y_right,
345 color=self.colors[0],
346 marker=marker,
347 markersize=self.marker_size,
348 linestyle="None",
349 )
350 )
352 if self.labels[i] is not None and not self.same_marker:
353 self.ax.plot(
354 [],
355 [],
356 color="black",
357 marker=marker,
358 linestyle="None",
359 markersize=self.marker_size,
360 label=self.labels[i],
361 )
363 self.format()
365 def format(self):
366 super().format()
367 self.ax.yaxis.label.set_color("black")
368 self.ax.tick_params(axis="y", colors="black")
369 self.ax_right.yaxis.label.set_color(self.colors[0])
370 self.ax_right.tick_params(axis="y", colors=self.colors[0])
373class LinePlot(PlotMixin):
374 def __init__(
375 self,
376 x,
377 ys: list | np.ndarray,
378 labels=None,
379 y_scale=1,
380 y_title="Values",
381 linewidth=1.8,
382 x_title=None,
383 width_mm=160,
384 height_mm=90,
385 ):
386 if not isinstance(ys, list):
387 if not (isinstance(ys, np.ndarray) and len(ys.shape) > 1 and ys.shape[0] > 1 and ys.shape[1] > 1):
388 ys = [np.array(ys)]
389 ys = [np.array(y) for y in ys]
390 super().__init__(x=np.array(x), ys=ys, y_title=y_title, x_title=x_title, width_mm=width_mm, height_mm=height_mm)
392 if labels is None:
393 labels = [None] * len(ys)
394 if not isinstance(labels, list):
395 labels = [labels]
396 self.labels = labels
398 self.y_scale = y_scale
399 self.line_width = linewidth
401 def plot(self, x=None, ys=None, labels=None):
402 if x is None:
403 x = self.x
404 if ys is None:
405 ys = self.ys
406 if labels is None:
407 labels = self.labels
408 if not isinstance(labels, list):
409 labels = [labels]
410 for i, y in enumerate(ys):
411 self._add_line(
412 self.ax.plot(
413 x,
414 np.array(y) * self.y_scale,
415 label=labels[i],
416 color=next(self.next_color),
417 linewidth=self.line_width,
418 linestyle=next(self.next_line_style),
419 )
420 )
421 self.format()
423 def add_points(self, x, ys, labels=None):
424 """
425 Add points to the plot (e.g. measurement data to interpolated lines)
427 The ys values are not scaled with self.y_scale!
429 Parameters
430 ----------
431 x : float | int | np.ndarray | list
432 x values
433 ys : float | int | np.ndarray | list
434 y values. If two-dimensional several point plots are added with different colors.
435 labels : list | str, optional
436 The labels of the point groups, accordingly
437 """
438 if not isinstance(x, np.ndarray):
439 x = np.array(x)
440 if not isinstance(ys, np.ndarray):
441 ys = np.array(ys)
442 ys = np.atleast_2d(ys)
443 if labels is None:
444 labels = [None] * len(ys)
445 if not isinstance(labels, list):
446 labels = [labels]
447 assert len(labels) == len(ys)
449 for i, y in enumerate(ys):
450 self._add_line_and_label(
451 self.ax.plot(
452 x,
453 np.array(y),
454 label=labels[i],
455 color=next(self.next_color),
456 linestyle="none",
457 marker=next(self.next_marker),
458 ),
459 labels[i]
460 )
461 self.format()
463 def plot_marker(self):
464 for i, y in enumerate(self.ys):
465 self.ax.plot(
466 self.x,
467 np.array(y) * self.y_scale,
468 label=self.labels[i],
469 color=next(self.next_color),
470 marker=next(self.next_marker),
471 )
472 self.format()
474 def plot_stack(self):
475 self.ax.stackplot(
476 self.x,
477 *[y * self.y_scale for y in self.ys],
478 labels=self.labels,
479 colors=self.colors,
480 )
481 self.format()
483 def format(self):
484 super().format()
485 self.format_numeric_ticks()
488class TimePlot(LinePlot):
489 def format(self):
490 super().format()
491 self.formate_x_datetime()
492 self.format_numeric_ticks()
495class BarPlot(PlotMixin):
496 def __init__(self, bar_values, bar_positions=None, y_title="Heat / kWh", **kwargs):
497 assert len(bar_values) > 1
498 if bar_positions is None:
499 bar_positions = range(len(bar_values))
500 super().__init__(x=bar_positions, ys=bar_values, y_title=y_title, **kwargs)
502 @property
503 def bar_width(self):
504 if len(self.x) > 1:
505 diff = np.diff(self.x)
506 min_diff = np.min(diff)
507 return min_diff * 0.8
508 return 1
510 def plot(self):
511 self.ax.bar(self.x, self.ys, width=self.bar_width, color=next(self.next_color))
512 self.format()
514 def format(self):
515 super().format()
516 if is_numeric(self.x[0]):
517 self.ax.set_xlim(left=self.x[0] - self.bar_width / 0.8 / 2, right=self.x[-1] + self.bar_width / 0.8 / 2)
518 self.format_numeric_ticks()
521class TimeBarPlot(BarPlot):
522 def __init__(self, bar_values, bar_positions, y_title="Heat / kWh", **kwargs):
523 super().__init__(bar_values, bar_positions, y_title=y_title, **kwargs)
525 @property
526 def bar_width(self) -> float:
527 if len(self.x) > 1:
528 date_nums = mdates.date2num(self.x)
529 time_diffs = np.diff(date_nums)
530 min_diff = time_diffs[0]
531 return float(min_diff * 0.8)
532 else:
533 return 1 / 24
535 def format(self):
536 super().format()
537 format_date_x_axis(self.x[0], self.x[-1], self.ax, return_version=False)
538 self.ax.set_xlim(
539 left=self.x[0] - timedelta(days=self.bar_width / 0.8 / 2),
540 right=self.x[-1] + timedelta(days=self.bar_width / 0.8 / 2),
541 )
542 self.format_numeric_ticks()
545def seconds_to_dates(seconds: list, start_date=datetime(2023, 1, 1), return_array=False) -> list | np.ndarray:
546 result = [start_date + timedelta(seconds=int(s)) for s in seconds]
547 if return_array:
548 return np.array(result)
549 return result
552def format_x_axis_to_date(fig, ax):
553 ax.xaxis.set_major_locator(mdates.DayLocator(bymonthday=(1, 7, 14, 21, 28)))
554 ax.xaxis.set_minor_locator(mdates.DayLocator(interval=1))
555 ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
556 ax.grid(True)
557 # for label in ax.get_xticklabels(which="major"):
558 # label.set(rotation=30, horizontalalignment="right")
559 ax.tick_params(axis="x", which="minor", bottom=True)
560 fig.autofmt_xdate()
561 return fig, ax