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 ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("hot_open")
@@ -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
+ )
@@ -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
+ ]