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.
heatmapts-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|