pyepwmorph 3.0.0__tar.gz → 3.2.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.
Files changed (26) hide show
  1. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/PKG-INFO +30 -5
  2. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/README.md +29 -4
  3. pyepwmorph-3.2.0/pyepwmorph/data/__init__.py +1 -0
  4. pyepwmorph-3.2.0/pyepwmorph/data/ch2025_monthly.parquet +0 -0
  5. pyepwmorph-3.2.0/pyepwmorph/data/ch2025_stations.parquet +0 -0
  6. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/models/assemble.py +24 -0
  7. pyepwmorph-3.2.0/pyepwmorph/models/ch2025.py +301 -0
  8. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/morph/procedures.py +64 -2
  9. pyepwmorph-3.2.0/pyepwmorph/tools/configuration.py +433 -0
  10. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/io.py +75 -5
  11. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/workflow.py +175 -24
  12. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyproject.toml +3 -2
  13. pyepwmorph-3.0.0/pyepwmorph/tools/configuration.py +0 -246
  14. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/.gitignore +0 -0
  15. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/LICENSE +0 -0
  16. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/__init__.py +0 -0
  17. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/models/__init__.py +0 -0
  18. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/models/access.py +0 -0
  19. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/models/coordinate.py +0 -0
  20. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/models/custom.py +0 -0
  21. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/morph/__init__.py +0 -0
  22. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/__init__.py +0 -0
  23. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/cache.py +0 -0
  24. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/psychrometrics.py +0 -0
  25. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/solar.py +0 -0
  26. {pyepwmorph-3.0.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/utilities.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyepwmorph
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: A python package to enable simple and easy gathering of climate model data and morphing of EPW files
5
5
  Project-URL: Homepage, https://github.com/justinfmccarty/pyepwmorph
6
6
  Project-URL: Issues, https://github.com/justinfmccarty/pyepwmorph/issues
@@ -51,6 +51,7 @@ A Python package for morphing EnergyPlus Weather (EPW) files with climate model
51
51
 
52
52
  - **CMIP6 data** fetched automatically from Google Cloud (Pangeo)
53
53
  - **Custom CSV data** from any climate model, including historical reconstructions
54
+ - **CH2025 station scenarios** for Switzerland, shipped with the package and indexed by global warming level
54
55
 
55
56
  ## Installation
56
57
 
@@ -128,6 +129,29 @@ Custom CSVs should have a `date` column (parseable by pandas) and a column named
128
129
 
129
130
  The reference and target scenarios must cover **different years**, the same way the CMIP6 `historical` and `sspXXX` experiments do. The two series are concatenated before the baseline and target periods are sliced out, so overlapping years get averaged together and weaken the climate signal. Keep `baseline_range` inside the years the reference scenario covers.
130
131
 
132
+ ### Switzerland (CH2025 warming levels)
133
+
134
+ For an EPW inside Switzerland, `data_source="ch2025"` morphs from MeteoSwiss CH2025 station scenarios. There is no target year: each pathway is a global warming level relative to 1991-2020, and the run is offline.
135
+
136
+ ```python
137
+ results = workflow.morphing_workflow(
138
+ project_name="Zurich_GWL2",
139
+ epw_file="zurich.epw",
140
+ user_variables=["Temperature", "Humidity", "Wind", "Radiation", "Dew Point"],
141
+ user_pathways=["GWL 2.0"],
142
+ percentiles=[50],
143
+ output_directory="output/",
144
+ data_source="ch2025",
145
+ )
146
+ morphed = results["gwl2.0"]["50"]
147
+ ```
148
+
149
+ The site is matched to the nearest station that has the requested variables (or, with `ch2025_full_coverage=True`, the nearest station that has every CH2025 variable), with an elevation penalty so a much higher station is not chosen just because it is close on the map. Supported variables are temperature, humidity, dew point, wind, and radiation (global, diffuse, and direct). Pressure and cloud cover are not in CH2025; requesting them raises an error. Sky cover is left at the EPW's baseline values when radiation is morphed, so longwave sky temperature in EnergyPlus does not follow the shortwave change.
150
+
151
+ The signal is the change from the 1991-2020 reference climate to the chosen warming level. Each model chain's change is computed first, over the chains that reach that warming level, and the percentiles are taken of those changes. The EPW's period is read from the `Period of Record` in its header. If that is not exactly 1991-2020, a `UserWarning` recommends a TMY built from 1991-2020 data. If the header states no period, a second warning says the period could not be detected, and the mismatch warning still fires if the data rows' years fall outside 1991-2020. The signal is applied to the file as it stands either way. `tools.io.write_period_of_record` adds the statement to files built without one. The same caveats are listed in `MorphConfig.ch2025_notes` and the matched station in `MorphConfig.ch2025_station`.
152
+
153
+ CH2025 data: MeteoSwiss & ETH Zurich (2025), Climate CH2025 - Daily Datasets, CC-BY 4.0, https://doi.org/10.18751/climate/scenarios/ch2025/data/1.0/
154
+
131
155
  ## Climate scenarios
132
156
 
133
157
  | Scenario | SSP | Description | Expected warming |
@@ -143,15 +167,16 @@ The reference and target scenarios must cover **different years**, the same way
143
167
  - **Humidity** -- relative humidity, stretched in specific humidity space
144
168
  - **Pressure** -- atmospheric pressure (shift)
145
169
  - **Wind** -- wind speed (stretch)
146
- - **Clouds and Radiation** -- global/diffuse/direct radiation and sky cover
170
+ - **Radiation** -- global/diffuse/direct radiation, without changing sky cover (all sources; the only radiation option for CH2025)
171
+ - **Clouds and Radiation** -- global/diffuse/direct radiation and sky cover (CMIP6 and custom data)
147
172
  - **Dew Point** -- recalculated from morphed temperature and humidity
148
173
 
149
174
  ### Variable dependencies
150
175
 
151
176
  Some variables cannot be morphed on their own:
152
177
 
153
- - **Humidity** requires Temperature and Pressure
154
- - **Dew Point** requires Temperature, Humidity, and Pressure
178
+ - **Humidity** requires Temperature and Pressure, except with CH2025, where relative humidity is stretched directly
179
+ - **Dew Point** requires Temperature, Humidity, and Pressure (Temperature and Humidity only with CH2025)
155
180
 
156
181
  Dependencies are added automatically and are **written to the output file**. Asking for `Dew Point` alone therefore returns an EPW with morphed pressure, temperature, relative humidity, and dew point, which keeps the file internally consistent. `MorphConfig.resolved_variables` shows exactly what will be written, in the order it is computed.
157
182
 
@@ -220,7 +245,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full history. The most recent release c
220
245
 
221
246
  - Python >= 3.9
222
247
  - pandas >= 2.2
223
- - Internet connection (for CMIP6 data download; the custom CSV workflow runs offline)
248
+ - Internet connection (for CMIP6 data download; the custom CSV and CH2025 workflows run offline)
224
249
 
225
250
  ## License
226
251
 
@@ -12,6 +12,7 @@ A Python package for morphing EnergyPlus Weather (EPW) files with climate model
12
12
 
13
13
  - **CMIP6 data** fetched automatically from Google Cloud (Pangeo)
14
14
  - **Custom CSV data** from any climate model, including historical reconstructions
15
+ - **CH2025 station scenarios** for Switzerland, shipped with the package and indexed by global warming level
15
16
 
16
17
  ## Installation
17
18
 
@@ -89,6 +90,29 @@ Custom CSVs should have a `date` column (parseable by pandas) and a column named
89
90
 
90
91
  The reference and target scenarios must cover **different years**, the same way the CMIP6 `historical` and `sspXXX` experiments do. The two series are concatenated before the baseline and target periods are sliced out, so overlapping years get averaged together and weaken the climate signal. Keep `baseline_range` inside the years the reference scenario covers.
91
92
 
93
+ ### Switzerland (CH2025 warming levels)
94
+
95
+ For an EPW inside Switzerland, `data_source="ch2025"` morphs from MeteoSwiss CH2025 station scenarios. There is no target year: each pathway is a global warming level relative to 1991-2020, and the run is offline.
96
+
97
+ ```python
98
+ results = workflow.morphing_workflow(
99
+ project_name="Zurich_GWL2",
100
+ epw_file="zurich.epw",
101
+ user_variables=["Temperature", "Humidity", "Wind", "Radiation", "Dew Point"],
102
+ user_pathways=["GWL 2.0"],
103
+ percentiles=[50],
104
+ output_directory="output/",
105
+ data_source="ch2025",
106
+ )
107
+ morphed = results["gwl2.0"]["50"]
108
+ ```
109
+
110
+ The site is matched to the nearest station that has the requested variables (or, with `ch2025_full_coverage=True`, the nearest station that has every CH2025 variable), with an elevation penalty so a much higher station is not chosen just because it is close on the map. Supported variables are temperature, humidity, dew point, wind, and radiation (global, diffuse, and direct). Pressure and cloud cover are not in CH2025; requesting them raises an error. Sky cover is left at the EPW's baseline values when radiation is morphed, so longwave sky temperature in EnergyPlus does not follow the shortwave change.
111
+
112
+ The signal is the change from the 1991-2020 reference climate to the chosen warming level. Each model chain's change is computed first, over the chains that reach that warming level, and the percentiles are taken of those changes. The EPW's period is read from the `Period of Record` in its header. If that is not exactly 1991-2020, a `UserWarning` recommends a TMY built from 1991-2020 data. If the header states no period, a second warning says the period could not be detected, and the mismatch warning still fires if the data rows' years fall outside 1991-2020. The signal is applied to the file as it stands either way. `tools.io.write_period_of_record` adds the statement to files built without one. The same caveats are listed in `MorphConfig.ch2025_notes` and the matched station in `MorphConfig.ch2025_station`.
113
+
114
+ CH2025 data: MeteoSwiss & ETH Zurich (2025), Climate CH2025 - Daily Datasets, CC-BY 4.0, https://doi.org/10.18751/climate/scenarios/ch2025/data/1.0/
115
+
92
116
  ## Climate scenarios
93
117
 
94
118
  | Scenario | SSP | Description | Expected warming |
@@ -104,15 +128,16 @@ The reference and target scenarios must cover **different years**, the same way
104
128
  - **Humidity** -- relative humidity, stretched in specific humidity space
105
129
  - **Pressure** -- atmospheric pressure (shift)
106
130
  - **Wind** -- wind speed (stretch)
107
- - **Clouds and Radiation** -- global/diffuse/direct radiation and sky cover
131
+ - **Radiation** -- global/diffuse/direct radiation, without changing sky cover (all sources; the only radiation option for CH2025)
132
+ - **Clouds and Radiation** -- global/diffuse/direct radiation and sky cover (CMIP6 and custom data)
108
133
  - **Dew Point** -- recalculated from morphed temperature and humidity
109
134
 
110
135
  ### Variable dependencies
111
136
 
112
137
  Some variables cannot be morphed on their own:
113
138
 
114
- - **Humidity** requires Temperature and Pressure
115
- - **Dew Point** requires Temperature, Humidity, and Pressure
139
+ - **Humidity** requires Temperature and Pressure, except with CH2025, where relative humidity is stretched directly
140
+ - **Dew Point** requires Temperature, Humidity, and Pressure (Temperature and Humidity only with CH2025)
116
141
 
117
142
  Dependencies are added automatically and are **written to the output file**. Asking for `Dew Point` alone therefore returns an EPW with morphed pressure, temperature, relative humidity, and dew point, which keeps the file internally consistent. `MorphConfig.resolved_variables` shows exactly what will be written, in the order it is computed.
118
143
 
@@ -181,7 +206,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full history. The most recent release c
181
206
 
182
207
  - Python >= 3.9
183
208
  - pandas >= 2.2
184
- - Internet connection (for CMIP6 data download; the custom CSV workflow runs offline)
209
+ - Internet connection (for CMIP6 data download; the custom CSV and CH2025 workflows run offline)
185
210
 
186
211
  ## License
187
212
 
@@ -0,0 +1 @@
1
+ """Shipped climate datasets."""
@@ -109,3 +109,27 @@ def calc_model_climatologies(baseline_range, future_range, baseline_data, future
109
109
 
110
110
 
111
111
  return baseline_means, future_means
112
+
113
+
114
+ def calc_gwl_climatologies(baseline_data, future_data, variable):
115
+ """Return CH2025 monthly climatologies unchanged.
116
+
117
+ A warming-level state is already a stationary 30-year sample, so there
118
+ is no year range to slice the way ``calc_model_climatologies`` does for
119
+ a transient CMIP6 run.
120
+
121
+ Parameters
122
+ ----------
123
+ baseline_data : pd.Series
124
+ Twelve monthly values for the reference state.
125
+ future_data : pd.Series
126
+ Twelve monthly values for the warming-level state.
127
+ variable : str
128
+ Name used to rename both series.
129
+
130
+ Returns
131
+ -------
132
+ tuple
133
+ ``(baseline_data, future_data)``, each renamed to *variable*.
134
+ """
135
+ return baseline_data.rename(variable), future_data.rename(variable)
@@ -0,0 +1,301 @@
1
+ """Offline access to MeteoSwiss CH2025 station scenarios.
2
+
3
+ The monthly climatologies in ``pyepwmorph/data`` are reduced from the
4
+ DAILY-LOCAL product. Each row is one model chain's mean for a calendar
5
+ month, over the 30 synthetic climate years of a reference state or a
6
+ global warming level.
7
+
8
+ CH2025 data © MeteoSwiss & ETH Zurich, licensed CC-BY 4.0.
9
+ https://doi.org/10.18751/climate/scenarios/ch2025/data/1.0/
10
+ """
11
+
12
+ import logging
13
+ from functools import lru_cache
14
+ from importlib.resources import files
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ #: (west, south, east, north) of the CH2025 gridded domain, in degrees.
22
+ CH2025_BBOX = (5.96, 45.82, 10.49, 47.81)
23
+
24
+ CH2025_REFERENCE = "ref91-20"
25
+ CH2025_BASELINE_RANGE = (1991, 2020)
26
+ CH2025_STATES = ("gwl1.5", "gwl2.0", "gwl2.5", "gwl3.0")
27
+ CH2025_VARIABLES = frozenset({"tas", "tasmax", "tasmin", "hurs", "rsds", "sfcWind"})
28
+
29
+ #: Variables whose change is a difference (future - reference). The others
30
+ #: (``hurs``, ``rsds``, ``sfcWind``) change as a ratio, matching how the
31
+ #: morphing procedures apply them.
32
+ CH2025_ADDITIVE_VARIABLES = frozenset({"tas", "tasmax", "tasmin"})
33
+
34
+ #: Short attribution written into morphed EPW comments. No commas: EPW
35
+ #: header fields are comma delimited.
36
+ CH2025_CITATION = (
37
+ "CH2025 (c) MeteoSwiss and ETH Zurich CC-BY 4.0 "
38
+ "doi:10.18751/climate/scenarios/ch2025/data/1.0"
39
+ )
40
+
41
+ #: Kilometres of horizontal distance treated as equal to 100 m of elevation,
42
+ #: so a station far below or above the site loses to a nearer-altitude one.
43
+ DEFAULT_ELEVATION_WEIGHT_KM_PER_100M = 10.0
44
+ WARN_DISTANCE_KM = 25.0
45
+ WARN_ELEVATION_M = 300.0
46
+
47
+
48
+ def _read_parquet(name: str) -> pd.DataFrame:
49
+ resource = files("pyepwmorph.data").joinpath(name)
50
+ with resource.open("rb") as handle:
51
+ return pd.read_parquet(handle)
52
+
53
+
54
+ @lru_cache(maxsize=1)
55
+ def load_table() -> pd.DataFrame:
56
+ """Return the shipped monthly climatology table.
57
+
58
+ Columns are ``station_id``, ``variable``, ``state``, ``chain``,
59
+ ``month``, and ``value``. The frame is cached; do not mutate it.
60
+ """
61
+ return _read_parquet("ch2025_monthly.parquet")
62
+
63
+
64
+ @lru_cache(maxsize=1)
65
+ def _load_stations() -> pd.DataFrame:
66
+ return _read_parquet("ch2025_stations.parquet")
67
+
68
+
69
+ def available_stations(variables=None) -> pd.DataFrame:
70
+ """Return stations, optionally limited to those carrying every variable.
71
+
72
+ Parameters
73
+ ----------
74
+ variables : iterable of str or None
75
+ Model variable ids that a station must provide. ``None`` returns
76
+ every station in the shipped table.
77
+ """
78
+ stations = _load_stations().copy()
79
+ if variables is None:
80
+ return stations.reset_index(drop=True)
81
+ needed = set(variables)
82
+ mask = stations["variables"].map(lambda present: needed <= set(present))
83
+ return stations.loc[mask].reset_index(drop=True)
84
+
85
+
86
+ def reference_key(state: str) -> str:
87
+ """Key under which the reference paired with *state* is stored.
88
+
89
+ Each warming level is compared with the reference over the model chains
90
+ the two share, so the reference climatology differs per warming level.
91
+ """
92
+ return f"{CH2025_REFERENCE}|{state}"
93
+
94
+
95
+ def baseline_matches(baseline_range) -> bool:
96
+ """Return whether an EPW baseline is exactly the CH2025 reference period."""
97
+ return tuple(int(year) for year in baseline_range) == CH2025_BASELINE_RANGE
98
+
99
+
100
+ def in_switzerland(latitude: float, longitude: float) -> bool:
101
+ """Return whether a point lies inside the CH2025 domain."""
102
+ west, south, east, north = CH2025_BBOX
103
+ return south <= latitude <= north and west <= longitude <= east
104
+
105
+
106
+ def _haversine_km(latitude, longitude, latitudes, longitudes) -> np.ndarray:
107
+ radius = 6371.0
108
+ lat1 = np.radians(latitude)
109
+ lat2 = np.radians(np.asarray(latitudes, dtype=float))
110
+ dlat = lat2 - lat1
111
+ dlon = np.radians(np.asarray(longitudes, dtype=float) - longitude)
112
+ a = np.sin(dlat / 2.0) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2.0) ** 2
113
+ return 2.0 * radius * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0)))
114
+
115
+
116
+ def nearest_station(
117
+ latitude: float,
118
+ longitude: float,
119
+ elevation: float,
120
+ variables=None,
121
+ elevation_weight_km_per_100m: float = DEFAULT_ELEVATION_WEIGHT_KM_PER_100M,
122
+ ) -> pd.Series:
123
+ """Pick the station that best matches a site.
124
+
125
+ The score is horizontal distance plus an elevation penalty, so a
126
+ station hundreds of metres higher loses to one farther away at a
127
+ similar altitude. Only stations that carry every requested variable
128
+ are eligible.
129
+
130
+ Returns
131
+ -------
132
+ pd.Series
133
+ The station row, with ``distance_km`` and ``elevation_difference_m``.
134
+ """
135
+ candidates = available_stations(variables)
136
+ if candidates.empty:
137
+ needed = ", ".join(sorted(variables or []))
138
+ raise ValueError(f"No CH2025 station provides {needed or 'any variables'}")
139
+
140
+ distance = _haversine_km(
141
+ latitude, longitude, candidates["latitude"], candidates["longitude"],
142
+ )
143
+ elevation_difference = candidates["elevation"].to_numpy(dtype=float) - float(elevation)
144
+ penalty = np.abs(elevation_difference) / 100.0 * elevation_weight_km_per_100m
145
+ position = int(np.argmin(distance + penalty))
146
+ chosen = candidates.iloc[position].copy()
147
+ chosen["distance_km"] = float(distance[position])
148
+ chosen["elevation_difference_m"] = float(elevation_difference[position])
149
+ chosen["weak_match"] = bool(
150
+ chosen["distance_km"] > WARN_DISTANCE_KM
151
+ or abs(chosen["elevation_difference_m"]) > WARN_ELEVATION_M
152
+ )
153
+
154
+ logger.info(
155
+ "CH2025 station %s (%s): %.1f km away, %+.0f m elevation",
156
+ chosen["station_id"],
157
+ chosen["name"],
158
+ chosen["distance_km"],
159
+ chosen["elevation_difference_m"],
160
+ )
161
+ if chosen["weak_match"]:
162
+ logger.warning(
163
+ "CH2025 station %s is a weak match (%.1f km, %+.0f m). "
164
+ "The morphed file uses that station's climate signal.",
165
+ chosen["station_id"],
166
+ chosen["distance_km"],
167
+ chosen["elevation_difference_m"],
168
+ )
169
+ return chosen
170
+
171
+
172
+ def _check_variable(variable):
173
+ if variable not in CH2025_VARIABLES:
174
+ raise ValueError(
175
+ f"Variable '{variable}' is not in the CH2025 station table. "
176
+ f"Available: {sorted(CH2025_VARIABLES)}"
177
+ )
178
+
179
+
180
+ def _chains_by_month(station_id, variable, state) -> pd.DataFrame:
181
+ """Return a month x chain table for one station, variable, and state."""
182
+ table = load_table()
183
+ subset = table[
184
+ (table["station_id"] == station_id)
185
+ & (table["variable"] == variable)
186
+ & (table["state"] == state)
187
+ ]
188
+ if subset.empty:
189
+ raise ValueError(
190
+ f"No CH2025 data for station '{station_id}', variable '{variable}', state '{state}'"
191
+ )
192
+ return subset.pivot(index="month", columns="chain", values="value").sort_index()
193
+
194
+
195
+ def build_ch2025_change_ensemble(percentiles, variable, station_id, state):
196
+ """Build reference and warming-level climatologies from paired chain changes.
197
+
198
+ Each model chain's change from the reference to *state* is computed
199
+ first, over the chains present in both, and the percentiles are taken
200
+ of those changes. Taking percentiles of each state separately and
201
+ differencing them would mix chains, and at the tails can even flip the
202
+ sign of the change.
203
+
204
+ The reference returned is the median of the paired chains, the same for
205
+ every percentile column. The warming-level frame is that reference plus
206
+ the percentile change (temperatures) or times the percentile ratio
207
+ (humidity, radiation, wind), so the morphing procedures recover exactly
208
+ the percentile change.
209
+
210
+ Parameters
211
+ ----------
212
+ percentiles : list
213
+ Ensemble percentiles. Column labels match these values.
214
+ variable : str
215
+ One of ``CH2025_VARIABLES``.
216
+ station_id : str
217
+ Lower-case station abbreviation, for example ``"sma"``.
218
+ state : str
219
+ A warming level such as ``"gwl2.0"``.
220
+
221
+ Returns
222
+ -------
223
+ tuple[pd.DataFrame, pd.DataFrame]
224
+ ``(reference, future)``, each with twelve rows (months 1-12) and one
225
+ column per percentile. ``attrs["n_chains"]`` on both records how many
226
+ paired chains contributed.
227
+ """
228
+ _check_variable(variable)
229
+ reference = _chains_by_month(station_id, variable, CH2025_REFERENCE)
230
+ future = _chains_by_month(station_id, variable, state)
231
+ common = reference.columns.intersection(future.columns)
232
+ if len(common) == 0:
233
+ raise ValueError(
234
+ f"No CH2025 model chain provides both {CH2025_REFERENCE} and {state} "
235
+ f"for station '{station_id}', variable '{variable}'"
236
+ )
237
+ reference = reference[common]
238
+ future = future[common]
239
+ baseline = reference.median(axis=1)
240
+
241
+ if variable in CH2025_ADDITIVE_VARIABLES:
242
+ change = future - reference
243
+ else:
244
+ ref_values = reference.to_numpy(dtype=float)
245
+ change = pd.DataFrame(
246
+ np.divide(
247
+ future.to_numpy(dtype=float), ref_values,
248
+ out=np.ones_like(ref_values), where=ref_values != 0,
249
+ ),
250
+ index=reference.index, columns=reference.columns,
251
+ )
252
+
253
+ reference_frame = pd.DataFrame({percentile: baseline for percentile in percentiles})
254
+ future_columns = {}
255
+ for percentile in percentiles:
256
+ quantile = change.quantile(int(percentile) / 100.0, axis=1)
257
+ if variable in CH2025_ADDITIVE_VARIABLES:
258
+ future_columns[percentile] = baseline + quantile
259
+ else:
260
+ future_columns[percentile] = baseline * quantile
261
+ future_frame = pd.DataFrame(future_columns)
262
+
263
+ for frame in (reference_frame, future_frame):
264
+ frame.index.name = "month"
265
+ frame.attrs["n_chains"] = int(len(common))
266
+ return reference_frame, future_frame
267
+
268
+
269
+ def build_ch2025_ensemble(percentiles, variable, station_id, state) -> pd.DataFrame:
270
+ """Reduce one station, variable, and state to percentile climatologies.
271
+
272
+ Parameters
273
+ ----------
274
+ percentiles : list
275
+ Ensemble percentiles. Column labels match these values, as in
276
+ ``assemble.build_cmip6_ensemble``.
277
+ variable : str
278
+ One of ``CH2025_VARIABLES``.
279
+ station_id : str
280
+ Lower-case station abbreviation, for example ``"sma"``.
281
+ state : str
282
+ ``"ref91-20"`` or a warming level such as ``"gwl2.0"``.
283
+
284
+ Returns
285
+ -------
286
+ pd.DataFrame
287
+ Twelve rows (months 1-12) and one column per percentile.
288
+ ``attrs["n_chains"]`` records how many model chains contributed.
289
+ Membership differs by variable and warming level.
290
+
291
+ Morphing does not use this: differencing two separately reduced states
292
+ mixes model chains. See ``build_ch2025_change_ensemble``.
293
+ """
294
+ _check_variable(variable)
295
+ wide = _chains_by_month(station_id, variable, state)
296
+ result = pd.DataFrame(
297
+ {percentile: wide.quantile(int(percentile) / 100.0, axis=1) for percentile in percentiles}
298
+ )
299
+ result.index.name = "month"
300
+ result.attrs["n_chains"] = int(wide.shape[1])
301
+ return result
@@ -7,6 +7,8 @@ Container for all of the individual morphing calculations which can be traced ba
7
7
  55 514–524 ISSN 0960-1481 URL https://www.sciencedirect.com/science/article/pii/S0960148113000232
8
8
 
9
9
  """
10
+ import calendar
11
+
10
12
  import numpy as np
11
13
  import pandas as pd
12
14
 
@@ -33,10 +35,17 @@ def _index_of(*candidates):
33
35
 
34
36
 
35
37
  def _year_of(index):
36
- """Return the year of a DatetimeIndex, or the default solar year."""
38
+ """Return a non-leap year to build solar geometry for the data in *index*.
39
+
40
+ EPW files always hold 8760 hours with no 29 February, so their calendar
41
+ is a non-leap year's even when the first data row says 2016. Building
42
+ daily series for a leap year would add an empty 29 February and shift
43
+ every later day by one, so a leap year is replaced by the year before.
44
+ """
37
45
  if index is None or len(index) == 0:
38
46
  return morph_solar_utils.DEFAULT_SOLAR_YEAR
39
- return int(index[0].year)
47
+ year = int(index[0].year)
48
+ return year - 1 if calendar.isleap(year) else year
40
49
 
41
50
 
42
51
  def _as_series(values, index, name, decimals=2):
@@ -221,6 +230,34 @@ def morph_relhum(present_relhum, present_psl, present_dbt, future_psl, future_db
221
230
  return _as_series(np.clip(morphed_relhum, 1, 100), index, "relhum_percent")
222
231
 
223
232
 
233
+ def morph_relhum_direct(present_relhum, future_hurs, baseline_hurs):
234
+ """Stretch relative humidity by the modelled relative-humidity ratio.
235
+
236
+ CH2025 provides ``hurs`` directly, so the specific-humidity round trip
237
+ used by ``morph_relhum`` is not needed.
238
+
239
+ Parameters
240
+ ----------
241
+ present_relhum : pd.Series
242
+ Hourly present-day relative humidity in percent.
243
+ future_hurs : pd.Series
244
+ Twelve monthly future relative-humidity values.
245
+ baseline_hurs : pd.Series
246
+ Twelve monthly baseline relative-humidity values.
247
+
248
+ Returns
249
+ -------
250
+ pd.Series
251
+ Morphed relative humidity, clipped to ``[1, 100]``.
252
+ """
253
+ index = present_relhum.index
254
+ ratio = morph_utils.relative_delta(
255
+ morph_utils.as_array(future_hurs, 12), morph_utils.as_array(baseline_hurs, 12),
256
+ )
257
+ morphed = stretch(present_relhum.to_numpy(dtype=float), morph_utils.month_factors(index, ratio))
258
+ return _as_series(np.clip(morphed, 1, 100), index, "relhum_percent")
259
+
260
+
224
261
  def morph_psl(present_psl, future_psl, baseline_psl):
225
262
  """
226
263
  Pressure at sea level morph requires a shift
@@ -327,6 +364,31 @@ def morph_wspd(present_wspd, future_vas, baseline_vas, future_uas, baseline_uas)
327
364
  return _as_series(np.clip(morphed_wspd, 0, None), index, "windspd_ms")
328
365
 
329
366
 
367
+ def morph_wspd_direct(present_wspd, future_sfcwind, baseline_sfcwind):
368
+ """Stretch wind speed by a scalar surface-wind ratio.
369
+
370
+ Parameters
371
+ ----------
372
+ present_wspd : pd.Series
373
+ Hourly present-day wind speed in m/s.
374
+ future_sfcwind : pd.Series
375
+ Twelve monthly future surface wind speeds.
376
+ baseline_sfcwind : pd.Series
377
+ Twelve monthly baseline surface wind speeds.
378
+
379
+ Returns
380
+ -------
381
+ pd.Series
382
+ Morphed wind speed, clipped at zero.
383
+ """
384
+ index = present_wspd.index
385
+ ratio = morph_utils.relative_delta(
386
+ morph_utils.as_array(future_sfcwind, 12), morph_utils.as_array(baseline_sfcwind, 12),
387
+ )
388
+ morphed = stretch(present_wspd.to_numpy(dtype=float), morph_utils.month_factors(index, ratio))
389
+ return _as_series(np.clip(morphed, 0, None), index, "windspd_ms")
390
+
391
+
330
392
  def morph_glohor(present_glohor, future_glohor, baseline_glohor):
331
393
  """
332
394
  Global horizontal radiation morph requires a stretch