heatmapts 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.3
2
+ Name: heatmapts
3
+ Version: 0.1.0
4
+ Summary: The classical time series heatmap plot extended with a mean profile and daily average and peaks.
5
+ Author: Markus Kreft
6
+ Author-email: Markus Kreft <mail@mkreft.de>
7
+ Requires-Dist: pandas>=2.2.3
8
+ Requires-Dist: matplotlib>=3.9.2
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ <p align="center" width="100%">
13
+ <img src="docs/logo_wide.png" width="300">
14
+ </p>
15
+
16
+ The classical time series heatmap plot extended with a mean profile and daily average and peaks.
17
+ This type of visualization is very informative when showing large amounts of time series data.
18
+ While I mostly use it for energy data, it can easily be adapted for any other time series.
19
+ The figure works best for data with a range of a few months to a few years and a resolution of a few minutes to a few hours.
20
+
21
+ <picture>
22
+ <source media="(prefers-color-scheme: dark)" srcset="docs/sample-dark.png">
23
+ <img alt="Sample plot created with HeatmapTS" src="docs/sample-light.png">
24
+ </picture>
25
+
26
+ ## Installation
27
+
28
+ The package is available on [PyPI](https://pypi.org/project/heatmapts/):
29
+ ```sh
30
+ pip install heatmapts
31
+ ```
32
+
33
+ Alternatively, you can install it from GitHub:
34
+ ```sh
35
+ pip install git+https://github.com/markus-kreft/heatmapts.git
36
+ ```
37
+ or simply clone this repository and work in the local directory.
38
+
39
+
40
+ ## Usage
41
+
42
+ The module provides the `heatmapfigure` function that takes a pandas Series with a timezone-aware DatetimeIndex with frequency.
43
+
44
+ This example show how to load and prepare data from a csv file in the necessary way.
45
+ This needs to be adapted depending on the format of the data at hand.
46
+
47
+ ```python
48
+ from heatmapts import heatmapfigure
49
+
50
+ # Read data
51
+ df = pd.read_csv('data.csv', index_col='Timestamp')
52
+ # Convert index to datetime and set timezone
53
+ df.index = pd.to_datetime(df.index, format='%Y-%m-%d %H:%M:%S').tz_convert('Europe/Zurich')
54
+ # Resample to 15 frequency
55
+ df = df.asfreq("15min", fill_value=pd.NA)
56
+ # Take data column and convert energy to power
57
+ series = df['Value'] * 4
58
+
59
+ fig = heatmapfigure(series, rasterized=True)
60
+ fig.savefig("plot.pdf")
61
+ ```
62
+
63
+ ## Customization
64
+
65
+ The figure is designed for easy customization.
66
+
67
+ - Any additional keyword arguments are passed to pcolormesh, which is especially convenient for using a different colormap or norm or to use rasterization.
68
+
69
+ <!-- norm = colors.TwoSlopeNorm(vcenter=0) | colors.CenteredNorm() -->
70
+ <!-- cmap = 'gist_rainbow_r' | 'guppy' | 'fusion_r' -->
71
+
72
+ - Passing a tuple of latitude and longitude coordinates will add sunrise and sunset times to the plot.
73
+
74
+ ```python
75
+ fig = heatmapfigure(series, annotate_suntimes=(52.37, 4.90))
76
+ ```
77
+
78
+ - The returned figure is subclassed from `matplotlib.figure.Figure` and features additional attributes for the heatmap, colorbar, daily and hourly axes.
79
+ These can be modified as any other matplotlib axis.
80
+
81
+ ```python
82
+ fig.ax_heatmap.xaxis.set_major_locator(mdates.YearLocator())
83
+ fig.ax_heatmap.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
84
+ ```
85
+
86
+ - The figure uses its own rcParams for styling.
87
+ You can modify or overwrite the parameters on a class level.
88
+ When setting an empty rcParams dictionary, the current parameters from matplotlib (e.g., as determined by `matplotlibrc`) will be used.
89
+
90
+ ```python
91
+ from heatmapts import HeatmapFigure
92
+ # Customize rc parameters
93
+ HeatmapFigure.rc_params["font.family"] = "serif"
94
+ HeatmapFigure.rc_params["font.size"] = 5
95
+
96
+ # Set empty rc parameters to use those from matplotlib
97
+ HeatmapFigure.rc_params = {}
98
+ ```
@@ -0,0 +1,87 @@
1
+ <p align="center" width="100%">
2
+ <img src="docs/logo_wide.png" width="300">
3
+ </p>
4
+
5
+ The classical time series heatmap plot extended with a mean profile and daily average and peaks.
6
+ This type of visualization is very informative when showing large amounts of time series data.
7
+ While I mostly use it for energy data, it can easily be adapted for any other time series.
8
+ The figure works best for data with a range of a few months to a few years and a resolution of a few minutes to a few hours.
9
+
10
+ <picture>
11
+ <source media="(prefers-color-scheme: dark)" srcset="docs/sample-dark.png">
12
+ <img alt="Sample plot created with HeatmapTS" src="docs/sample-light.png">
13
+ </picture>
14
+
15
+ ## Installation
16
+
17
+ The package is available on [PyPI](https://pypi.org/project/heatmapts/):
18
+ ```sh
19
+ pip install heatmapts
20
+ ```
21
+
22
+ Alternatively, you can install it from GitHub:
23
+ ```sh
24
+ pip install git+https://github.com/markus-kreft/heatmapts.git
25
+ ```
26
+ or simply clone this repository and work in the local directory.
27
+
28
+
29
+ ## Usage
30
+
31
+ The module provides the `heatmapfigure` function that takes a pandas Series with a timezone-aware DatetimeIndex with frequency.
32
+
33
+ This example show how to load and prepare data from a csv file in the necessary way.
34
+ This needs to be adapted depending on the format of the data at hand.
35
+
36
+ ```python
37
+ from heatmapts import heatmapfigure
38
+
39
+ # Read data
40
+ df = pd.read_csv('data.csv', index_col='Timestamp')
41
+ # Convert index to datetime and set timezone
42
+ df.index = pd.to_datetime(df.index, format='%Y-%m-%d %H:%M:%S').tz_convert('Europe/Zurich')
43
+ # Resample to 15 frequency
44
+ df = df.asfreq("15min", fill_value=pd.NA)
45
+ # Take data column and convert energy to power
46
+ series = df['Value'] * 4
47
+
48
+ fig = heatmapfigure(series, rasterized=True)
49
+ fig.savefig("plot.pdf")
50
+ ```
51
+
52
+ ## Customization
53
+
54
+ The figure is designed for easy customization.
55
+
56
+ - Any additional keyword arguments are passed to pcolormesh, which is especially convenient for using a different colormap or norm or to use rasterization.
57
+
58
+ <!-- norm = colors.TwoSlopeNorm(vcenter=0) | colors.CenteredNorm() -->
59
+ <!-- cmap = 'gist_rainbow_r' | 'guppy' | 'fusion_r' -->
60
+
61
+ - Passing a tuple of latitude and longitude coordinates will add sunrise and sunset times to the plot.
62
+
63
+ ```python
64
+ fig = heatmapfigure(series, annotate_suntimes=(52.37, 4.90))
65
+ ```
66
+
67
+ - The returned figure is subclassed from `matplotlib.figure.Figure` and features additional attributes for the heatmap, colorbar, daily and hourly axes.
68
+ These can be modified as any other matplotlib axis.
69
+
70
+ ```python
71
+ fig.ax_heatmap.xaxis.set_major_locator(mdates.YearLocator())
72
+ fig.ax_heatmap.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
73
+ ```
74
+
75
+ - The figure uses its own rcParams for styling.
76
+ You can modify or overwrite the parameters on a class level.
77
+ When setting an empty rcParams dictionary, the current parameters from matplotlib (e.g., as determined by `matplotlibrc`) will be used.
78
+
79
+ ```python
80
+ from heatmapts import HeatmapFigure
81
+ # Customize rc parameters
82
+ HeatmapFigure.rc_params["font.family"] = "serif"
83
+ HeatmapFigure.rc_params["font.size"] = 5
84
+
85
+ # Set empty rc parameters to use those from matplotlib
86
+ HeatmapFigure.rc_params = {}
87
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "heatmapts"
3
+ version = "0.1.0"
4
+ description = "The classical time series heatmap plot extended with a mean profile and daily average and peaks."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Markus Kreft", email = "mail@mkreft.de" }
8
+ ]
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "pandas>=2.2.3",
12
+ "matplotlib>=3.9.2",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.11.17,<0.12.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pandas-stubs>=2.2.2.240807",
22
+ ]
@@ -0,0 +1,5 @@
1
+ """The classical time series heatmap plot extended with a mean profile and daily average and peaks."""
2
+
3
+ from .heatmap import heatmapfigure, HeatmapFigure
4
+
5
+ __all__ = ["heatmapfigure", "HeatmapFigure"]
@@ -0,0 +1,396 @@
1
+ import warnings
2
+ import datetime as dt
3
+ import pandas as pd
4
+ import numpy as np
5
+ import matplotlib.pyplot as plt
6
+ import matplotlib.dates as mdates
7
+ from matplotlib.axes import Axes
8
+ from cycler import cycler
9
+ from typing import Optional
10
+
11
+ from .suntimes import Sun
12
+
13
+
14
+ class HeatmapFigure(plt.Figure):
15
+ """Custom matplotlib Figure subclass for plotting time series heatmaps.
16
+
17
+ Provides public attributes (ax_heatmap, ax_daily, ax_hourly, ax_cbar, ax_daily_peak)
18
+ for easy access and further customization of the individual plot axes.
19
+ """
20
+
21
+ COLOR_AXES = "#999999"
22
+ COLOR_SCATTER = "#000000"
23
+ COLOR_SUNTIMES = "#ffffff"
24
+ _default_figwidth = 8
25
+ rc_params = {
26
+ "figure.figsize": (_default_figwidth, _default_figwidth / 1.618),
27
+ "font.family": "sans-serif",
28
+ "font.size": 9,
29
+ "savefig.bbox": "tight",
30
+ "savefig.pad_inches": 0.02,
31
+ "axes.edgecolor": COLOR_AXES,
32
+ "xtick.color": COLOR_AXES, # modifies both ticks and labels
33
+ "ytick.color": COLOR_AXES,
34
+ "xtick.labelcolor": "black", # set labels back to black
35
+ "ytick.labelcolor": "black",
36
+ "lines.color": COLOR_AXES,
37
+ "axes.prop_cycle": cycler(
38
+ "color", ["#999999"]
39
+ ), # this sets the facecolor for fill_between
40
+ }
41
+
42
+ ax_cbar: Axes
43
+ ax_heatmap: Axes
44
+ ax_daily: Axes
45
+ ax_daily_peak: Optional[Axes] = None
46
+ ax_hourly: Axes
47
+
48
+ def savefig(self, *args, **kwargs):
49
+ with plt.rc_context(self.rc_params):
50
+ super().savefig(*args, **kwargs)
51
+
52
+
53
+ def heatmapfigure(
54
+ series: pd.Series,
55
+ cbar_label: str = "Power (kW)",
56
+ daily_label: str = "Energy (kWh)",
57
+ hourly_label: str = "Profile (kW)",
58
+ daily_max: bool = True,
59
+ daily_func: str = "integral",
60
+ title: Optional[str] = None,
61
+ annotate_suntimes: Optional[tuple[float, float]] = None,
62
+ figsize: Optional[tuple[int, int]] = None,
63
+ **kwargs,
64
+ ) -> HeatmapFigure:
65
+ """Makes a figure with heatmap, daily overview, and hourly profile.
66
+
67
+ Args:
68
+ series: Series with timezone-aware DatetimeIndex and frequency.
69
+ Timestamps describe the start of the interval for which the
70
+ value is valid.
71
+ cbar_label: Label for the colorbar.
72
+ daily_label: Label for the y-axis of the daily overview. This axis
73
+ shows the integral of the series values over the day,
74
+ i.e., miltiplied by the interval length.
75
+ hourly_label: Label for the y-axis of the mean profile.
76
+ daily_max: If True, the daily maximum is plotted as a scatter plot.
77
+ daily_func: Function to use for the daily overview. Can be "integral"
78
+ for the integral over the day or "mean" for the mean.
79
+ title: Title of the figure.
80
+ annotate_suntimes: Tuple with latitude and longitude to annotate
81
+ sunrise and sunset time for that location.
82
+ figsize: Tuple with width and height of the figure in inches.
83
+ **kwargs: Additional keyword arguments passed to pcolormesh.
84
+
85
+ Returns:
86
+ Custom Figure subclass with the plot and axes as additional attributes.
87
+ """
88
+
89
+ # check is series
90
+ if not isinstance(series, pd.Series):
91
+ raise TypeError("series must be a pandas Series")
92
+ if series.empty:
93
+ raise ValueError("Series cannot be empty")
94
+ # Check if the index is a datetime index
95
+ if not isinstance(series.index, pd.DatetimeIndex):
96
+ raise ValueError("Series index must be a DatetimeIndex")
97
+ index: pd.DatetimeIndex = series.index
98
+ # Check if the index is timezone aware
99
+ if index.tz is None:
100
+ raise ValueError("Series index must be timezone-aware")
101
+ # Check if the index has a fixed frequency
102
+ if index.freq is None:
103
+ raise ValueError("Series index must have a frequency")
104
+ if daily_func not in ["integral", "mean"]:
105
+ raise ValueError("`daily_func` must be either 'integral' or 'mean'")
106
+
107
+ interval_minutes = pd.to_timedelta(index.freq).total_seconds() / 60 # ty: ignore[no-matching-overload]
108
+
109
+ # Generate the pivoted heatmap and corresponding time and date range
110
+ data, daterange, timerange = _heatmap_data(series)
111
+
112
+ with plt.rc_context(HeatmapFigure.rc_params):
113
+ # Set up the figure and axes
114
+ fig: HeatmapFigure = plt.figure(FigureClass=HeatmapFigure, figsize=figsize) # ty: ignore[invalid-assignment]
115
+ if title is not None:
116
+ fig.suptitle(title)
117
+ gs = fig.add_gridspec(
118
+ 2,
119
+ 2,
120
+ width_ratios=(7, 1),
121
+ height_ratios=(2, 8),
122
+ left=0.1,
123
+ right=0.9,
124
+ bottom=0.1,
125
+ top=0.9,
126
+ wspace=0.01,
127
+ hspace=0.01 * (fig.get_figwidth() / fig.get_figheight()),
128
+ )
129
+ ax = fig.add_subplot(gs[1, 0])
130
+ ax_daily = fig.add_subplot(gs[0, 0])
131
+ ax_hourly = fig.add_subplot(gs[1, 1])
132
+ ax_cbar = ax_daily.inset_axes((1.0, 0, 0.035, 1)) # (1.055, 0, 0.035, 1)
133
+
134
+ daily_peak_ax = _plot_hists(
135
+ daterange,
136
+ timerange,
137
+ data,
138
+ ax_daily,
139
+ ax_hourly,
140
+ interval_minutes,
141
+ daily_max=daily_max,
142
+ daily_func=daily_func,
143
+ )
144
+ ax_daily.set_ylabel(daily_label)
145
+ ax_hourly.set_xlabel(hourly_label)
146
+
147
+ mesh = plot_pcolormesh(ax, daterange, timerange, data, **kwargs)
148
+
149
+ # Add and style the colorbar
150
+ cbar = fig.colorbar(mesh, cax=ax_cbar, label=cbar_label)
151
+ cbar.outline.set_visible(False) # ty: ignore[call-non-callable]
152
+ ax_cbar.tick_params(which="both", rotation=0, left=False, labelleft=False)
153
+ ax_cbar.minorticks_on()
154
+ # for t in ax_cbar.get_yticklabels():
155
+ # t.set_verticalalignment('center')
156
+ ax_cbar.set_zorder(100)
157
+
158
+ if annotate_suntimes:
159
+ _annotate_suntimes(ax, daterange, coords=annotate_suntimes)
160
+
161
+ fig.ax_cbar = ax_cbar
162
+ fig.ax_heatmap = ax
163
+ fig.ax_daily = ax_daily
164
+ fig.ax_hourly = ax_hourly
165
+ fig.ax_daily_peak = daily_peak_ax
166
+ if daily_max and daily_peak_ax is not None:
167
+ # equalize y-axis limits of cbar and peak hist
168
+ daily_peak_ax.set_ylim(ax_cbar.get_ylim())
169
+
170
+ return fig
171
+
172
+
173
+ def _heatmap_data(
174
+ series: pd.Series,
175
+ ) -> tuple[np.ndarray, pd.DatetimeIndex, pd.DatetimeIndex]:
176
+ """Get [day x hour] matrix and date-/timeranges from series"""
177
+ index = series.index
178
+ if not isinstance(index, pd.DatetimeIndex):
179
+ raise ValueError("Series index must be a DatetimeIndex")
180
+
181
+ # Pad to start and end of day if the series is does not cover a full day
182
+ if index.min().date() == index.max().date():
183
+ new_index = pd.date_range(
184
+ index.min().floor("D"),
185
+ index.max().floor("D") + pd.Timedelta(days=1),
186
+ freq=index.freq,
187
+ )[:-1]
188
+ series = series.reindex(new_index, fill_value=np.nan)
189
+ index = series.index
190
+ if not isinstance(index, pd.DatetimeIndex):
191
+ raise ValueError("Series index must be a DatetimeIndex")
192
+
193
+ timezone = index.tz
194
+ # Alternative: set date and time as multiindex (drop duplicates) and use unstack
195
+ df = series.copy().to_frame(name="values")
196
+ df["date"] = index.date
197
+ df["time"] = index.time
198
+ data = df.pivot_table(
199
+ index="time",
200
+ columns="date",
201
+ values="values",
202
+ # in local time there are duplicates, set them to nan
203
+ aggfunc=lambda x: x.iloc[0] if len(x) == 1 else np.nan,
204
+ # aggfunc=lambda x: x if len(x) == 1 else np.nan,
205
+ # aggfunc=lambda x: x.iloc[0],
206
+ # aggfunc=lambda x: x.sum(skipna=False),
207
+ dropna=False,
208
+ )
209
+ # Construct daterange with timezone
210
+ daterange = data.columns.astype("datetime64[ns]").tz_localize(timezone)
211
+ # Add one day to the end because pcolormesh requires edges. When last date
212
+ # is one with time change backwards, adding one in local time does not add
213
+ # the day. Therefore, use naive timestamps and add timezone explicitly.
214
+ daterange = pd.date_range(
215
+ start=daterange.min().tz_localize(None),
216
+ end=daterange.max().tz_localize(None) + pd.Timedelta(days=1),
217
+ tz=timezone,
218
+ )
219
+ # Construct timerange with frequency and timezone information
220
+ timerange = pd.date_range(
221
+ start="1970-01-01T00:00:00",
222
+ end="1970-01-02T00:00:00",
223
+ freq=index.freq,
224
+ tz=timezone,
225
+ )
226
+ # Numpy needs float type with np.nan instead of pf.NA
227
+ data = data.replace(pd.NA, np.nan).to_numpy()
228
+ return data, daterange, timerange
229
+
230
+
231
+ def plot_pcolormesh(
232
+ ax: Axes,
233
+ daterange: pd.DatetimeIndex,
234
+ timerange: pd.DatetimeIndex,
235
+ data: np.ndarray,
236
+ **kwargs,
237
+ ):
238
+ """
239
+ Plot the 2D demand profile
240
+ Take a numpy matrix and indices and make a figure with heatmap and sum/avg
241
+ """
242
+
243
+ mesh = ax.pcolormesh(daterange, timerange, data, **kwargs)
244
+ ax.set_xlim(daterange[0], daterange[-1]) # ty: ignore[invalid-argument-type]
245
+ ax.invert_yaxis()
246
+
247
+ locator = mdates.AutoDateLocator(tz=daterange.tz)
248
+ formatter = mdates.ConciseDateFormatter(locator, tz=daterange.tz)
249
+ ax.xaxis.set_major_locator(locator)
250
+ # ax.xaxis.set_major_formatter(mdates.DateFormatter("%m.%y"))
251
+ ax.xaxis.set_major_formatter(formatter)
252
+ ax.xaxis.set_minor_locator(mdates.MonthLocator(tz=daterange.tz))
253
+
254
+ # Alternative format: '%#H' if os.name == 'nt' else '%-H'
255
+ ax.yaxis.set_major_formatter(mdates.DateFormatter("%H", tz=timerange.tz))
256
+ ax.yaxis.set_major_locator(mdates.AutoDateLocator(tz=timerange.tz))
257
+ ax.yaxis.set_minor_locator(mdates.HourLocator(tz=timerange.tz))
258
+
259
+ # Remove last xticklabel to avoid overlap with next axis
260
+ ax.xaxis.get_majorticklabels()[-1].set_visible(False)
261
+ # Also remove first and last yticklabel to avoid overlap and because is 00
262
+ ax.yaxis.get_majorticklabels()[0].set_visible(False)
263
+ ax.yaxis.get_majorticklabels()[-1].set_visible(False)
264
+
265
+ ax.set_xlabel("Date")
266
+ ax.set_ylabel("Hour")
267
+
268
+ # Hide axes frame lines and set color
269
+ ax.spines[["top", "right"]].set_visible(False)
270
+
271
+ return mesh
272
+
273
+
274
+ def _plot_hists(
275
+ daterange: pd.DatetimeIndex,
276
+ timerange: pd.DatetimeIndex,
277
+ data: np.ndarray,
278
+ ax_daily: Axes,
279
+ ax_hourly: Axes,
280
+ interval_minutes: float,
281
+ daily_max: bool,
282
+ daily_func: str,
283
+ ) -> Optional[Axes]:
284
+ """Plot the daily aggregated profile (top axis) and mean profile (right axis)."""
285
+ twinx = None
286
+ # Daily max
287
+ if daily_max:
288
+ with warnings.catch_warnings():
289
+ warnings.simplefilter("ignore", category=RuntimeWarning)
290
+ daily_max_draw = np.nanmax(data, axis=0)
291
+ daily_min_draw = np.nanmin(data, axis=0)
292
+ # take max if max larger than abs(min)
293
+ daily_peak_draw = np.where(
294
+ np.abs(daily_max_draw) >= np.abs(daily_min_draw),
295
+ daily_max_draw,
296
+ daily_min_draw,
297
+ )
298
+ twinx = ax_daily.twinx()
299
+ # twinx.set_ylabel("Peak", labelpad=0)
300
+ twinx.scatter(
301
+ daterange[:-1] + dt.timedelta(hours=12),
302
+ daily_peak_draw,
303
+ color=HeatmapFigure.COLOR_SCATTER,
304
+ s=1,
305
+ linewidths=0,
306
+ )
307
+ twinx.set_ylim(0, None)
308
+
309
+ twinx.spines[["top", "left", "bottom"]].set_visible(False)
310
+
311
+ # Rotate in case they are long
312
+ # # does not work in older mpl
313
+ # # twinx.set_yticks(twinx.get_yticks())
314
+ # # twinx.set_yticklabels(twinx.get_yticklabels(), rotation=90, va='center')
315
+ # for t in twinx.get_yticklabels():
316
+ # t.set_verticalalignment('center')
317
+ # remove ticks and labels because we use the ones from the coolorbar
318
+ twinx.set_yticklabels([])
319
+ twinx.yaxis.set_tick_params(size=0)
320
+
321
+ # Add a line at 0 if the min is below zero
322
+ if np.nanmin(daily_peak_draw) < 0:
323
+ twinx.axhline(0, color=HeatmapFigure.COLOR_AXES, lw=0.5)
324
+
325
+ # Daily sum
326
+ if daily_func == "integral":
327
+ daily_demand = np.nansum(data, axis=0) * interval_minutes / 60
328
+ elif daily_func == "mean":
329
+ daily_demand = np.nanmean(data, axis=0)
330
+ ax_daily.fill_between(
331
+ daterange[:-1] + dt.timedelta(hours=12),
332
+ daily_demand,
333
+ alpha=0.5,
334
+ )
335
+ ax_daily.set_xlim(daterange[0], daterange[-1]) # ty: ignore[invalid-argument-type]
336
+ # Need to set the max here as well, else when removing the lower tick (below)
337
+ # the limits get extended, since the ticks have not been rendered yet.
338
+ ax_daily.set_ylim(min(0, daily_demand.min()), daily_demand.max())
339
+ # Remove first label because it may overlap with heat map
340
+ # ax_histx.yaxis.get_majorticklabels()[0].set_visible(False)
341
+
342
+ ax_daily.ticklabel_format(axis="y", style="sci", scilimits=(-2, 2))
343
+
344
+ # Mean profile
345
+ ax_hourly.fill_betweenx(
346
+ timerange[:-1] + dt.timedelta(minutes=interval_minutes) / 2,
347
+ np.nanmean(data, axis=1),
348
+ alpha=0.5,
349
+ )
350
+ ax_hourly.set_zorder(-1) # Draw behind so labels from other axes can be on graph
351
+ ax_hourly.set_yticklabels([])
352
+ # If demand is larger than zero, always show from zero, else show from negative demand on
353
+ ax_hourly.set_xlim(min(0, np.nanmean(data, axis=1).min()), None)
354
+ ax_hourly.set_ylim(timerange[0], timerange[-1]) # ty: ignore[invalid-argument-type]
355
+ ax_hourly.invert_yaxis() # This has to be called after setting lims
356
+
357
+ # Hide the ticks and labels
358
+ ax_daily.get_xaxis().set_visible(False)
359
+ ax_hourly.get_yaxis().set_visible(False)
360
+ # Hide axes frame lines
361
+ ax_daily.spines[["top", "right", "bottom"]].set_visible(False)
362
+ ax_hourly.spines[["top", "right", "left"]].set_visible(False)
363
+ ax_daily.minorticks_on()
364
+ ax_hourly.minorticks_on()
365
+
366
+ return twinx
367
+
368
+
369
+ def _annotate_suntimes(
370
+ ax: Axes,
371
+ daterange: pd.DatetimeIndex,
372
+ coords: tuple[float, float],
373
+ ) -> None:
374
+ """Plots lines for sunrise and sunset times."""
375
+ # Shift daterange by half a day to match the heatmap
376
+ daterange = daterange[:-1] + pd.Timedelta(hours=12)
377
+ times = daterange.to_frame(index=False, name="date").merge(
378
+ pd.DataFrame(columns=["date", "sunrise", "sunset"]), how="left", on="date"
379
+ )
380
+ lat, long = coords
381
+ sun = Sun(latitude=lat, longitude=long)
382
+ times[["sunrise", "sunset"]] = times.apply(
383
+ lambda d: [
384
+ # non-aware datetime in local timezone
385
+ dt.datetime.combine(dt.datetime(year=1970, month=1, day=1).date(), x)
386
+ for x in sun.get_suntimes(d["date"])
387
+ ],
388
+ axis="columns",
389
+ result_type="expand",
390
+ )
391
+ # Suntimes are calcualted in the local timezone and need to be localized
392
+ times["sunset"] = times["sunset"].dt.tz_localize(daterange.tz)
393
+ times["sunrise"] = times["sunrise"].dt.tz_localize(daterange.tz)
394
+
395
+ ax.plot(daterange, times["sunset"], color=HeatmapFigure.COLOR_SUNTIMES, lw=0.5)
396
+ ax.plot(daterange, times["sunrise"], color=HeatmapFigure.COLOR_SUNTIMES, lw=0.5)
File without changes
@@ -0,0 +1,135 @@
1
+ import datetime
2
+ from math import sin, cos, tan, radians, degrees, acos, asin
3
+
4
+
5
+ class Sun:
6
+ """Calculate sunrise and sunset based on equations from NOAA:
7
+ http://www.srrb.noaa.gov/highlights/sunrise/calcdetails.html
8
+ A similar implementation is available at
9
+ https://michelanders.blogspot.com/2010/12/calulating-sunrise-and-sunset-in-python.html
10
+ The algorithm is based on the Book Astronomical Algorithms by Jean Meeus.
11
+ """
12
+
13
+ def __init__(self, latitude, longitude):
14
+ self.latitude = latitude
15
+ self.longitude = longitude
16
+
17
+ @staticmethod
18
+ def percentday2time(x):
19
+ """Convert a fractional day float [0.0, 1.0) into a datetime.time object."""
20
+ # Ensure x is in [0, 1) by wrapping
21
+ x = x % 1.0
22
+ hours = 24 * x
23
+ h = int(hours)
24
+ minutes = (hours - h) * 60
25
+ m = int(minutes)
26
+ seconds = (minutes - m) * 60
27
+ s = int(seconds)
28
+ return datetime.time(h, m, s)
29
+
30
+ @staticmethod
31
+ def day_time_timezone_from_datetime(
32
+ date: datetime.datetime,
33
+ ) -> tuple[int, float, float]:
34
+ """Convert a datetime into a fractional day and timezone offset (in hours)."""
35
+ # OpenOffice day zero is December 30, 1899
36
+ day = date.toordinal() - datetime.datetime(1899, 12, 30).toordinal()
37
+ t = date.time()
38
+ time = (t.hour + t.minute / 60 + t.second / 3600) / 24
39
+ offset = date.utcoffset()
40
+ timezone = offset.total_seconds() / 3600 if offset else 0
41
+ return day, time, timezone
42
+
43
+ def get_suntimes(
44
+ self, date: datetime.datetime
45
+ ) -> tuple[datetime.time, datetime.time]:
46
+ """Calculate sunrise and sunset times for a given date.
47
+
48
+ The sunrise and sunset results are accurate to one minute for latitudes
49
+ between +/- 72°, and 10 minutes outside those. Calculations are valid
50
+ for dates between 1901 and 2099, due to an approximation used in the
51
+ Julian Day calculation.
52
+
53
+ Args:
54
+ date: The date for which to calculate sunrise and sunset.
55
+
56
+ Returns:
57
+ tuple: A tuple containing sunrise and sunset times in local time.
58
+ """
59
+ day, time, timezone = self.day_time_timezone_from_datetime(date)
60
+
61
+ julian_day = day + 2415018.5 + time - timezone / 24
62
+ julian_century = (julian_day - 2451545) / 36525
63
+
64
+ geom_mean_long_sun_deg = (
65
+ 280.46646 + julian_century * (36000.76983 + julian_century * 0.0003032)
66
+ ) % 360
67
+ geom_mean_anom_sun_deg = 357.52911 + julian_century * (
68
+ 35999.05029 - 0.0001537 * julian_century
69
+ )
70
+ eccent_earth_orbit = 0.016708634 - julian_century * (
71
+ 0.000042037 + 0.0000001267 * julian_century
72
+ )
73
+ sun_eq_of_ctr = (
74
+ sin(radians(geom_mean_anom_sun_deg))
75
+ * (1.914602 - julian_century * (0.004817 + 0.000014 * julian_century))
76
+ + sin(radians(2 * geom_mean_anom_sun_deg))
77
+ * (0.019993 - 0.000101 * julian_century)
78
+ + sin(radians(3 * geom_mean_anom_sun_deg)) * 0.000289
79
+ )
80
+ sun_true_long_deg = geom_mean_long_sun_deg + sun_eq_of_ctr
81
+ sun_app_long_deg = (
82
+ sun_true_long_deg
83
+ - 0.00569
84
+ - 0.00478 * sin(radians(125.04 - 1934.136 * julian_century))
85
+ )
86
+ mean_obliq_ecliptic_deg = (
87
+ 23
88
+ + (
89
+ 26
90
+ + (
91
+ 21.448
92
+ - julian_century
93
+ * (46.815 + julian_century * (0.00059 - julian_century * 0.001813))
94
+ )
95
+ / 60
96
+ )
97
+ / 60
98
+ )
99
+ obliq_corr_deg = mean_obliq_ecliptic_deg + 0.00256 * cos(
100
+ radians(125.04 - 1934.136 * julian_century)
101
+ )
102
+ sun_declin_deg = degrees(
103
+ asin(sin(radians(obliq_corr_deg)) * sin(radians(sun_app_long_deg)))
104
+ )
105
+ var_y = tan(radians(obliq_corr_deg / 2)) * tan(radians(obliq_corr_deg / 2))
106
+ eq_of_time_minutes = 4 * degrees(
107
+ var_y * sin(2 * radians(geom_mean_long_sun_deg))
108
+ - 2 * eccent_earth_orbit * sin(radians(geom_mean_anom_sun_deg))
109
+ + 4
110
+ * eccent_earth_orbit
111
+ * var_y
112
+ * sin(radians(geom_mean_anom_sun_deg))
113
+ * cos(2 * radians(geom_mean_long_sun_deg))
114
+ - 0.5 * var_y * var_y * sin(4 * radians(geom_mean_long_sun_deg))
115
+ - 1.25
116
+ * eccent_earth_orbit
117
+ * eccent_earth_orbit
118
+ * sin(2 * radians(geom_mean_anom_sun_deg))
119
+ )
120
+ cos_ha = cos(radians(90.833)) / (
121
+ cos(radians(self.latitude)) * cos(radians(sun_declin_deg))
122
+ ) - tan(radians(self.latitude)) * tan(radians(sun_declin_deg))
123
+ # Clip to avoid ValueError: math domain error at extreme latitudes/seasons
124
+ cos_ha = max(-1.0, min(1.0, cos_ha))
125
+ ha_sunrise_deg = degrees(acos(cos_ha))
126
+ solar_noon_lst = (
127
+ 720 - 4 * self.longitude - eq_of_time_minutes + timezone * 60
128
+ ) / 1440
129
+ sunrise_time_lst = solar_noon_lst - ha_sunrise_deg * 4 / 1440
130
+ sunset_time_lst = solar_noon_lst + ha_sunrise_deg * 4 / 1440
131
+
132
+ sunrise_time_lst = self.percentday2time(sunrise_time_lst)
133
+ sunset_time_lst = self.percentday2time(sunset_time_lst)
134
+
135
+ return sunrise_time_lst, sunset_time_lst