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.
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/PKG-INFO +30 -5
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/README.md +29 -4
- pyepwmorph-3.1.0/pyepwmorph/data/__init__.py +1 -0
- pyepwmorph-3.1.0/pyepwmorph/data/ch2025_monthly.parquet +0 -0
- pyepwmorph-3.1.0/pyepwmorph/data/ch2025_stations.parquet +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/assemble.py +24 -0
- pyepwmorph-3.1.0/pyepwmorph/models/ch2025.py +303 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/morph/procedures.py +53 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/configuration.py +164 -10
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/workflow.py +175 -24
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyproject.toml +3 -2
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/.gitignore +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/LICENSE +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/__init__.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/__init__.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/access.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/coordinate.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/models/custom.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/morph/__init__.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/__init__.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/cache.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/io.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/psychrometrics.py +0 -0
- {pyepwmorph-3.0.0 → pyepwmorph-3.1.0}/pyepwmorph/tools/solar.py +0 -0
- {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.
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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."""
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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 ``"
|
|
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
|
|
214
|
-
self.
|
|
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(
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
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 ``"
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|