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

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 

10 

11import numpy as np 

12from matplotlib import pyplot as plt 

13import matplotlib.dates as mdates 

14import matplotlib.ticker as ticker 

15 

16from pyrc.tools.plotting import format_date_x_axis, custom_numeric_ticks_formatter 

17from pyrc.tools.science import cm_to_inch, is_numeric 

18 

19if TYPE_CHECKING: 

20 pass 

21 

22 

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

28 

29 

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

34 

35 self.x = x 

36 self.ys = ys 

37 

38 if isinstance(self.x[0], datetime): 

39 self.x_is_datetime = True 

40 else: 

41 self.x_is_datetime = False 

42 

43 self.y_title = y_title 

44 self.x_title = x_title 

45 self.marker_size = marker_size 

46 

47 self.lines = None 

48 self.labels = [] 

49 

50 self.next_color = self.color_iter() 

51 self.next_line_style = self.line_style_iter() 

52 self.next_marker = self.marker_iter() 

53 

54 def __del__(self): 

55 if self.fig is not None: 

56 plt.close(self.fig) 

57 

58 @property 

59 def markers(self) -> list[str]: 

60 return ["o", "s", "^", "v", "<", ">", "d", "p", "*", "h"] 

61 

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 

69 

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 ] 

84 

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 

94 

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 

102 

103 def _add_line(self, line): 

104 if self.lines is None: 

105 self.lines = line 

106 else: 

107 self.lines = self.lines + line 

108 

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) 

115 

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

125 

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) 

132 

133 def format_numeric_ticks(self): 

134 formatter = ticker.FuncFormatter(custom_numeric_ticks_formatter) 

135 

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 

144 

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 

153 

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) 

158 

159 def show(self): 

160 self.format_numeric_ticks() 

161 plt.show() 

162 

163 def save(self, path): 

164 self.format_numeric_ticks() 

165 self.fig.savefig(path, dpi=600) 

166 

167 def close(self): 

168 if self.fig is not None: 

169 plt.close(self.fig) 

170 

171 

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] 

191 

192 super().__init__(x=np.array(x), ys=ys_left, y_title=y_title_left, marker_size=marker_size, **kwargs) 

193 

194 self.ys_left = ys_left 

195 self.ys_right = ys_right 

196 self.y_title_right = y_title_right 

197 

198 self.ax_right = self.ax.twinx() 

199 

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] 

208 

209 self.labels_left = labels_left 

210 self.labels_right = labels_right 

211 

212 @property 

213 def ax_left(self): 

214 return self.ax 

215 

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) 

222 

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 

226 

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) 

232 

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

236 

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 

240 

241 # Create right axis ticks 

242 right_ticks = np.linspace(right_min, right_max, num_ticks) 

243 

244 self.ax_right.set_ylim(right_min, right_max) 

245 self.ax_right.set_yticks(right_ticks) 

246 

247 def plot(self): 

248 

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 ) 

262 

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 ) 

276 

277 self.format() 

278 

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) 

286 

287 self.ax.grid(True) 

288 

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) 

292 

293 if self.x_is_datetime: 

294 self.formate_x_datetime(self.x[0] - dx / 2, self.x[-1] + dx / 2) 

295 

296 self.format_numeric_ticks() 

297 

298 

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 ) 

314 

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 

321 

322 def plot(self): 

323 

324 for i, (y_left, y_right) in enumerate(zip(self.ys_left, self.ys_right)): 

325 marker = next(self.next_marker) 

326 

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 ) 

337 

338 if not self.same_marker: 

339 marker = next(self.next_marker) 

340 

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 ) 

351 

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 ) 

362 

363 self.format() 

364 

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

371 

372 

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) 

391 

392 if labels is None: 

393 labels = [None] * len(ys) 

394 if not isinstance(labels, list): 

395 labels = [labels] 

396 self.labels = labels 

397 

398 self.y_scale = y_scale 

399 self.line_width = linewidth 

400 

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

422 

423 def add_points(self, x, ys, labels=None): 

424 """ 

425 Add points to the plot (e.g. measurement data to interpolated lines) 

426 

427 The ys values are not scaled with self.y_scale! 

428 

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) 

448 

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

462 

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

473 

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

482 

483 def format(self): 

484 super().format() 

485 self.format_numeric_ticks() 

486 

487 

488class TimePlot(LinePlot): 

489 def format(self): 

490 super().format() 

491 self.formate_x_datetime() 

492 self.format_numeric_ticks() 

493 

494 

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) 

501 

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 

509 

510 def plot(self): 

511 self.ax.bar(self.x, self.ys, width=self.bar_width, color=next(self.next_color)) 

512 self.format() 

513 

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

519 

520 

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) 

524 

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 

534 

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

543 

544 

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 

550 

551 

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