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