hot-open 0.2.0__py3-none-any.whl
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.
- hot_open/__init__.py +3 -0
- hot_open/circular_math.py +180 -0
- hot_open/era5_helpers.py +142 -0
- hot_open/fastlog_helpers.py +1059 -0
- hot_open/lidar_helpers.py +365 -0
- hot_open/parquet_io.py +45 -0
- hot_open/scada_helpers.py +195 -0
- hot_open/settings.py +102 -0
- hot_open/sourcing_data.py +396 -0
- hot_open/unpack.py +81 -0
- hot_open-0.2.0.dist-info/METADATA +235 -0
- hot_open-0.2.0.dist-info/RECORD +14 -0
- hot_open-0.2.0.dist-info/WHEEL +4 -0
- hot_open-0.2.0.dist-info/licenses/LICENSE.txt +26 -0
hot_open/__init__.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Circular math functions missing from numpy/scipy."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from scipy.stats import circmean
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def circdiff_degrees(
|
|
14
|
+
angle1: float | npt.NDArray | list | pd.Series, angle2: float | npt.NDArray | list | pd.Series
|
|
15
|
+
) -> float | npt.NDArray | pd.Series:
|
|
16
|
+
"""Calculate the circular difference between two angles.
|
|
17
|
+
|
|
18
|
+
:param angle1: First angle in degrees.
|
|
19
|
+
:param angle2: Second angle in degrees.
|
|
20
|
+
:return: Circular difference between the two angles in degrees
|
|
21
|
+
"""
|
|
22
|
+
# Convert list to numpy array
|
|
23
|
+
if isinstance(angle1, list):
|
|
24
|
+
angle1 = np.array(angle1)
|
|
25
|
+
if isinstance(angle2, list):
|
|
26
|
+
angle2 = np.array(angle2)
|
|
27
|
+
|
|
28
|
+
return np.mod(angle1 - angle2 + 180, 360) - 180
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def circ_mean_resample_degrees(df: pd.DataFrame, *, resample_timedelta: pd.Timedelta) -> pd.DataFrame:
|
|
32
|
+
"""Apply resample and ciruclar mean to a DataFrame efficiently.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
----
|
|
36
|
+
df: DataFrame where each column contains circular data in degrees from 0 inclusive to 360 exclusive.
|
|
37
|
+
resample_timedelta: Timebase to resample the data to.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
-------
|
|
41
|
+
Resampled DataFrame with circular mean of each column.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(df.index, pd.DatetimeIndex):
|
|
45
|
+
msg = "DataFrame must have a DatetimeIndex."
|
|
46
|
+
raise TypeError(msg)
|
|
47
|
+
|
|
48
|
+
for col in df.columns:
|
|
49
|
+
if (data_range := df[col].max() - df[col].min()) > 360: # noqa:PLR2004
|
|
50
|
+
msg = f"Data in circular column {col} has a range larger than 360: {data_range}."
|
|
51
|
+
logger.warning(msg)
|
|
52
|
+
elif (df[col].min() < 0) or (df[col].max() > 360): # noqa:PLR2004
|
|
53
|
+
msg = (
|
|
54
|
+
f"Data in circular column {col} has values outside 0 to 360: "
|
|
55
|
+
f"min = {df[col].min()}, max = {df[col].max()}."
|
|
56
|
+
)
|
|
57
|
+
logger.warning(msg)
|
|
58
|
+
|
|
59
|
+
rad_df = np.deg2rad(df)
|
|
60
|
+
sin_df = pd.DataFrame(np.sin(rad_df), index=df.index, columns=df.columns)
|
|
61
|
+
cos_df = pd.DataFrame(np.cos(rad_df), index=df.index, columns=df.columns)
|
|
62
|
+
sin_df_resampled = sin_df.resample(resample_timedelta).mean()
|
|
63
|
+
cos_df_resampled = cos_df.resample(resample_timedelta).mean()
|
|
64
|
+
result = (np.rad2deg(np.arctan2(sin_df_resampled, cos_df_resampled)) + 360) % 360
|
|
65
|
+
return pd.DataFrame(result, index=sin_df_resampled.index, columns=df.columns)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def circ_std_resample_degrees(df: pd.DataFrame, *, resample_timedelta: pd.Timedelta) -> pd.DataFrame:
|
|
69
|
+
"""Apply resample and circular standard deviation to a DataFrame efficiently.
|
|
70
|
+
|
|
71
|
+
Uses the mean resultant length R of the unit vectors and sqrt(-2 ln R), with R clipped at 1
|
|
72
|
+
so floating-point overshoot on a tight window gives 0 rather than NaN.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
----
|
|
76
|
+
df: DataFrame where each column contains circular data in degrees from 0 inclusive to 360 exclusive.
|
|
77
|
+
resample_timedelta: Timebase to resample the data to.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
-------
|
|
81
|
+
Resampled DataFrame with the circular standard deviation of each column, in degrees.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
if not isinstance(df.index, pd.DatetimeIndex):
|
|
85
|
+
msg = "DataFrame must have a DatetimeIndex."
|
|
86
|
+
raise TypeError(msg)
|
|
87
|
+
|
|
88
|
+
rad_df = np.deg2rad(df)
|
|
89
|
+
sin_resampled = pd.DataFrame(np.sin(rad_df), index=df.index, columns=df.columns).resample(resample_timedelta).mean()
|
|
90
|
+
cos_resampled = pd.DataFrame(np.cos(rad_df), index=df.index, columns=df.columns).resample(resample_timedelta).mean()
|
|
91
|
+
resultant_length = np.sqrt(sin_resampled**2 + cos_resampled**2).clip(upper=1.0)
|
|
92
|
+
result = np.rad2deg(np.sqrt(-2 * np.log(resultant_length)))
|
|
93
|
+
return pd.DataFrame(result, index=sin_resampled.index, columns=df.columns)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def circ_mean_dataframe_columns(df: pd.DataFrame) -> pd.Series:
|
|
97
|
+
"""Apply ciruclar mean to DataFrame columns efficiently.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
----
|
|
101
|
+
df: DataFrame where each column contains circular data in degrees from 0 inclusive to 360 exclusive.
|
|
102
|
+
resample_timedelta: Timebase to resample the data to.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
-------
|
|
106
|
+
Resampled DataFrame with circular mean of each column.
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
# warn if data outside 0 to 360
|
|
110
|
+
if any(df.min() < 0) or any(df.max() >= 360): # noqa:PLR2004
|
|
111
|
+
msg = "Data is expected to be between 0 and 360 degrees."
|
|
112
|
+
logger.warning(msg)
|
|
113
|
+
|
|
114
|
+
# check that each column has data where max-min is no more than
|
|
115
|
+
for col in df.columns:
|
|
116
|
+
if (data_range := df[col].max() - df[col].min()) > 360: # noqa:PLR2004
|
|
117
|
+
msg = f"Data in column {col} has a range larger than 360 degrees: {data_range}."
|
|
118
|
+
raise ValueError(msg)
|
|
119
|
+
|
|
120
|
+
rad_df = np.deg2rad(df)
|
|
121
|
+
sin_df = np.sin(rad_df)
|
|
122
|
+
cos_df = np.cos(rad_df)
|
|
123
|
+
mean_sin_df = sin_df.mean(axis=1)
|
|
124
|
+
mean_cos_df = cos_df.mean(axis=1)
|
|
125
|
+
return (np.rad2deg(np.arctan2(mean_sin_df, mean_cos_df)) + 360) % 360
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def circmedian_degrees(angles: list[float] | npt.NDArray | pd.Series) -> float:
|
|
129
|
+
"""Calculate the circular median of angles in degrees.
|
|
130
|
+
|
|
131
|
+
The circular median is defined as the angle in provided angles that minimizes the sum of circular
|
|
132
|
+
distances to all other provided angles. For a set of angles, this is computed
|
|
133
|
+
by converting to the unit circle, finding the angle that minimizes the great circle
|
|
134
|
+
distances, and converting back to degrees.
|
|
135
|
+
|
|
136
|
+
If two angles are tied their circular mean is returned.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
----
|
|
140
|
+
angles: Array-like object containing angles in degrees
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
-------
|
|
144
|
+
float: Circular median in degrees, normalized to [0, 360)
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
# Convert to numpy array and handle empty input
|
|
148
|
+
angles_arr = np.asarray(angles)
|
|
149
|
+
if len(angles_arr) == 0:
|
|
150
|
+
msg = "Input array must not be empty"
|
|
151
|
+
raise ValueError(msg)
|
|
152
|
+
|
|
153
|
+
# Remove NaN values
|
|
154
|
+
valid_angles = angles_arr[~np.isnan(angles_arr)]
|
|
155
|
+
|
|
156
|
+
if len(valid_angles) == 0:
|
|
157
|
+
return np.nan
|
|
158
|
+
|
|
159
|
+
# Find the angle that minimizes the sum of absolute circular differences
|
|
160
|
+
min_sum_diff = float("inf")
|
|
161
|
+
median_angle = np.nan
|
|
162
|
+
candidates_which_are_tied = []
|
|
163
|
+
|
|
164
|
+
for candidate in valid_angles:
|
|
165
|
+
# Calculate absolute circular differences to all other angles
|
|
166
|
+
diffs = np.abs(circdiff_degrees(valid_angles, candidate))
|
|
167
|
+
sum_diff = np.sum(diffs)
|
|
168
|
+
|
|
169
|
+
if sum_diff < min_sum_diff:
|
|
170
|
+
min_sum_diff = sum_diff
|
|
171
|
+
median_angle = candidate
|
|
172
|
+
candidates_which_are_tied = [candidate]
|
|
173
|
+
elif sum_diff == min_sum_diff:
|
|
174
|
+
candidates_which_are_tied.append(candidate)
|
|
175
|
+
|
|
176
|
+
return float(
|
|
177
|
+
(median_angle + 360) % 360
|
|
178
|
+
if len(candidates_which_are_tied) == 1
|
|
179
|
+
else circmean(candidates_which_are_tied, low=0, high=360)
|
|
180
|
+
)
|
hot_open/era5_helpers.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""ERA5 reanalysis data fetching via Open-Meteo API."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import openmeteo_requests
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import requests_cache
|
|
11
|
+
from retry_requests import retry
|
|
12
|
+
from wind_up.reanalysis_data import ReanalysisDataset
|
|
13
|
+
|
|
14
|
+
from .parquet_io import write_parquet_atomic
|
|
15
|
+
from .settings import get_cache_dir
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
HOT_LAT: float = 57.50
|
|
20
|
+
HOT_LON: float = -3.25
|
|
21
|
+
HOT_ERA5_START: str = "2000-01-01"
|
|
22
|
+
HOT_ERA5_END: str = "2026-05-01"
|
|
23
|
+
ERA5_DEFAULT_FIELDS: list[str] = [
|
|
24
|
+
"temperature_2m",
|
|
25
|
+
"relative_humidity_2m",
|
|
26
|
+
"dew_point_2m",
|
|
27
|
+
"apparent_temperature",
|
|
28
|
+
"pressure_msl",
|
|
29
|
+
"surface_pressure",
|
|
30
|
+
"precipitation",
|
|
31
|
+
"rain",
|
|
32
|
+
"snowfall",
|
|
33
|
+
"cloud_cover",
|
|
34
|
+
"shortwave_radiation",
|
|
35
|
+
"direct_radiation",
|
|
36
|
+
"diffuse_radiation",
|
|
37
|
+
"wind_speed_10m",
|
|
38
|
+
"wind_speed_100m",
|
|
39
|
+
"wind_direction_10m",
|
|
40
|
+
"wind_direction_100m",
|
|
41
|
+
"wind_gusts_10m",
|
|
42
|
+
"weather_code",
|
|
43
|
+
]
|
|
44
|
+
HOT_ERA5_FIELDS: list[str] = ERA5_DEFAULT_FIELDS
|
|
45
|
+
|
|
46
|
+
_era5_cache_dir = get_cache_dir() / "era5_data"
|
|
47
|
+
_era5_cache_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_era5_df(response: object, fields: list[str]) -> pd.DataFrame:
|
|
51
|
+
"""Build a tidy hourly DataFrame from a single Open-Meteo response object."""
|
|
52
|
+
hourly_data = response.Hourly() # type: ignore[attr-defined] # openmeteo_requests has no type stubs
|
|
53
|
+
return pd.DataFrame(
|
|
54
|
+
{
|
|
55
|
+
"timestamp": pd.date_range(
|
|
56
|
+
start=pd.to_datetime(hourly_data.Time(), unit="s", utc=True),
|
|
57
|
+
end=pd.to_datetime(hourly_data.TimeEnd(), unit="s", utc=True),
|
|
58
|
+
freq=pd.Timedelta(seconds=hourly_data.Interval()),
|
|
59
|
+
inclusive="left",
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
| {field: hourly_data.Variables(i).ValuesAsNumpy() for i, field in enumerate(fields)}
|
|
63
|
+
).set_index("timestamp")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _era5_cache_path(*, lat: float, lon: float, start_date: str, end_date: str, fields: list[str]) -> Path:
|
|
67
|
+
"""Build a deterministic parquet cache path from the request args."""
|
|
68
|
+
args_blob = json.dumps(
|
|
69
|
+
{"lat": lat, "lon": lon, "start_date": start_date, "end_date": end_date, "fields": list(fields)},
|
|
70
|
+
sort_keys=True,
|
|
71
|
+
)
|
|
72
|
+
args_hash = hashlib.sha256(args_blob.encode("utf-8")).hexdigest()[:16]
|
|
73
|
+
return _era5_cache_dir / f"ERA5_{lat:.2f}_{lon:.2f}_{start_date}_{end_date}_{args_hash}.parquet"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_era5_hourly_df(
|
|
77
|
+
*,
|
|
78
|
+
lat: float,
|
|
79
|
+
lon: float,
|
|
80
|
+
start_date: str = "2000-01-01",
|
|
81
|
+
end_date: str | None = None,
|
|
82
|
+
fields: list[str] = ERA5_DEFAULT_FIELDS,
|
|
83
|
+
) -> pd.DataFrame:
|
|
84
|
+
"""Fetch hourly ERA5 data from Open-Meteo for any location and return as a DataFrame.
|
|
85
|
+
|
|
86
|
+
``end_date`` defaults to today (UTC) when ``None``. Each unique combination of arguments
|
|
87
|
+
is cached to its own parquet file keyed by a hash of the arguments. Delete the cache file
|
|
88
|
+
to force a refetch.
|
|
89
|
+
"""
|
|
90
|
+
if end_date is None:
|
|
91
|
+
end_date = pd.Timestamp.now(tz="UTC").normalize().strftime("%Y-%m-%d")
|
|
92
|
+
cache_path = _era5_cache_path(lat=lat, lon=lon, start_date=start_date, end_date=end_date, fields=fields)
|
|
93
|
+
if cache_path.exists():
|
|
94
|
+
logger.info("Reading: %s", cache_path)
|
|
95
|
+
return pd.read_parquet(cache_path)
|
|
96
|
+
|
|
97
|
+
openmeteo = openmeteo_requests.Client(
|
|
98
|
+
session=retry(
|
|
99
|
+
requests_cache.CachedSession(
|
|
100
|
+
str(get_cache_dir() / "openmeteo_requests_cache"),
|
|
101
|
+
expire_after=3600,
|
|
102
|
+
),
|
|
103
|
+
retries=5,
|
|
104
|
+
backoff_factor=0.2,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
responses = openmeteo.weather_api(
|
|
108
|
+
url="https://archive-api.open-meteo.com/v1/archive",
|
|
109
|
+
params={
|
|
110
|
+
"latitude": lat,
|
|
111
|
+
"longitude": lon,
|
|
112
|
+
"start_date": start_date,
|
|
113
|
+
"end_date": end_date,
|
|
114
|
+
"hourly": fields,
|
|
115
|
+
"models": "era5",
|
|
116
|
+
"wind_speed_unit": "ms",
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
df = _build_era5_df(responses[0], fields)
|
|
120
|
+
logger.info("Writing: %s", cache_path)
|
|
121
|
+
write_parquet_atomic(df, cache_path)
|
|
122
|
+
return df
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_hot_era5_hourly_df(
|
|
126
|
+
*,
|
|
127
|
+
start_date: str = HOT_ERA5_START,
|
|
128
|
+
end_date: str = HOT_ERA5_END,
|
|
129
|
+
fields: list[str] = HOT_ERA5_FIELDS,
|
|
130
|
+
) -> pd.DataFrame:
|
|
131
|
+
"""Fetch hourly ERA5 data for the HOT site (convenience wrapper over :func:`get_era5_hourly_df`)."""
|
|
132
|
+
return get_era5_hourly_df(lat=HOT_LAT, lon=HOT_LON, start_date=start_date, end_date=end_date, fields=fields)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_hot_reanalysis_datasets() -> list[ReanalysisDataset]:
|
|
136
|
+
"""Return a list of ReanalysisDataset objects for the HOT site."""
|
|
137
|
+
return [
|
|
138
|
+
ReanalysisDataset(
|
|
139
|
+
id=f"ERA5_{HOT_LAT:.2f}_{HOT_LON:.2f}",
|
|
140
|
+
data=get_hot_era5_hourly_df(),
|
|
141
|
+
)
|
|
142
|
+
]
|