pyepwmorph 3.0.0__tar.gz → 3.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.
Files changed (25) hide show
  1. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/PKG-INFO +30 -5
  2. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/README.md +29 -4
  3. pyepwmorph-3.1.0/pyepwmorph/data/__init__.py +1 -0
  4. pyepwmorph-3.1.0/pyepwmorph/data/ch2025_monthly.parquet +0 -0
  5. pyepwmorph-3.1.0/pyepwmorph/data/ch2025_stations.parquet +0 -0
  6. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/assemble.py +24 -0
  7. pyepwmorph-3.1.0/pyepwmorph/models/ch2025.py +303 -0
  8. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/morph/procedures.py +53 -0
  9. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/configuration.py +164 -10
  10. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/workflow.py +175 -24
  11. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyproject.toml +3 -2
  12. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/.gitignore +0 -0
  13. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/LICENSE +0 -0
  14. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/__init__.py +0 -0
  15. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/__init__.py +0 -0
  16. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/access.py +0 -0
  17. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/coordinate.py +0 -0
  18. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/custom.py +0 -0
  19. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/morph/__init__.py +0 -0
  20. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/__init__.py +0 -0
  21. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/cache.py +0 -0
  22. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/io.py +0 -0
  23. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/psychrometrics.py +0 -0
  24. {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/solar.py +0 -0
  25. {pyepwmorph-3.0.0 → pyepwmorph-3.1.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.1.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. If the EPW's years fall outside 1991-2020, a `UserWarning` recommends a TMY built from 1991-2020 data and the signal is still applied to the file as it stands. 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. If the EPW's years fall outside 1991-2020, a `UserWarning` recommends a TMY built from 1991-2020 data and the signal is still applied to the file as it stands. 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,303 @@
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 lies inside the CH2025 reference period."""
97
+ start, end = (int(year) for year in baseline_range)
98
+ ref_start, ref_end = CH2025_BASELINE_RANGE
99
+ return ref_start <= start and end <= ref_end
100
+
101
+
102
+ def in_switzerland(latitude: float, longitude: float) -> bool:
103
+ """Return whether a point lies inside the CH2025 domain."""
104
+ west, south, east, north = CH2025_BBOX
105
+ return south <= latitude <= north and west <= longitude <= east
106
+
107
+
108
+ def _haversine_km(latitude, longitude, latitudes, longitudes) -> np.ndarray:
109
+ radius = 6371.0
110
+ lat1 = np.radians(latitude)
111
+ lat2 = np.radians(np.asarray(latitudes, dtype=float))
112
+ dlat = lat2 - lat1
113
+ dlon = np.radians(np.asarray(longitudes, dtype=float) - longitude)
114
+ a = np.sin(dlat / 2.0) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2.0) ** 2
115
+ return 2.0 * radius * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0)))
116
+
117
+
118
+ def nearest_station(
119
+ latitude: float,
120
+ longitude: float,
121
+ elevation: float,
122
+ variables=None,
123
+ elevation_weight_km_per_100m: float = DEFAULT_ELEVATION_WEIGHT_KM_PER_100M,
124
+ ) -> pd.Series:
125
+ """Pick the station that best matches a site.
126
+
127
+ The score is horizontal distance plus an elevation penalty, so a
128
+ station hundreds of metres higher loses to one farther away at a
129
+ similar altitude. Only stations that carry every requested variable
130
+ are eligible.
131
+
132
+ Returns
133
+ -------
134
+ pd.Series
135
+ The station row, with ``distance_km`` and ``elevation_difference_m``.
136
+ """
137
+ candidates = available_stations(variables)
138
+ if candidates.empty:
139
+ needed = ", ".join(sorted(variables or []))
140
+ raise ValueError(f"No CH2025 station provides {needed or 'any variables'}")
141
+
142
+ distance = _haversine_km(
143
+ latitude, longitude, candidates["latitude"], candidates["longitude"],
144
+ )
145
+ elevation_difference = candidates["elevation"].to_numpy(dtype=float) - float(elevation)
146
+ penalty = np.abs(elevation_difference) / 100.0 * elevation_weight_km_per_100m
147
+ position = int(np.argmin(distance + penalty))
148
+ chosen = candidates.iloc[position].copy()
149
+ chosen["distance_km"] = float(distance[position])
150
+ chosen["elevation_difference_m"] = float(elevation_difference[position])
151
+ chosen["weak_match"] = bool(
152
+ chosen["distance_km"] > WARN_DISTANCE_KM
153
+ or abs(chosen["elevation_difference_m"]) > WARN_ELEVATION_M
154
+ )
155
+
156
+ logger.info(
157
+ "CH2025 station %s (%s): %.1f km away, %+.0f m elevation",
158
+ chosen["station_id"],
159
+ chosen["name"],
160
+ chosen["distance_km"],
161
+ chosen["elevation_difference_m"],
162
+ )
163
+ if chosen["weak_match"]:
164
+ logger.warning(
165
+ "CH2025 station %s is a weak match (%.1f km, %+.0f m). "
166
+ "The morphed file uses that station's climate signal.",
167
+ chosen["station_id"],
168
+ chosen["distance_km"],
169
+ chosen["elevation_difference_m"],
170
+ )
171
+ return chosen
172
+
173
+
174
+ def _check_variable(variable):
175
+ if variable not in CH2025_VARIABLES:
176
+ raise ValueError(
177
+ f"Variable '{variable}' is not in the CH2025 station table. "
178
+ f"Available: {sorted(CH2025_VARIABLES)}"
179
+ )
180
+
181
+
182
+ def _chains_by_month(station_id, variable, state) -> pd.DataFrame:
183
+ """Return a month x chain table for one station, variable, and state."""
184
+ table = load_table()
185
+ subset = table[
186
+ (table["station_id"] == station_id)
187
+ & (table["variable"] == variable)
188
+ & (table["state"] == state)
189
+ ]
190
+ if subset.empty:
191
+ raise ValueError(
192
+ f"No CH2025 data for station '{station_id}', variable '{variable}', state '{state}'"
193
+ )
194
+ return subset.pivot(index="month", columns="chain", values="value").sort_index()
195
+
196
+
197
+ def build_ch2025_change_ensemble(percentiles, variable, station_id, state):
198
+ """Build reference and warming-level climatologies from paired chain changes.
199
+
200
+ Each model chain's change from the reference to *state* is computed
201
+ first, over the chains present in both, and the percentiles are taken
202
+ of those changes. Taking percentiles of each state separately and
203
+ differencing them would mix chains, and at the tails can even flip the
204
+ sign of the change.
205
+
206
+ The reference returned is the median of the paired chains, the same for
207
+ every percentile column. The warming-level frame is that reference plus
208
+ the percentile change (temperatures) or times the percentile ratio
209
+ (humidity, radiation, wind), so the morphing procedures recover exactly
210
+ the percentile change.
211
+
212
+ Parameters
213
+ ----------
214
+ percentiles : list
215
+ Ensemble percentiles. Column labels match these values.
216
+ variable : str
217
+ One of ``CH2025_VARIABLES``.
218
+ station_id : str
219
+ Lower-case station abbreviation, for example ``"sma"``.
220
+ state : str
221
+ A warming level such as ``"gwl2.0"``.
222
+
223
+ Returns
224
+ -------
225
+ tuple[pd.DataFrame, pd.DataFrame]
226
+ ``(reference, future)``, each with twelve rows (months 1-12) and one
227
+ column per percentile. ``attrs["n_chains"]`` on both records how many
228
+ paired chains contributed.
229
+ """
230
+ _check_variable(variable)
231
+ reference = _chains_by_month(station_id, variable, CH2025_REFERENCE)
232
+ future = _chains_by_month(station_id, variable, state)
233
+ common = reference.columns.intersection(future.columns)
234
+ if len(common) == 0:
235
+ raise ValueError(
236
+ f"No CH2025 model chain provides both {CH2025_REFERENCE} and {state} "
237
+ f"for station '{station_id}', variable '{variable}'"
238
+ )
239
+ reference = reference[common]
240
+ future = future[common]
241
+ baseline = reference.median(axis=1)
242
+
243
+ if variable in CH2025_ADDITIVE_VARIABLES:
244
+ change = future - reference
245
+ else:
246
+ ref_values = reference.to_numpy(dtype=float)
247
+ change = pd.DataFrame(
248
+ np.divide(
249
+ future.to_numpy(dtype=float), ref_values,
250
+ out=np.ones_like(ref_values), where=ref_values != 0,
251
+ ),
252
+ index=reference.index, columns=reference.columns,
253
+ )
254
+
255
+ reference_frame = pd.DataFrame({percentile: baseline for percentile in percentiles})
256
+ future_columns = {}
257
+ for percentile in percentiles:
258
+ quantile = change.quantile(int(percentile) / 100.0, axis=1)
259
+ if variable in CH2025_ADDITIVE_VARIABLES:
260
+ future_columns[percentile] = baseline + quantile
261
+ else:
262
+ future_columns[percentile] = baseline * quantile
263
+ future_frame = pd.DataFrame(future_columns)
264
+
265
+ for frame in (reference_frame, future_frame):
266
+ frame.index.name = "month"
267
+ frame.attrs["n_chains"] = int(len(common))
268
+ return reference_frame, future_frame
269
+
270
+
271
+ def build_ch2025_ensemble(percentiles, variable, station_id, state) -> pd.DataFrame:
272
+ """Reduce one station, variable, and state to percentile climatologies.
273
+
274
+ Parameters
275
+ ----------
276
+ percentiles : list
277
+ Ensemble percentiles. Column labels match these values, as in
278
+ ``assemble.build_cmip6_ensemble``.
279
+ variable : str
280
+ One of ``CH2025_VARIABLES``.
281
+ station_id : str
282
+ Lower-case station abbreviation, for example ``"sma"``.
283
+ state : str
284
+ ``"ref91-20"`` or a warming level such as ``"gwl2.0"``.
285
+
286
+ Returns
287
+ -------
288
+ pd.DataFrame
289
+ Twelve rows (months 1-12) and one column per percentile.
290
+ ``attrs["n_chains"]`` records how many model chains contributed.
291
+ Membership differs by variable and warming level.
292
+
293
+ Morphing does not use this: differencing two separately reduced states
294
+ mixes model chains. See ``build_ch2025_change_ensemble``.
295
+ """
296
+ _check_variable(variable)
297
+ wide = _chains_by_month(station_id, variable, state)
298
+ result = pd.DataFrame(
299
+ {percentile: wide.quantile(int(percentile) / 100.0, axis=1) for percentile in percentiles}
300
+ )
301
+ result.index.name = "month"
302
+ result.attrs["n_chains"] = int(wide.shape[1])
303
+ return result
@@ -221,6 +221,34 @@ def morph_relhum(present_relhum, present_psl, present_dbt, future_psl, future_db
221
221
  return _as_series(np.clip(morphed_relhum, 1, 100), index, "relhum_percent")
222
222
 
223
223
 
224
+ def morph_relhum_direct(present_relhum, future_hurs, baseline_hurs):
225
+ """Stretch relative humidity by the modelled relative-humidity ratio.
226
+
227
+ CH2025 provides ``hurs`` directly, so the specific-humidity round trip
228
+ used by ``morph_relhum`` is not needed.
229
+
230
+ Parameters
231
+ ----------
232
+ present_relhum : pd.Series
233
+ Hourly present-day relative humidity in percent.
234
+ future_hurs : pd.Series
235
+ Twelve monthly future relative-humidity values.
236
+ baseline_hurs : pd.Series
237
+ Twelve monthly baseline relative-humidity values.
238
+
239
+ Returns
240
+ -------
241
+ pd.Series
242
+ Morphed relative humidity, clipped to ``[1, 100]``.
243
+ """
244
+ index = present_relhum.index
245
+ ratio = morph_utils.relative_delta(
246
+ morph_utils.as_array(future_hurs, 12), morph_utils.as_array(baseline_hurs, 12),
247
+ )
248
+ morphed = stretch(present_relhum.to_numpy(dtype=float), morph_utils.month_factors(index, ratio))
249
+ return _as_series(np.clip(morphed, 1, 100), index, "relhum_percent")
250
+
251
+
224
252
  def morph_psl(present_psl, future_psl, baseline_psl):
225
253
  """
226
254
  Pressure at sea level morph requires a shift
@@ -327,6 +355,31 @@ def morph_wspd(present_wspd, future_vas, baseline_vas, future_uas, baseline_uas)
327
355
  return _as_series(np.clip(morphed_wspd, 0, None), index, "windspd_ms")
328
356
 
329
357
 
358
+ def morph_wspd_direct(present_wspd, future_sfcwind, baseline_sfcwind):
359
+ """Stretch wind speed by a scalar surface-wind ratio.
360
+
361
+ Parameters
362
+ ----------
363
+ present_wspd : pd.Series
364
+ Hourly present-day wind speed in m/s.
365
+ future_sfcwind : pd.Series
366
+ Twelve monthly future surface wind speeds.
367
+ baseline_sfcwind : pd.Series
368
+ Twelve monthly baseline surface wind speeds.
369
+
370
+ Returns
371
+ -------
372
+ pd.Series
373
+ Morphed wind speed, clipped at zero.
374
+ """
375
+ index = present_wspd.index
376
+ ratio = morph_utils.relative_delta(
377
+ morph_utils.as_array(future_sfcwind, 12), morph_utils.as_array(baseline_sfcwind, 12),
378
+ )
379
+ morphed = stretch(present_wspd.to_numpy(dtype=float), morph_utils.month_factors(index, ratio))
380
+ return _as_series(np.clip(morphed, 0, None), index, "windspd_ms")
381
+
382
+
330
383
  def morph_glohor(present_glohor, future_glohor, baseline_glohor):
331
384
  """
332
385
  Global horizontal radiation morph requires a stretch
@@ -28,21 +28,55 @@ VARIABLE_MAPPING = {
28
28
  'Humidity': ['huss'],
29
29
  'Pressure': ['psl'],
30
30
  'Wind': ['uas', 'vas'],
31
+ 'Radiation': ['rsds'],
31
32
  'Clouds and Radiation': ['clt', 'rsds'],
32
33
  }
33
34
 
35
+ #: CH2025 station scenarios carry relative humidity and scalar wind speed
36
+ #: directly, and they do not include pressure or cloud cover.
37
+ CH2025_PATHWAY_MAP = {
38
+ 'GWL 1.5': 'gwl1.5',
39
+ 'GWL 2.0': 'gwl2.0',
40
+ 'GWL 2.5': 'gwl2.5',
41
+ 'GWL 3.0': 'gwl3.0',
42
+ }
43
+
44
+ CH2025_VARIABLE_MAPPING = {
45
+ 'Temperature': ['tas', 'tasmax', 'tasmin'],
46
+ 'Humidity': ['hurs'],
47
+ 'Wind': ['sfcWind'],
48
+ 'Radiation': ['rsds'],
49
+ 'Dew Point': [],
50
+ }
51
+
52
+ CH2025_UNSUPPORTED = frozenset({'Pressure', 'Clouds and Radiation'})
53
+
34
54
  VARIABLE_DEPENDENCIES = {
35
55
  'Humidity': ['Temperature', 'Pressure'],
36
56
  'Dew Point': ['Temperature', 'Humidity', 'Pressure'],
37
57
  }
38
58
 
59
+ CH2025_VARIABLE_DEPENDENCIES = {
60
+ 'Dew Point': ['Temperature', 'Humidity'],
61
+ }
62
+
39
63
  #: The order variables must be morphed in so that every variable sees its
40
64
  #: dependencies already morphed. Pressure and temperature feed humidity,
41
- #: which in turn feeds dew point.
42
- MORPH_ORDER = ['Pressure', 'Temperature', 'Humidity', 'Dew Point', 'Wind', 'Clouds and Radiation']
65
+ #: which in turn feeds dew point. ``Radiation`` morphs the solar fields
66
+ #: without touching sky cover.
67
+ MORPH_ORDER = [
68
+ 'Pressure', 'Temperature', 'Humidity', 'Dew Point', 'Wind',
69
+ 'Radiation', 'Clouds and Radiation',
70
+ ]
43
71
 
44
72
 
45
- def resolve_variable_order(user_variables):
73
+ def _dependencies_for(data_source):
74
+ if data_source == "ch2025":
75
+ return CH2025_VARIABLE_DEPENDENCIES
76
+ return VARIABLE_DEPENDENCIES
77
+
78
+
79
+ def resolve_variable_order(user_variables, data_source="cmip6"):
46
80
  """Expand variable dependencies and return them in a safe morphing order.
47
81
 
48
82
  Dependencies are pulled in transitively, so asking for ``Dew Point``
@@ -50,16 +84,23 @@ def resolve_variable_order(user_variables):
50
84
  resolved variable is written to the morphed EPW, which keeps the output
51
85
  file internally consistent.
52
86
 
87
+ For ``data_source="ch2025"`` humidity is a direct relative-humidity
88
+ stretch, so it has no dependencies, and dew point needs only temperature
89
+ and humidity.
90
+
53
91
  Parameters
54
92
  ----------
55
93
  user_variables : list[str]
56
94
  The variables the caller asked for.
95
+ data_source : str
96
+ ``"cmip6"`` (default), ``"custom"``, or ``"ch2025"``.
57
97
 
58
98
  Returns
59
99
  -------
60
100
  list[str]
61
101
  Supported variables, deduplicated and ordered for morphing.
62
102
  """
103
+ dependencies = _dependencies_for(data_source)
63
104
  resolved = set()
64
105
  pending = list(user_variables)
65
106
  while pending:
@@ -67,7 +108,7 @@ def resolve_variable_order(user_variables):
67
108
  if variable in resolved:
68
109
  continue
69
110
  resolved.add(variable)
70
- pending.extend(VARIABLE_DEPENDENCIES.get(variable, []))
111
+ pending.extend(dependencies.get(variable, []))
71
112
 
72
113
  added = resolved - set(user_variables)
73
114
  if added:
@@ -107,14 +148,29 @@ class MorphConfig:
107
148
  ``(start_year, end_year)`` for the baseline period. If *None*,
108
149
  derived from the EPW file.
109
150
  data_source : str
110
- ``"cmip6"`` (default) or ``"custom"``.
151
+ ``"cmip6"`` (default), ``"custom"``, or ``"ch2025"``.
111
152
  custom_data : dict or None
112
153
  When *data_source* is ``"custom"``, a nested dict:
113
154
  ``{scenario_label: {variable: csv_path, ...}, ...}``.
114
155
  Must include a ``"reference"`` scenario and at least one target.
115
156
  reference_scenario : str or None
116
157
  Which scenario key to treat as the baseline/reference.
117
- Defaults to ``"historical"`` for CMIP6, ``"reference"`` for custom.
158
+ Defaults to ``"historical"`` for CMIP6, ``"reference"`` for custom,
159
+ and ``"ref91-20"`` for CH2025.
160
+ ch2025_full_coverage : bool
161
+ CH2025 only. When True, match the site only against stations that
162
+ carry every CH2025 variable, so the station does not change with the
163
+ variables requested. Default False matches against stations that
164
+ carry the requested variables.
165
+
166
+ Attributes
167
+ ----------
168
+ ch2025_station : pd.Series or None
169
+ CH2025 only. The matched station row, including ``distance_km``,
170
+ ``elevation_difference_m`` and ``weak_match``.
171
+ ch2025_notes : list[str]
172
+ CH2025 only. Plain-language caveats about this morph (baseline
173
+ mismatch, weak station match), for display to users.
118
174
 
119
175
  Backward Compatibility
120
176
  ----------------------
@@ -136,6 +192,7 @@ class MorphConfig:
136
192
  data_source="cmip6",
137
193
  custom_data=None,
138
194
  reference_scenario=None,
195
+ ch2025_full_coverage=False,
139
196
  # backward-compat alias
140
197
  future_years=None,
141
198
  ):
@@ -146,8 +203,10 @@ class MorphConfig:
146
203
  stacklevel=2,
147
204
  )
148
205
  target_years = future_years
149
- if target_years is None:
206
+ if target_years is None and data_source != "ch2025":
150
207
  raise ValueError("target_years (or future_years) must be provided")
208
+ if data_source == "ch2025" and target_years is None:
209
+ target_years = []
151
210
 
152
211
  if model_sources is None:
153
212
  model_sources = ['ACCESS-CM2', 'CanESM5', 'TaiESM1']
@@ -184,12 +243,17 @@ class MorphConfig:
184
243
  self.reference_scenario = reference_scenario
185
244
  elif data_source == "custom":
186
245
  self.reference_scenario = "reference"
246
+ elif data_source == "ch2025":
247
+ self.reference_scenario = "ref91-20"
187
248
  else:
188
249
  self.reference_scenario = "historical"
189
250
 
190
251
  self.model_pathways: list[str] = []
191
252
  self.model_variables: list[str] = []
192
253
  self.resolved_variables: list[str] = []
254
+ self.ch2025_station = None
255
+ self.ch2025_notes: list[str] = []
256
+ self.ch2025_full_coverage = bool(ch2025_full_coverage)
193
257
 
194
258
  if output_directory is not None:
195
259
  self.output_directory = output_directory
@@ -199,6 +263,7 @@ class MorphConfig:
199
263
 
200
264
  self.assign_from_epw()
201
265
  self.assign_model_variables()
266
+ self.assign_ch2025_station()
202
267
  self.assign_model_pathways()
203
268
 
204
269
  def assign_from_epw(self):
@@ -209,23 +274,98 @@ class MorphConfig:
209
274
  if self.baseline_range is None:
210
275
  self.baseline_range = self.epw.detect_baseline_range()
211
276
 
277
+ if self.data_source == "ch2025":
278
+ from pyepwmorph.models.ch2025 import (
279
+ CH2025_BASELINE_RANGE,
280
+ CH2025_BBOX,
281
+ baseline_matches,
282
+ in_switzerland,
283
+ )
284
+
285
+ latitude = self.location['latitude']
286
+ longitude = self.location['longitude']
287
+ if not in_switzerland(latitude, longitude):
288
+ west, south, east, north = CH2025_BBOX
289
+ raise ValueError(
290
+ f"EPW location ({latitude}, {longitude}) is outside Switzerland. "
291
+ f"CH2025 covers longitude {west} to {east} and latitude {south} to {north}. "
292
+ f"Use data_source='cmip6' for locations outside that domain."
293
+ )
294
+ if not baseline_matches(self.baseline_range):
295
+ start, end = (int(year) for year in self.baseline_range)
296
+ ref_start, ref_end = CH2025_BASELINE_RANGE
297
+ if end > ref_end:
298
+ effect = (
299
+ f"Years after {ref_end} already contain part of that warming, "
300
+ f"so the morphed file may overstate it."
301
+ )
302
+ else:
303
+ effect = (
304
+ f"Years before {ref_start} were cooler, so the morphed file may "
305
+ f"understate the warming."
306
+ )
307
+ note = (
308
+ f"The EPW covers {start}-{end} but CH2025 changes are measured from "
309
+ f"{ref_start}-{ref_end}. {effect} For CH2025, use a TMY built from "
310
+ f"{ref_start}-{ref_end} data."
311
+ )
312
+ self.ch2025_notes.append(note)
313
+ warnings.warn(note, UserWarning, stacklevel=2)
314
+
212
315
  def assign_model_variables(self):
213
- """Resolve user-facing variable names to CMIP6-style variable IDs."""
214
- self.resolved_variables = resolve_variable_order(self.user_variables)
316
+ """Resolve user-facing variable names to model variable IDs."""
317
+ if self.data_source == "ch2025":
318
+ blocked = set(self.user_variables) & CH2025_UNSUPPORTED
319
+ if blocked:
320
+ names = ", ".join(sorted(blocked))
321
+ raise ValueError(
322
+ f"CH2025 station scenarios do not include {names}. "
323
+ f"Use data_source='cmip6' for pressure and cloud cover, "
324
+ f"or request Radiation instead of Clouds and Radiation."
325
+ )
326
+ mapping = CH2025_VARIABLE_MAPPING
327
+ else:
328
+ mapping = VARIABLE_MAPPING
329
+
330
+ self.resolved_variables = resolve_variable_order(self.user_variables, self.data_source)
215
331
 
216
332
  model_variables = []
217
333
  for variable in self.resolved_variables:
218
- model_variables.extend(VARIABLE_MAPPING.get(variable, []))
334
+ model_variables.extend(mapping.get(variable, []))
219
335
 
220
336
  self.model_variables = sorted(set(model_variables))
221
337
  logger.debug("Resolved model variables: %s", self.model_variables)
222
338
 
339
+ def assign_ch2025_station(self):
340
+ """Match the EPW site to a CH2025 station that has the requested variables."""
341
+ if self.data_source != "ch2025":
342
+ return
343
+ from pyepwmorph.models.ch2025 import CH2025_VARIABLES, nearest_station
344
+
345
+ variables = sorted(CH2025_VARIABLES) if self.ch2025_full_coverage else self.model_variables
346
+ station = nearest_station(
347
+ self.location['latitude'],
348
+ self.location['longitude'],
349
+ self.location['elevation'],
350
+ variables=variables,
351
+ )
352
+ self.ch2025_station = station
353
+ if station["weak_match"]:
354
+ self.ch2025_notes.append(
355
+ f"The nearest suitable CH2025 station, {station['name']}, is "
356
+ f"{station['distance_km']:.0f} km away and "
357
+ f"{station['elevation_difference_m']:+.0f} m in elevation. Its climate "
358
+ f"signal may not represent this site."
359
+ )
360
+
223
361
  def assign_model_pathways(self):
224
362
  """Resolve user-facing pathway labels to model experiment IDs.
225
363
 
226
364
  For ``data_source="cmip6"``, maps friendly names to SSP IDs.
227
365
  For ``data_source="custom"``, passes labels through directly
228
366
  and adds the reference scenario.
367
+ For ``data_source="ch2025"``, maps warming-level labels to state ids
368
+ and always includes the ``ref91-20`` reference.
229
369
  """
230
370
  self.model_pathways = []
231
371
 
@@ -236,6 +376,20 @@ class MorphConfig:
236
376
  f"Reference scenario '{self.reference_scenario}' not found "
237
377
  f"in custom_data keys: {self.model_pathways}"
238
378
  )
379
+ elif self.data_source == "ch2025":
380
+ known = set(CH2025_PATHWAY_MAP.values())
381
+ for user_path in self.user_pathways:
382
+ if user_path in CH2025_PATHWAY_MAP:
383
+ self.model_pathways.append(CH2025_PATHWAY_MAP[user_path])
384
+ elif user_path in known:
385
+ self.model_pathways.append(user_path)
386
+ else:
387
+ raise ValueError(
388
+ f"Unknown CH2025 warming level '{user_path}'. "
389
+ f"Use one of {list(CH2025_PATHWAY_MAP)} or {sorted(known)}."
390
+ )
391
+ if self.reference_scenario not in self.model_pathways:
392
+ self.model_pathways.append(self.reference_scenario)
239
393
  else:
240
394
  for user_path in self.user_pathways:
241
395
  if user_path in CMIP6_PATHWAY_MAP:
@@ -8,7 +8,7 @@ import datetime
8
8
  import logging
9
9
  import os
10
10
 
11
- from pyepwmorph.models import access, assemble, coordinate, custom
11
+ from pyepwmorph.models import access, assemble, ch2025, coordinate, custom
12
12
  from pyepwmorph.morph import procedures
13
13
  from pyepwmorph.tools import cache
14
14
  from pyepwmorph.tools import configuration as morph_config
@@ -152,6 +152,49 @@ def compile_custom_model_data(custom_data, variables, percentile=50):
152
152
  return model_data_dict
153
153
 
154
154
 
155
+ def compile_ch2025_model_data(station_id, pathways, variables, percentiles):
156
+ """Load shipped CH2025 climatologies for one station.
157
+
158
+ Each warming level gets its own paired reference, built from the model
159
+ chains it shares with ``ref91-20`` (see
160
+ ``ch2025.build_ch2025_change_ensemble``). The reference is stored under
161
+ ``ch2025.reference_key(state)``; pass that as ``reference_scenario`` to
162
+ ``morph_epw``.
163
+
164
+ Parameters
165
+ ----------
166
+ station_id : str
167
+ Lower-case CH2025 station abbreviation.
168
+ pathways : list[str]
169
+ State ids. ``ref91-20`` may be included and is skipped.
170
+ variables : list[str]
171
+ CH2025 variable ids.
172
+ percentiles : list
173
+ Ensemble percentiles.
174
+
175
+ Returns
176
+ -------
177
+ dict[str, dict[str, pd.DataFrame]]
178
+ ``model_data_dict[key][variable]`` with percentile columns, where
179
+ *key* is a warming level or its paired reference key.
180
+ """
181
+ model_data_dict = {}
182
+ for pathway in pathways:
183
+ if pathway == ch2025.CH2025_REFERENCE:
184
+ continue
185
+ reference_key = ch2025.reference_key(pathway)
186
+ model_data_dict[pathway] = {}
187
+ model_data_dict[reference_key] = {}
188
+ for variable in variables:
189
+ logger.info("Loading CH2025 data for '%s' / '%s' / '%s'", station_id, pathway, variable)
190
+ reference, future = ch2025.build_ch2025_change_ensemble(
191
+ percentiles, variable, station_id, pathway,
192
+ )
193
+ model_data_dict[reference_key][variable] = reference
194
+ model_data_dict[pathway][variable] = future
195
+ return model_data_dict
196
+
197
+
155
198
  # ---------------------------------------------------------------------------
156
199
  # Morphing
157
200
  # ---------------------------------------------------------------------------
@@ -165,6 +208,8 @@ def morph_epw(
165
208
  pathway,
166
209
  percentile,
167
210
  reference_scenario="historical",
211
+ data_source="cmip6",
212
+ station_label=None,
168
213
  ):
169
214
  """Apply morphing procedures to an EPW file.
170
215
 
@@ -194,6 +239,12 @@ def morph_epw(
194
239
  reference_scenario : str
195
240
  Key in *model_data_dict* for the baseline/reference data.
196
241
  Defaults to ``"historical"``.
242
+ data_source : str
243
+ ``"cmip6"``, ``"custom"``, or ``"ch2025"``. CH2025 uses warming-level
244
+ climatologies directly and morphs humidity and wind from ``hurs``
245
+ and ``sfcWind``.
246
+ station_label : str or None
247
+ CH2025 station named in the EPW comment.
197
248
 
198
249
  Returns
199
250
  -------
@@ -225,13 +276,16 @@ def morph_epw(
225
276
 
226
277
  def _climatology(var):
227
278
  """Return the (baseline, future) monthly climatologies for a variable."""
279
+ baseline = _get_series(reference_scenario, var)
280
+ future = _get_series(pathway, var)
281
+ if data_source == "ch2025":
282
+ return assemble.calc_gwl_climatologies(baseline, future, var)
228
283
  return assemble.calc_model_climatologies(
229
- baseline_range, target_range,
230
- _get_series(reference_scenario, var), _get_series(pathway, var), var,
284
+ baseline_range, target_range, baseline, future, var,
231
285
  )
232
286
 
233
287
  morphed = {}
234
- for variable in morph_config.resolve_variable_order(user_variables):
288
+ for variable in morph_config.resolve_variable_order(user_variables, data_source):
235
289
  if variable == 'Pressure':
236
290
  psl_base, psl_future = _climatology('psl')
237
291
  morphed['atmos_Pa'] = procedures.morph_psl(
@@ -250,12 +304,18 @@ def morph_epw(
250
304
  )
251
305
 
252
306
  elif variable == 'Humidity':
253
- huss_base, huss_future = _climatology('huss')
254
- morphed['relhum_percent'] = procedures.morph_relhum(
255
- present['relhum_percent'], present['atmos_Pa'], present['drybulb_C'],
256
- morphed['atmos_Pa'], morphed['drybulb_C'],
257
- huss_future, huss_base,
258
- )
307
+ if data_source == "ch2025":
308
+ hurs_base, hurs_future = _climatology('hurs')
309
+ morphed['relhum_percent'] = procedures.morph_relhum_direct(
310
+ present['relhum_percent'], hurs_future, hurs_base,
311
+ )
312
+ else:
313
+ huss_base, huss_future = _climatology('huss')
314
+ morphed['relhum_percent'] = procedures.morph_relhum(
315
+ present['relhum_percent'], present['atmos_Pa'], present['drybulb_C'],
316
+ morphed['atmos_Pa'], morphed['drybulb_C'],
317
+ huss_future, huss_base,
318
+ )
259
319
 
260
320
  elif variable == 'Dew Point':
261
321
  morphed['dewpoint_C'] = procedures.morph_dewpt(
@@ -263,13 +323,39 @@ def morph_epw(
263
323
  )
264
324
 
265
325
  elif variable == 'Wind':
266
- vas_base, vas_future = _climatology('vas')
267
- uas_base, uas_future = _climatology('uas')
268
- morphed['windspd_ms'] = procedures.morph_wspd(
269
- present['windspd_ms'],
270
- vas_future, vas_base,
271
- uas_future, uas_base,
326
+ if data_source == "ch2025":
327
+ wind_base, wind_future = _climatology('sfcWind')
328
+ morphed['windspd_ms'] = procedures.morph_wspd_direct(
329
+ present['windspd_ms'], wind_future, wind_base,
330
+ )
331
+ else:
332
+ vas_base, vas_future = _climatology('vas')
333
+ uas_base, uas_future = _climatology('uas')
334
+ morphed['windspd_ms'] = procedures.morph_wspd(
335
+ present['windspd_ms'],
336
+ vas_future, vas_base,
337
+ uas_future, uas_base,
338
+ )
339
+
340
+ elif variable == 'Radiation':
341
+ rsds_base, rsds_future = _climatology('rsds')
342
+ longitude = location['longitude']
343
+ latitude = location['latitude']
344
+ utc_offset = location['utc_offset']
345
+
346
+ glohor = procedures.morph_glohor(
347
+ present['glohorrad_Whm2'], rsds_future, rsds_base,
272
348
  )
349
+ difhor = procedures.calc_difhor(
350
+ longitude, latitude, utc_offset, glohor, present['exthorrad_Whm2'],
351
+ )
352
+ dirnor = procedures.calc_dirnor(
353
+ glohor, difhor, longitude, latitude, utc_offset,
354
+ extraterrestrial_dirnor=present['extdirrad_Whm2'],
355
+ )
356
+ morphed['glohorrad_Whm2'] = glohor
357
+ morphed['difhorrad_Whm2'] = difhor
358
+ morphed['dirnorrad_Whm2'] = dirnor
273
359
 
274
360
  elif variable == 'Clouds and Radiation':
275
361
  rsds_base, rsds_future = _climatology('rsds')
@@ -306,10 +392,22 @@ def morph_epw(
306
392
  epw_object.dataframe[column] = values
307
393
  # EPW headers are comma delimited, so the comment itself must not
308
394
  # contain commas or it will be split across fields on the next read
309
- epw_object.add_comment(
310
- f" morphed for {pathway} ({target_range[0]}-{target_range[1]}) with pyepwmorph"
311
- f" on {datetime.datetime.now().isoformat()} [{' '.join(morphed)}]"
312
- )
395
+ sky_note = ""
396
+ if "Radiation" in morph_config.resolve_variable_order(user_variables, data_source):
397
+ sky_note = " [sky cover left unmorphed]"
398
+ if data_source == "ch2025":
399
+ station_note = f" station {station_label}" if station_label else ""
400
+ comment = (
401
+ f" morphed for {pathway} with CH2025{station_note} with pyepwmorph"
402
+ f" on {datetime.datetime.now().isoformat()} [{' '.join(morphed)}]{sky_note}"
403
+ f" {ch2025.CH2025_CITATION}"
404
+ )
405
+ else:
406
+ comment = (
407
+ f" morphed for {pathway} ({target_range[0]}-{target_range[1]}) with pyepwmorph"
408
+ f" on {datetime.datetime.now().isoformat()} [{' '.join(morphed)}]{sky_note}"
409
+ )
410
+ epw_object.add_comment(comment)
313
411
 
314
412
  return epw_object
315
413
 
@@ -333,13 +431,15 @@ def morphing_workflow(
333
431
  custom_data=None,
334
432
  reference_scenario=None,
335
433
  time_slices=None,
434
+ ch2025_full_coverage=False,
336
435
  # backward-compat alias
337
436
  future_years=None,
338
437
  ):
339
438
  """Run the full morphing pipeline.
340
439
 
341
- Supports both CMIP6 (``data_source="cmip6"``) and custom CSV data
342
- (``data_source="custom"``).
440
+ Supports CMIP6 (``data_source="cmip6"``), custom CSV data
441
+ (``data_source="custom"``), and Swiss CH2025 warming levels
442
+ (``data_source="ch2025"``).
343
443
 
344
444
  Parameters
345
445
  ----------
@@ -364,20 +464,25 @@ def morphing_workflow(
364
464
  write_file : bool
365
465
  Whether to write morphed EPW files to disk.
366
466
  data_source : str
367
- ``"cmip6"`` or ``"custom"``.
467
+ ``"cmip6"``, ``"custom"``, or ``"ch2025"``. CH2025 does not use
468
+ ``target_years``; pathways are warming levels such as ``"gwl2.0"``.
368
469
  custom_data : dict or None
369
470
  Nested dict for custom data (see ``MorphConfig``).
370
471
  reference_scenario : str or None
371
472
  Baseline scenario key.
372
473
  time_slices : dict or None
373
474
  Optional per-pathway ``(start, end)`` temporal bounds for CMIP6 data.
475
+ ch2025_full_coverage : bool
476
+ CH2025 only. Match the site only against stations that carry every
477
+ CH2025 variable (see ``MorphConfig``).
374
478
  future_years : list[int] or None
375
479
  Deprecated alias for *target_years*.
376
480
 
377
481
  Returns
378
482
  -------
379
483
  dict
380
- Nested ``result_data[year][pathway][percentile]`` -> morphed Epw.
484
+ For CMIP6 and custom data, ``result_data[year][pathway][percentile]``.
485
+ For CH2025, ``result_data[warming_level][percentile]``.
381
486
  """
382
487
  config_object = morph_config.MorphConfig(
383
488
  project_name, epw_file, user_variables, user_pathways, percentiles,
@@ -388,6 +493,7 @@ def morphing_workflow(
388
493
  data_source=data_source,
389
494
  custom_data=custom_data,
390
495
  reference_scenario=reference_scenario,
496
+ ch2025_full_coverage=ch2025_full_coverage,
391
497
  future_years=future_years,
392
498
  )
393
499
 
@@ -401,6 +507,14 @@ def morphing_workflow(
401
507
  config_object.model_variables,
402
508
  percentile=default_pctile,
403
509
  )
510
+ elif config_object.data_source == "ch2025":
511
+ station = config_object.ch2025_station
512
+ year_model_dict = compile_ch2025_model_data(
513
+ station["station_id"],
514
+ config_object.model_pathways,
515
+ config_object.model_variables,
516
+ config_object.percentiles,
517
+ )
404
518
  else:
405
519
  year_model_dict = iterate_compile_model_data(
406
520
  config_object.model_pathways,
@@ -416,6 +530,43 @@ def morphing_workflow(
416
530
  target_pathways = [p for p in config_object.model_pathways if p != ref_scenario]
417
531
  result_data = {}
418
532
 
533
+ if "Radiation" in config_object.resolved_variables:
534
+ logger.warning(
535
+ "Radiation morphs shortwave radiation while leaving sky cover at "
536
+ "baseline values. EnergyPlus derives sky temperature from opaque "
537
+ "sky cover when horizontal infrared is absent, so the longwave "
538
+ "side of this file does not follow the shortwave change."
539
+ )
540
+
541
+ if config_object.data_source == "ch2025":
542
+ station = config_object.ch2025_station
543
+ station_label = f"{station['station_id']} {station['name']}".replace(",", " ")
544
+ for pathway in target_pathways:
545
+ result_data[pathway] = {}
546
+ for percentile in config_object.percentiles:
547
+ percentile_key = str(percentile)
548
+ morphed_data = morph_epw(
549
+ config_object.epw,
550
+ config_object.resolved_variables,
551
+ config_object.baseline_range,
552
+ ch2025.CH2025_BASELINE_RANGE,
553
+ year_model_dict,
554
+ pathway,
555
+ percentile,
556
+ reference_scenario=ch2025.reference_key(pathway),
557
+ data_source="ch2025",
558
+ station_label=station_label,
559
+ )
560
+ result_data[pathway][percentile_key] = morphed_data
561
+ if write_file and config_object.output_directory:
562
+ morphed_data.write_to_file(
563
+ os.path.join(
564
+ config_object.output_directory,
565
+ f"{pathway}_{percentile_key}.epw",
566
+ )
567
+ )
568
+ return result_data
569
+
419
570
  for target_year in config_object.target_years:
420
571
  year_key = str(target_year)
421
572
  result_data[year_key] = {}
@@ -3,12 +3,13 @@ requires = ["hatchling"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [tool.hatch.build]
6
- include = ["pyepwmorph/*"]
6
+ include = ["pyepwmorph"]
7
7
 
8
8
  # hatchling 1.32 defaults to core metadata 2.5, which twine below 7.0 rejects,
9
9
  # including the twine inside pypa/gh-action-pypi-publish. Stay on 2.4 until the
10
10
  # upload path supports 2.5.
11
11
  [tool.hatch.build.targets.wheel]
12
+ artifacts = ["pyepwmorph/data/*.parquet"]
12
13
  core-metadata-version = "2.4"
13
14
 
14
15
  [tool.hatch.build.targets.sdist]
@@ -16,7 +17,7 @@ core-metadata-version = "2.4"
16
17
 
17
18
  [project]
18
19
  name = "pyepwmorph"
19
- version = "3.0.0"
20
+ version = "3.1.0"
20
21
  authors = [
21
22
  { name="Justin McCarty", email="mccarty.justin.f@gmail.com" },
22
23
  ]
File without changes
File without changes