• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SPF-OST / pytrnsys_process / 26558912126

28 May 2026 06:34AM UTC coverage: 95.936% (-0.01%) from 95.948%
26558912126

push

github

ahobeost
Refactoring to pass CI.

11 of 11 new or added lines in 2 files covered. (100.0%)

3 existing lines in 2 files now uncovered.

1322 of 1378 relevant lines covered (95.94%)

1.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.37
/pytrnsys_process/plot/plotters.py
1
import typing as _tp
2✔
2
import warnings
2✔
3
from abc import abstractmethod
2✔
4
from dataclasses import dataclass
2✔
5

6
import numpy as _np
2✔
7
import pandas as _pd
2✔
8
import matplotlib.pyplot as _plt
2✔
9
from matplotlib import dates as _dates, ticker as _tick
2✔
10

11
from pytrnsys_process import config as conf
2✔
12

13
# TODO: provide A4 and half A4 plots to test sizes in latex # pylint: disable=fixme
14
# TODO: provide height as input for plot?  # pylint: disable=fixme
15
# TODO: deal with legends (curve names, fonts, colors, linestyles) # pylint: disable=fixme
16
# TODO: clean up old stuff by refactoring # pylint: disable=fixme
17
# TODO: make issue for docstrings of plotting # pylint: disable=fixme
18
# TODO: Add colormap support # pylint: disable=fixme
19

20

21
# TODO find a better place for this to live in # pylint : disable=fixme
22
plot_settings = conf.global_settings.plot
2✔
23
"Settings shared by all plots"
2✔
24

25

26
class ChartBase:
2✔
27
    cmap: str | None = None
2✔
28

29
    def plot(
2✔
30
        self,
31
        df: _pd.DataFrame,
32
        columns: list[str],
33
        **kwargs,
34
    ) -> tuple[_plt.Figure, _plt.Axes]:
35
        fig, ax = self._do_plot(df, columns, **kwargs)
2✔
36
        return fig, ax
2✔
37

38
    @abstractmethod
2✔
39
    def _do_plot(
2✔
40
        self,
41
        df: _pd.DataFrame,
42
        columns: list[str],
43
        use_legend: bool = True,
44
        size: tuple[float, float] = conf.PlotSizes.A4.value,
45
        **kwargs: _tp.Any,
46
    ) -> tuple[_plt.Figure, _plt.Axes]:
47
        """Implement actual plotting logic in subclasses"""
48

49
    def check_for_cmap(self, kwargs, plot_kwargs):
2✔
50
        if "cmap" not in kwargs and "colormap" not in kwargs:
2✔
51
            plot_kwargs["cmap"] = self.cmap
2✔
52
        return plot_kwargs
2✔
53

54
    def get_cmap(self, kwargs) -> str | None:
2✔
55
        if not kwargs:
2✔
56
            return self.cmap
2✔
57

58
        if "cmap" not in kwargs and "colormap" not in kwargs:
2✔
59
            return self.cmap
2✔
60

61
        if "cmap" in kwargs:
2✔
62
            return kwargs["cmap"]
2✔
63

64
        if "colormap" in kwargs:
2✔
65
            return kwargs["colormap"]
2✔
66

67
        raise ValueError  # pragma: no cover
68

69
    @staticmethod
2✔
70
    def get_fig_and_ax(kwargs, size):
2✔
71
        if "fig" not in kwargs and "ax" not in kwargs:
2✔
72
            fig, ax = _plt.subplots(
2✔
73
                figsize=size,
74
                layout="constrained",
75
            )
76
            kwargs["ax"] = ax
2✔
77
        else:
78
            fig = kwargs["fig"]
2✔
79
            ax = kwargs["ax"]
2✔
80
        return fig, ax
2✔
81

82
    def get_fig_and_multi_ax(self, kwargs, size):
2✔
83
        if (
2✔
84
            "fig" in kwargs
85
            or "lax" in kwargs
86
            or "rax" in kwargs
87
            or "ax" in kwargs
88
        ):
89
            warnings.warn(
2✔
90
                "get_fig_and_multi_ax does not pass the figure handle, nor the axis handles."
91
            )
92

93
        fig, lax, leg_ax = self.prep_subplots_for_legend_outside_of_plot(size)
2✔
94
        rax = lax.twinx()
2✔
95

96
        return fig, lax, rax, leg_ax
2✔
97

98
    @staticmethod
2✔
99
    def prep_subplots_for_legend_outside_of_plot(
2✔
100
        size,
101
    ) -> tuple[_plt.Figure, _plt.Axes, _plt.Axes]:
102
        # See: https://stackoverflow.com/questions/4700614/
103
        # how-to-put-the-legend-outside-the-plot
104
        # This is required to place the legend in a dedicated subplot
105
        fig, (ax, leg_ax) = _plt.subplots(
2✔
106
            layout="constrained",
107
            figsize=size,
108
            ncols=2,
109
            gridspec_kw={"width_ratios": [4, 1]},
110
        )
111
        return fig, ax, leg_ax
2✔
112

113

114
class StackedBarChart(ChartBase):
2✔
115
    cmap: str | None = "inferno_r"
2✔
116

117
    def _do_plot(
2✔
118
        self,
119
        df: _pd.DataFrame,
120
        columns: list[str],
121
        use_legend: bool = True,
122
        size: tuple[float, float] = conf.PlotSizes.A4.value,
123
        **kwargs: _tp.Any,
124
    ) -> tuple[_plt.Figure, _plt.Axes]:
125
        fig, ax = self.get_fig_and_ax(kwargs, size)
2✔
126

127
        plot_kwargs = {
2✔
128
            "stacked": True,
129
            "legend": use_legend,
130
            **kwargs,
131
        }
132
        self.check_for_cmap(kwargs, plot_kwargs)
2✔
133
        ax = df[columns].plot.bar(**plot_kwargs)
2✔
134
        ax.set_xticklabels(
2✔
135
            _pd.to_datetime(df.index).strftime(plot_settings.date_format)
136
        )
137

138
        return fig, ax
2✔
139

140

141
class EnergyBalanceChart(ChartBase):
2✔
142
    cmap: str | None = "inferno_r"
2✔
143

144
    def plot(  # type: ignore[override]
2✔
145
        self,
146
        df: _pd.DataFrame,
147
        columns: dict[_tp.Any, _tp.Any],  # type: ignore[arg-type]
148
        **kwargs,
149
    ) -> tuple[_plt.Figure, _plt.Axes, _plt.Axes]:
150
        fig, lax, rax = self._do_plot(df, columns, **kwargs)
2✔
151
        return fig, lax, rax
2✔
152

153
    # pylint: disable=too-many-locals, too-many-statements
154
    def _do_plot(  # typing: ignore[override]
2✔
155
        self,
156
        df: _pd.DataFrame,
157
        columns: dict[_tp.Any, _tp.Any],
158
        use_legend: bool = True,
159
        size: tuple[float, float] = conf.PlotSizes.A4.value,
160
        **kwargs: _tp.Any,
161
    ) -> tuple[_plt.Figure, _plt.Axes, _plt.Axes]:
162
        fig, lax, rax, leg_ax = self.get_fig_and_multi_ax(kwargs, size)
2✔
163

164
        # TODO: implement other kwargs?  # pylint: disable=fixme
165
        plot_kwargs = {
2✔
166
            **kwargs,
167
        }
168

169
        q_in_columns = columns["q_in_columns"]
2✔
170
        q_out_columns = columns["q_out_columns"]
2✔
171
        q_imb_column = columns["q_imb_column"]
2✔
172
        line_columns = columns["line_columns"]
2✔
173

174
        pos_bottom = _np.zeros(len(df))
2✔
175
        neg_bottom = _np.zeros(len(df))
2✔
176
        self.check_for_cmap(kwargs, plot_kwargs)
2✔
177
        n_colors = len(q_in_columns) + len(q_out_columns) + 1
2✔
178
        cmap = self.get_cmap(kwargs)
2✔
179
        cm = _plt.get_cmap(cmap, n_colors)
2✔
180

181
        date_time = _dates.date2num(df.index)
2✔
182

183
        data_frequency = get_frequency_of_data(df)
2✔
184

185
        bar_width = None
2✔
186
        if data_frequency == "step":
2✔
187
            bar_width = 0.0008
2✔
188
        elif data_frequency == "hourly":
2✔
189
            bar_width = 0.02
2✔
190
        elif data_frequency == "monthly":
2✔
191
            bar_width = 25
2✔
192

193
        # build positive stack
194
        i_color = 0
2✔
195
        for column in q_in_columns:
2✔
196
            values = df[column].clip(lower=0)
2✔
197
            lax.bar(
2✔
198
                date_time,
199
                values,
200
                bottom=pos_bottom,
201
                label=column,
202
                width=bar_width,
203
                color=cm(i_color),
204
            )
205
            pos_bottom += values
2✔
206
            i_color += 1
2✔
207

208
        # build negative stack
209
        for column in q_out_columns:
2✔
210
            values = df[column].clip(upper=0)
2✔
211
            lax.bar(
2✔
212
                date_time,
213
                values,
214
                bottom=neg_bottom,
215
                label=column,
216
                width=bar_width,
217
                color=cm(i_color),
218
            )
219
            neg_bottom += values
2✔
220
            i_color += 1
2✔
221

222
        values = df[q_imb_column]
2✔
223
        lax.bar(
2✔
224
            date_time,
225
            values.clip(lower=0),
226
            bottom=pos_bottom,
227
            label=q_imb_column,
228
            width=bar_width,
229
            color="black",
230
        )
231
        pos_bottom += values.clip(lower=0)  # used for legend location
2✔
232
        i_color += 1
2✔
233
        lax.bar(
2✔
234
            date_time,
235
            values.clip(upper=0),
236
            bottom=neg_bottom,
237
            width=bar_width,
238
            color="black",
239
        )
240
        neg_bottom += values.clip(upper=0)  # used for legend location
2✔
241

242
        lax.axhline(0, color="black")
2✔
243

244
        if line_columns is not None:
2✔
245
            for column in line_columns:
2✔
246
                rax.plot(date_time, df[column], label=f"{column}")
2✔
247

248
        format_date_time_twin_axis(lax, rax, data_frequency)
2✔
249

250
        if line_columns is None:
2✔
251
            y_min, y_max = lax.get_ylim()
2✔
252
            rax.set_ylim(y_min, y_max)
2✔
253

254
        self._maybe_add_legend(use_legend, line_columns, lax, rax, leg_ax)
2✔
255

256
        if "xlabel" in kwargs:
2✔
257
            lax.set_xlabel(kwargs["xlabel"])
2✔
258
        if "energy_balance_ylabel" in kwargs:
2✔
259
            lax.set_ylabel(kwargs["energy_balance_ylabel"])
2✔
260
        if "line_ylabel" in kwargs:
2✔
261
            rax.set_ylabel(kwargs["line_ylabel"])
2✔
262

263
        return fig, lax, rax
2✔
264

265
    @staticmethod
2✔
266
    def _maybe_add_legend(
2✔
267
        use_legend: bool,
268
        line_columns: _tp.Optional[list[str]],
269
        lax: _plt.Axes,
270
        rax: _plt.Axes,
271
        leg_ax: _plt.Axes,
272
    ):
273
        if use_legend:
2✔
274
            balance_handles, _ = lax.get_legend_handles_labels()
2✔
275
            line_handles, _ = rax.get_legend_handles_labels()
2✔
276
            if line_columns is not None:
2✔
277
                line_legend = leg_ax.legend(
2✔
278
                    handles=line_handles,
279
                    loc="upper left",
280
                    bbox_to_anchor=(0, 0, 1, 1),
281
                )
282
                leg_ax.add_artist(line_legend)
2✔
283
            leg_ax.legend(
2✔
284
                handles=balance_handles,
285
                loc="upper left",
286
                bbox_to_anchor=(0, 0, 1, 0.7),
287
            )
288
            leg_ax.axis("off")
2✔
289

290

291
class BarChart(ChartBase):
2✔
292
    cmap = None
2✔
293

294
    def _do_plot(
2✔
295
        self,
296
        df: _pd.DataFrame,
297
        columns: list[str],
298
        use_legend: bool = True,
299
        size: tuple[float, float] = conf.PlotSizes.A4.value,
300
        **kwargs: _tp.Any,
301
    ) -> tuple[_plt.Figure, _plt.Axes]:
302
        # TODO: deal with colors  # pylint: disable=fixme
303
        fig, ax = self.get_fig_and_ax(kwargs, size)
2✔
304

305
        x = _np.arange(len(df.index))
2✔
306
        width = 0.8 / len(columns)
2✔
307

308
        cmap = self.get_cmap(kwargs)
2✔
309
        if cmap:
2✔
310
            cm = _plt.get_cmap(cmap)
2✔
311
            colors = cm(_np.linspace(0, 1, len(columns)))
2✔
312
        else:
313
            colors = [None] * len(columns)
2✔
314

315
        for i, col in enumerate(columns):
2✔
316
            ax.bar(x + i * width, df[col], width, label=col, color=colors[i])
2✔
317

318
        if use_legend:
2✔
319
            ax.legend()
2✔
320

321
        ax.set_xticks(x + width * (len(columns) - 1) / 2)
2✔
322
        ax.set_xticklabels(
2✔
323
            _pd.to_datetime(df.index).strftime(plot_settings.date_format)
324
        )
325
        ax.tick_params(axis="x", labelrotation=90)
2✔
326
        return fig, ax
2✔
327

328

329
class LinePlot(ChartBase):
2✔
330
    cmap: str | None = None
2✔
331

332
    def _do_plot(
2✔
333
        self,
334
        df: _pd.DataFrame,
335
        columns: list[str],
336
        use_legend: bool = True,
337
        size: tuple[float, float] = conf.PlotSizes.A4.value,
338
        **kwargs: _tp.Any,
339
    ) -> tuple[_plt.Figure, _plt.Axes]:
340
        fig, ax = self.get_fig_and_ax(kwargs, size)
2✔
341

342
        plot_kwargs = {
2✔
343
            "legend": use_legend,
344
            **kwargs,
345
        }
346
        self.check_for_cmap(kwargs, plot_kwargs)
2✔
347

348
        df[columns].plot.line(**plot_kwargs)
2✔
349
        return fig, ax
2✔
350

351

352
@dataclass()
2✔
353
class Histogram(ChartBase):
2✔
354
    bins: int = 50
2✔
355

356
    def _do_plot(
2✔
357
        self,
358
        df: _pd.DataFrame,
359
        columns: list[str],
360
        use_legend: bool = True,
361
        size: tuple[float, float] = conf.PlotSizes.A4.value,
362
        **kwargs: _tp.Any,
363
    ) -> tuple[_plt.Figure, _plt.Axes]:
364
        fig, ax = self.get_fig_and_ax(kwargs, size)
2✔
365

366
        plot_kwargs = {
2✔
367
            "legend": use_legend,
368
            "bins": self.bins,
369
            **kwargs,
370
        }
371
        self.check_for_cmap(kwargs, plot_kwargs)
2✔
372
        df[columns].plot.hist(**plot_kwargs)
2✔
373
        return fig, ax
2✔
374

375

376
def _validate_inputs(
2✔
377
    current_class,
378
    columns: list[str],
379
) -> None:
380
    if len(columns) != 2:
2✔
381
        raise ValueError(
2✔
382
            f"\n{type(current_class).__name__} requires exactly 2 columns (x and y)"
383
        )
384

385

386
class ScatterPlot(ChartBase):
2✔
387
    cmap = "Paired"  # This is ignored when no categorical groupings are used.
2✔
388

389
    def _do_plot(
2✔
390
        self,
391
        df: _pd.DataFrame,
392
        columns: list[str],
393
        use_legend: bool = True,
394
        size: tuple[float, float] = conf.PlotSizes.A4.value,
395
        **kwargs: _tp.Any,
396
    ) -> tuple[_plt.Figure, _plt.Axes]:
397
        _validate_inputs(self, columns)
2✔
398
        x_column, y_column = columns
2✔
399

400
        fig, ax = self.get_fig_and_ax(kwargs, size)
2✔
401
        df.plot.scatter(x=x_column, y=y_column, **kwargs)
2✔
402

403
        return fig, ax
2✔
404

405

406
class ScalarComparePlot(ChartBase):
2✔
407
    """Handles comparative scatter plots with dual grouping by color and markers."""
408

409
    cmap = "Paired"  # This is ignored when no categorical groupings are used.
2✔
410

411
    # pylint: disable=too-many-arguments,too-many-locals, too-many-positional-arguments
412
    def _do_plot(  # type: ignore[override]
2✔
413
        self,
414
        df: _pd.DataFrame,
415
        columns: list[str],
416
        use_legend: bool = True,
417
        size: tuple[float, float] = conf.PlotSizes.A4.value,
418
        group_by_color: str | None = None,
419
        group_by_marker: str | None = None,
420
        line_kwargs: dict[str, _tp.Any] | None = None,
421
        scatter_kwargs: dict[str, _tp.Any] | None = None,
422
    ) -> tuple[_plt.Figure, _plt.Axes]:
423

424
        _validate_inputs(self, columns)
2✔
425
        x_column, y_column = columns
2✔
426

427
        # ===========================================
428
        # The following simplifies the code later on,
429
        # while being compatible with linting.
430
        if not line_kwargs:
2✔
431
            line_kwargs = {}
2✔
432
        if not scatter_kwargs:
2✔
433
            scatter_kwargs = {}
2✔
434
        # ===========================================
435

436
        if group_by_color and group_by_marker:
2✔
437
            fig, ax, lax = self.prep_subplots_for_legend_outside_of_plot(size)
2✔
438
            secondary_axis_used = True
2✔
439
        else:
440
            secondary_axis_used = False
2✔
441
            fig, ax = self.get_fig_and_ax({}, size)
2✔
442
            lax = ax
2✔
443

444
        df_grouped, group_values = self._prepare_grouping(
2✔
445
            df, group_by_color, group_by_marker
446
        )
447
        cmap = self.get_cmap(line_kwargs)
2✔
448
        color_map, marker_map = self._create_style_mappings(
2✔
449
            *group_values, cmap=cmap
450
        )
451

452
        self._plot_groups(
2✔
453
            df_grouped,
454
            x_column,
455
            y_column,
456
            color_map,
457
            marker_map,
458
            ax,
459
            line_kwargs,
460
            scatter_kwargs,
461
        )
462

463
        use_color_legend = False
2✔
464
        if group_by_color:
2✔
465
            use_color_legend = True
2✔
466

467
        if use_legend:
2✔
468
            self._create_legends(
2✔
469
                lax,
470
                color_map,
471
                marker_map,
472
                group_by_color,
473
                group_by_marker,
474
                use_color_legend=use_color_legend,
475
                secondary_axis_used=secondary_axis_used,
476
            )
477

478
        return fig, ax
2✔
479

480
    @staticmethod
2✔
481
    def _prepare_grouping(
2✔
482
        df: _pd.DataFrame,
483
        by_color: str | None,
484
        by_marker: str | None,
485
    ) -> tuple[
486
        _pd.core.groupby.generic.DataFrameGroupBy, tuple[list[str], list[str]]
487
    ]:
488
        group_by = []
2✔
489
        if by_color:
2✔
490
            group_by.append(by_color)
2✔
491
        if by_marker:
2✔
492
            group_by.append(by_marker)
2✔
493

494
        df_grouped = df.groupby(group_by)
2✔
495

496
        color_values = sorted(df[by_color].unique()) if by_color else []
2✔
497
        marker_values = sorted(df[by_marker].unique()) if by_marker else []
2✔
498

499
        return df_grouped, (color_values, marker_values)
2✔
500

501
    @staticmethod
2✔
502
    def _create_style_mappings(
2✔
503
        color_values: list[str],
504
        marker_values: list[str],
505
        cmap: str | None,
506
    ) -> tuple[dict[str, _tp.Any], dict[str, str]]:
507
        if color_values:
2✔
508
            cm = _plt.get_cmap(cmap, len(color_values))
2✔
509
            color_map = {val: cm(i) for i, val in enumerate(color_values)}
2✔
510
        else:
511
            cm = _plt.get_cmap(cmap, len(marker_values))
2✔
512
            color_map = {val: cm(i) for i, val in enumerate(marker_values)}
2✔
513
        if marker_values:
2✔
514
            marker_map = dict(zip(marker_values, plot_settings.markers))
2✔
515
        else:
516
            marker_map = {}
2✔
517

518
        return color_map, marker_map
2✔
519

520
    # pylint: disable=too-many-arguments
521
    @staticmethod
2✔
522
    def _plot_groups(
2✔
523
        df_grouped: _pd.core.groupby.generic.DataFrameGroupBy,
524
        x_column: str,
525
        y_column: str,
526
        color_map: dict[str, _tp.Any],
527
        marker_map: dict[str, str] | str,
528
        ax: _plt.Axes,
529
        line_kwargs: dict[str, _tp.Any],
530
        scatter_kwargs: dict[str, _tp.Any],
531
    ) -> None:
532
        ax.set_xlabel(x_column, fontsize=plot_settings.label_font_size)
2✔
533
        ax.set_ylabel(y_column, fontsize=plot_settings.label_font_size)
2✔
534
        for val, group in df_grouped:
2✔
535
            sorted_group = group.sort_values(x_column)
2✔
536
            x = sorted_group[x_column]
2✔
537
            y = sorted_group[y_column]
2✔
538

539
            plot_args = {"color": "black"}
2✔
540
            if color_map:
2✔
541
                plot_args["color"] = color_map[val[0]]
2✔
542

543
            for key, value in line_kwargs.items():
2✔
544
                if key not in ["cmap", "colormap"]:
2✔
545
                    plot_args[key] = value
2✔
546

547
            scatter_args = {"marker": "None", "color": "black", "alpha": 0.5}
2✔
548
            if marker_map:
2✔
549
                scatter_args["marker"] = marker_map[val[-1]]
2✔
550

551
            for key, value in scatter_kwargs.items():
2✔
552
                if key in ["marker"] and marker_map:
2✔
553
                    continue
2✔
554

555
                scatter_args[key] = value
2✔
556

557
            ax.plot(x, y, **plot_args)  # type: ignore
2✔
558
            ax.scatter(x, y, **scatter_args)  # type: ignore
2✔
559

560
    def _create_legends(
2✔
561
        self,
562
        lax: _plt.Axes,
563
        color_map: dict[str, _tp.Any],
564
        marker_map: dict[str, str],
565
        color_legend_title: str | None,
566
        marker_legend_title: str | None,
567
        use_color_legend: bool,
568
        secondary_axis_used: bool,
569
    ) -> None:
570

571
        if secondary_axis_used:
2✔
572
            # Secondary axis should be turned off.
573
            # Primary axis should stay the same.
574
            lax.axis("off")
2✔
575

576
        if use_color_legend:
2✔
577
            self._create_color_legend(
2✔
578
                lax,
579
                color_map,
580
                color_legend_title,
581
                bool(marker_map),
582
                secondary_axis_used,
583
            )
584
        if marker_map:
2✔
585
            self._create_marker_legend(
2✔
586
                lax,
587
                marker_map,
588
                marker_legend_title,
589
                bool(use_color_legend),
590
                secondary_axis_used,
591
            )
592

593
    @staticmethod
2✔
594
    def _create_color_legend(
2✔
595
        lax: _plt.Axes,
596
        color_map: dict[str, _tp.Any],
597
        color_legend_title: str | None,
598
        has_markers: bool,
599
        secondary_axis_used: bool,
600
    ) -> None:
601
        color_handles = [
2✔
602
            _plt.Line2D([], [], color=color, linestyle="-", label=label)
603
            for label, color in color_map.items()
604
        ]
605

606
        if secondary_axis_used:
2✔
607
            loc = "upper left"
2✔
608
            alignment = "left"
2✔
609
        else:
610
            loc = "best"
2✔
611
            alignment = "center"
2✔
612

613
        legend = lax.legend(
2✔
614
            handles=color_handles,
615
            title=color_legend_title,
616
            bbox_to_anchor=(0, 0, 1, 1),
617
            loc=loc,
618
            alignment=alignment,
619
            fontsize=plot_settings.legend_font_size,
620
            borderaxespad=0,
621
        )
622

623
        if has_markers:
2✔
624
            lax.add_artist(legend)
2✔
625

626
    @staticmethod
2✔
627
    def _create_marker_legend(
2✔
628
        lax: _plt.Axes,
629
        marker_map: dict[str, str],
630
        marker_legend_title: str | None,
631
        has_colors: bool,
632
        secondary_axis_used: bool,
633
    ) -> None:
634
        marker_position = 0.7 if has_colors else 1
2✔
635
        marker_handles = [
2✔
636
            _plt.Line2D(
637
                [],
638
                [],
639
                color="black",
640
                marker=marker,
641
                linestyle="None",
642
                label=label,
643
            )
644
            for label, marker in marker_map.items()
645
            if label is not None
646
        ]
647

648
        if secondary_axis_used:
2✔
649
            loc = "upper left"
2✔
650
            alignment = "left"
2✔
651
        else:
652
            loc = "best"
2✔
653
            alignment = "center"
2✔
654

655
        lax.legend(
2✔
656
            handles=marker_handles,
657
            title=marker_legend_title,
658
            bbox_to_anchor=(0, 0, 1, marker_position),
659
            loc=loc,
660
            alignment=alignment,
661
            fontsize=plot_settings.legend_font_size,
662
            borderaxespad=0,
663
        )
664

665

666
def get_date_time_axis_locator_and_formatter(
2✔
667
    data_frequency: _tp.Literal["step", "hourly", "monthly"],
668
) -> _tp.Tuple[_dates.AutoDateLocator, _dates.ConciseDateFormatter]:
669
    """
670
    Method to prepare an axis locator and a date time formattter to adjust the date time formatting.
671
    Can be used as follows:
672
    ax.xaxis.set_major_formatter(formatter)
673
    ax.xaxis.set_major_locator(date_locator)
674

675
    Parameters
676
    ----------
677
    data_frequency: str
678
        Size of the timestep. This can be 'step', 'hourly', and 'monthly'.
679
    """
680
    # TODO: rewrite the original energy balance function, to use the current implementation.
681

682
    if data_frequency == "step":
2✔
683
        date_locator = _dates.AutoDateLocator(minticks=4, maxticks=30)
2✔
684
        date_locator.intervald = {
2✔
685
            _dates.YEARLY: [],
686
            _dates.MONTHLY: [1],
687
            _dates.DAILY: [1],
688
            _dates.HOURLY: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
689
            _dates.MINUTELY: [0, 2, 4, 6, 8],  # disable
690
            _dates.SECONDLY: [60],  # disable
691
        }
692

693
        formatter = _dates.ConciseDateFormatter(date_locator)
2✔
694
        formatter.formats[3] = "%H"  # sub-hours
2✔
695
        formatter.zero_formats[3] = "%b %d %H"  # on the hour
2✔
696
        formatter.formats[4] = "%H:%M"  # sub-minutes
2✔
697
        formatter.zero_formats[4] = "%d %b %H:%M"  # on the hour
2✔
698

699
    elif data_frequency == "hourly":
2✔
700
        # hourly
701
        date_locator = _dates.AutoDateLocator(minticks=4, maxticks=36)
2✔
702
        date_locator.intervald = {
2✔
703
            _dates.YEARLY: [],
704
            _dates.MONTHLY: [1],
705
            _dates.DAILY: [1],
706
            _dates.HOURLY: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
707
            _dates.MINUTELY: [60],  # disable minutes completely
708
            _dates.SECONDLY: [3600],  # disable seconds completely
709
        }
710

711
        formatter = _dates.ConciseDateFormatter(date_locator)
2✔
712
        formatter.formats[3] = "%H"
2✔
713
        formatter.zero_formats[3] = "%b %d"
2✔
714

715
    elif data_frequency == "monthly":
2✔
716
        date_locator = _dates.AutoDateLocator(minticks=4, maxticks=36)
2✔
717
        date_locator.intervald = {
2✔
718
            _dates.YEARLY: [],
719
            _dates.MONTHLY: [1],
720
            _dates.DAILY: [31],  # disable
721
            _dates.HOURLY: [31 * 24],  # disable
722
            _dates.MINUTELY: [31 * 24 * 60],  # disable
723
            _dates.SECONDLY: [31 * 24 * 3600],  # disable
724
        }
725

726
        formatter = _dates.ConciseDateFormatter(date_locator)
2✔
727
        formatter.formats[1] = "%b"  # leave out year value
2✔
728
        formatter.zero_formats[1] = "%b"  # leave out year value
2✔
729
    else:
UNCOV
730
        raise ValueError(f"Incorrect data frequency: {data_frequency}")
×
731

732
    return date_locator, formatter
2✔
733

734

735
def get_frequency_of_data(
2✔
736
    df: _pd.DataFrame,
737
) -> _tp.Literal["step", "hourly", "monthly"]:
738
    """
739
    Method to identify the timestep size of the give dataframe.
740
    Can return 'step', 'hourly', and 'monthly'.
741
    """
742
    delta_time = df.index[1] - df.index[0]
2✔
743

744
    if delta_time < _pd.Timedelta(hours=1):
2✔
745
        data_frequency = "step"
2✔
746

747
    elif delta_time == _pd.Timedelta(hours=1):
2✔
748
        data_frequency = "hourly"
2✔
749

750
    elif delta_time >= _pd.Timedelta(days=28):
2✔
751
        data_frequency = "monthly"
2✔
752

753
    else:
UNCOV
754
        raise ValueError(
×
755
            f"Timesteps should be hourly, monthly, or less then hourly, recieved: {delta_time}"
756
        )
757

758
    return data_frequency  # type: ignore[return-value]
2✔
759

760

761
def format_date_time_twin_axis(
2✔
762
    lax: _plt.Axes,
763
    rax: _plt.Axes,
764
    data_frequency: _tp.Literal["step", "hourly", "monthly"],
765
):
766
    """Method to update dateTime formatting for twin axes.
767

768
    Parameters
769
    ----------
770
    lax: _plt.Axes
771
        left-axis handle
772

773
    rax: _plt.Axes
774
        right-axis handle
775

776
    data_frequency: str
777
        Size of the timestep. This can be 'step', 'hourly', and 'monthly'.
778
    """
779
    date_locator, formatter = get_date_time_axis_locator_and_formatter(
2✔
780
        data_frequency
781
    )
782
    lax.xaxis_date()
2✔
783
    lax.xaxis.set_major_formatter(formatter)
2✔
784
    lax.xaxis.set_major_locator(date_locator)
2✔
785
    rax.xaxis_date()
2✔
786
    rax.xaxis.set_major_formatter(formatter)
2✔
787
    rax.xaxis.set_major_locator(date_locator)
2✔
788
    lax.tick_params(axis="x", rotation=90)
2✔
789
    rax.xaxis.get_offset_text().set_visible(False)
2✔
790
    lax.xaxis.get_offset_text().set_visible(False)
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc