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

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

26 

27 

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

32 

33 self.x = x 

34 self.ys = ys 

35 

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

37 self.x_is_datetime = True 

38 else: 

39 self.x_is_datetime = False 

40 

41 self.y_title = y_title 

42 self.x_title = x_title 

43 self.marker_size = marker_size 

44 

45 self.lines = None 

46 self.labels = [] 

47 

48 self.next_color = self.color_iter() 

49 self.next_line_style = self.line_style_iter() 

50 self.next_marker = self.marker_iter() 

51 

52 def __del__(self): 

53 if self.fig is not None: 

54 plt.close(self.fig) 

55 

56 @property 

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

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

59 

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 

67 

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 ] 

82 

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 

92 

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 

100 

101 def _add_line(self, line): 

102 if self.lines is None: 

103 self.lines = line 

104 else: 

105 self.lines = self.lines + line 

106 

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) 

113 

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

123 

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) 

130 

131 def format_numeric_ticks(self): 

132 formatter = ticker.FuncFormatter(custom_numeric_ticks_formatter) 

133 

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 

142 

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 

151 

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) 

156 

157 def show(self): 

158 self.format_numeric_ticks() 

159 plt.show() 

160 

161 def save(self, path): 

162 self.format_numeric_ticks() 

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

164 

165 def close(self): 

166 if self.fig is not None: 

167 plt.close(self.fig) 

168 

169 

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] 

189 

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

191 

192 self.ys_left = ys_left 

193 self.ys_right = ys_right 

194 self.y_title_right = y_title_right 

195 

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

197 

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] 

206 

207 self.labels_left = labels_left 

208 self.labels_right = labels_right 

209 

210 @property 

211 def ax_left(self): 

212 return self.ax 

213 

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) 

220 

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 

224 

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) 

230 

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

234 

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 

238 

239 # Create right axis ticks 

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

241 

242 self.ax_right.set_ylim(right_min, right_max) 

243 self.ax_right.set_yticks(right_ticks) 

244 

245 def plot(self): 

246 

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 ) 

260 

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 ) 

274 

275 self.format() 

276 

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) 

284 

285 self.ax.grid(True) 

286 

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) 

290 

291 if self.x_is_datetime: 

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

293 

294 self.format_numeric_ticks() 

295 

296 

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 ) 

312 

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 

319 

320 def plot(self): 

321 

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

323 marker = next(self.next_marker) 

324 

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 ) 

335 

336 if not self.same_marker: 

337 marker = next(self.next_marker) 

338 

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 ) 

349 

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 ) 

360 

361 self.format() 

362 

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

369 

370 

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) 

389 

390 if labels is None: 

391 labels = [None] * len(ys) 

392 if not isinstance(labels, list): 

393 labels = [labels] 

394 self.labels = labels 

395 

396 self.y_scale = y_scale 

397 self.line_width = linewidth 

398 

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

418 

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

429 

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

438 

439 def format(self): 

440 super().format() 

441 self.format_numeric_ticks() 

442 

443 

444class TimePlot(LinePlot): 

445 def format(self): 

446 super().format() 

447 self.formate_x_datetime() 

448 self.format_numeric_ticks() 

449 

450 

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) 

457 

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 

465 

466 def plot(self): 

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

468 self.format() 

469 

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

475 

476 

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) 

480 

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 

490 

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

499 

500 

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 

506 

507 

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