climate-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- climate_toolkit/__init__.py +109 -0
- climate_toolkit/_resources.py +45 -0
- climate_toolkit/calculate_hazards/__init__.py +14 -0
- climate_toolkit/calculate_hazards/crop_water_balance_params.json +58 -0
- climate_toolkit/calculate_hazards/ensemble_hazards.py +1317 -0
- climate_toolkit/calculate_hazards/hazards.py +3272 -0
- climate_toolkit/cli.py +89 -0
- climate_toolkit/climate_statistics/__init__.py +14 -0
- climate_toolkit/climate_statistics/ensemble_statistics.py +1065 -0
- climate_toolkit/climate_statistics/statistics.py +3375 -0
- climate_toolkit/climatology/__init__.py +104 -0
- climate_toolkit/climatology/_cli_common.py +172 -0
- climate_toolkit/climatology/heat_stress.py +675 -0
- climate_toolkit/climatology/human_heat_stress.py +389 -0
- climate_toolkit/climatology/long_term_climatology.py +1939 -0
- climate_toolkit/climatology/spei.py +745 -0
- climate_toolkit/climatology/vpd.py +246 -0
- climate_toolkit/climatology/xclim_reference.py +988 -0
- climate_toolkit/compare_datasets/__init__.py +14 -0
- climate_toolkit/compare_datasets/compare_datasets.py +1070 -0
- climate_toolkit/compare_periods/__init__.py +14 -0
- climate_toolkit/compare_periods/ensemble_periods.py +2899 -0
- climate_toolkit/compare_periods/periods.py +1892 -0
- climate_toolkit/crop_calendar/__init__.py +94 -0
- climate_toolkit/crop_calendar/ggcmi.py +198 -0
- climate_toolkit/crop_calendar/registry.py +102 -0
- climate_toolkit/data/__init__.py +1 -0
- climate_toolkit/data/ggcmi_phase3/__init__.py +1 -0
- climate_toolkit/data/ggcmi_phase3/crop_calendar.parquet +0 -0
- climate_toolkit/data/ggcmi_phase3/crop_calendar_manifest.json +42 -0
- climate_toolkit/fetch_data/__init__.py +61 -0
- climate_toolkit/fetch_data/cache_inventory.py +378 -0
- climate_toolkit/fetch_data/fetch_data.py +645 -0
- climate_toolkit/fetch_data/gee_xee_batch.py +1133 -0
- climate_toolkit/fetch_data/multi_site.py +220 -0
- climate_toolkit/fetch_data/nex_gddp_batch.py +1017 -0
- climate_toolkit/fetch_data/preprocess_data/__init__.py +18 -0
- climate_toolkit/fetch_data/preprocess_data/preprocess_data.py +332 -0
- climate_toolkit/fetch_data/runtime_notes.py +80 -0
- climate_toolkit/fetch_data/source_data/__init__.py +15 -0
- climate_toolkit/fetch_data/source_data/source_data.py +398 -0
- climate_toolkit/fetch_data/source_data/sources/agera_5.py +15 -0
- climate_toolkit/fetch_data/source_data/sources/chirps.py +63 -0
- climate_toolkit/fetch_data/source_data/sources/chirts.py +63 -0
- climate_toolkit/fetch_data/source_data/sources/era_5.py +15 -0
- climate_toolkit/fetch_data/source_data/sources/gee.py +999 -0
- climate_toolkit/fetch_data/source_data/sources/gee_xee.py +107 -0
- climate_toolkit/fetch_data/source_data/sources/ghcn_daily.py +87 -0
- climate_toolkit/fetch_data/source_data/sources/gsod.py +87 -0
- climate_toolkit/fetch_data/source_data/sources/imerg.py +106 -0
- climate_toolkit/fetch_data/source_data/sources/nasa_power.py +414 -0
- climate_toolkit/fetch_data/source_data/sources/nex_gddp.py +357 -0
- climate_toolkit/fetch_data/source_data/sources/nex_gddp_xee.py +1062 -0
- climate_toolkit/fetch_data/source_data/sources/soil_grid.py +58 -0
- climate_toolkit/fetch_data/source_data/sources/tamsat.py +647 -0
- climate_toolkit/fetch_data/source_data/sources/terraclimate.py +96 -0
- climate_toolkit/fetch_data/source_data/sources/utils/__init__.py +177 -0
- climate_toolkit/fetch_data/source_data/sources/utils/config.yaml +227 -0
- climate_toolkit/fetch_data/source_data/sources/utils/models.py +539 -0
- climate_toolkit/fetch_data/source_data/sources/utils/settings.py +206 -0
- climate_toolkit/fetch_data/source_data/sources/xee_common.py +221 -0
- climate_toolkit/fetch_data/transform_data/__init__.py +20 -0
- climate_toolkit/fetch_data/transform_data/data_dictionary.yaml +96 -0
- climate_toolkit/fetch_data/transform_data/transform_data.py +343 -0
- climate_toolkit/gee_check.py +280 -0
- climate_toolkit/pipeline.py +307 -0
- climate_toolkit/season_analysis/__init__.py +24 -0
- climate_toolkit/season_analysis/ensemble.py +1016 -0
- climate_toolkit/season_analysis/season_identity.py +58 -0
- climate_toolkit/season_analysis/seasons.py +1817 -0
- climate_toolkit/weather_station/__init__.py +73 -0
- climate_toolkit/weather_station/compare.py +3236 -0
- climate_toolkit/weather_station/custom_station.py +464 -0
- climate_toolkit/weather_station/dem.py +153 -0
- climate_toolkit/weather_station/download.py +1440 -0
- climate_toolkit/weather_station/ghcn_daily.py +832 -0
- climate_toolkit/weather_station/gsod.py +835 -0
- climate_toolkit/weather_station/overrides.py +174 -0
- climate_toolkit/weather_station/station_selector.py +607 -0
- climate_toolkit-0.1.0.dist-info/METADATA +1548 -0
- climate_toolkit-0.1.0.dist-info/RECORD +85 -0
- climate_toolkit-0.1.0.dist-info/WHEEL +5 -0
- climate_toolkit-0.1.0.dist-info/entry_points.txt +18 -0
- climate_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- climate_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ensemble of hazards.py across NEX-GDDP models x scenarios.
|
|
3
|
+
For each (model, scenario) combination, runs same hazard assessment that
|
|
4
|
+
hazards.py produces, but with NEX-GDDP daily data.
|
|
5
|
+
|
|
6
|
+
Scenario boundaries are preserved during aggregation. Within each scenario,
|
|
7
|
+
ensemble means come from projection-level season statistics, while hazard
|
|
8
|
+
statuses are aggregated from projection-level hazard evaluations rather than
|
|
9
|
+
re-derived from climate means.
|
|
10
|
+
|
|
11
|
+
Modes:
|
|
12
|
+
- default : auto-detect seasons per (model, scenario) using NEX-GDDP.
|
|
13
|
+
- --fixed-season : single, two, or year-crossing windows applied to each year.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import json
|
|
19
|
+
import io
|
|
20
|
+
import argparse
|
|
21
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
22
|
+
from collections import Counter, defaultdict
|
|
23
|
+
from contextlib import redirect_stdout
|
|
24
|
+
from datetime import datetime, date
|
|
25
|
+
from statistics import mean
|
|
26
|
+
from time import perf_counter
|
|
27
|
+
from typing import Any, Dict, List, Tuple, Optional
|
|
28
|
+
|
|
29
|
+
import pandas as pd
|
|
30
|
+
|
|
31
|
+
from climate_toolkit.calculate_hazards.hazards import (
|
|
32
|
+
CROP_THRESHOLDS,
|
|
33
|
+
HAZARD_EVAL_SPECS,
|
|
34
|
+
HAZARD_PRINT_ORDER,
|
|
35
|
+
evaluate_threshold,
|
|
36
|
+
evaluate_hazard_metrics,
|
|
37
|
+
calculate_season_statistics,
|
|
38
|
+
build_water_balance_methodology,
|
|
39
|
+
load_custom_thresholds_file,
|
|
40
|
+
add_et0,
|
|
41
|
+
FULL_WINDOW_WATER_BALANCE,
|
|
42
|
+
CROP_ACTIVE_WATER_BALANCE,
|
|
43
|
+
WATER_BALANCE_WINDOW_CHOICES,
|
|
44
|
+
DEFAULT_SOILCP,
|
|
45
|
+
DEFAULT_SOILSAT,
|
|
46
|
+
DEFAULT_SPINUP_DAYS,
|
|
47
|
+
_apply_water_balance_window_mode,
|
|
48
|
+
_normalize_water_balance_window_mode,
|
|
49
|
+
resolve_thresholds,
|
|
50
|
+
resolve_crop_water_balance_params,
|
|
51
|
+
_shift_iso_date,
|
|
52
|
+
_print_hazard_season_detection_summary,
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
from climate_toolkit.fetch_data.preprocess_data.preprocess_data import preprocess_data
|
|
56
|
+
PREPROCESS_AVAILABLE = True
|
|
57
|
+
except Exception:
|
|
58
|
+
preprocess_data = None
|
|
59
|
+
PREPROCESS_AVAILABLE = False
|
|
60
|
+
from climate_toolkit.fetch_data.source_data.sources.utils.models import ClimateVariable
|
|
61
|
+
from climate_toolkit.fetch_data.source_data.sources.nex_gddp import (
|
|
62
|
+
AVAILABLE_MODELS as MODELS,
|
|
63
|
+
POLICY_PROFILE_CHOICES,
|
|
64
|
+
POLICY_PROFILE_HELP,
|
|
65
|
+
default_ensemble_models_for_location,
|
|
66
|
+
policy_runtime_messages,
|
|
67
|
+
resolve_ensemble_policy_for_location,
|
|
68
|
+
)
|
|
69
|
+
from climate_toolkit.season_analysis.seasons import (
|
|
70
|
+
fetch_and_analyze_years,
|
|
71
|
+
detect_onset_cessation,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
HAS_FAY = callable(detect_onset_cessation)
|
|
75
|
+
|
|
76
|
+
SCENARIOS = ['ssp126', 'ssp245', 'ssp370', 'ssp585']
|
|
77
|
+
|
|
78
|
+
_VARS = [ClimateVariable.precipitation,
|
|
79
|
+
ClimateVariable.max_temperature,
|
|
80
|
+
ClimateVariable.min_temperature]
|
|
81
|
+
_COLMAP = {'pr': 'precipitation', 'prcp': 'precipitation',
|
|
82
|
+
'tasmax': 'max_temperature', 'tasmin': 'min_temperature'}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format_elapsed(seconds: float) -> str:
|
|
86
|
+
if seconds < 60:
|
|
87
|
+
return f"{seconds:.1f}s"
|
|
88
|
+
minutes, sec = divmod(int(round(seconds)), 60)
|
|
89
|
+
if minutes < 60:
|
|
90
|
+
return f"{minutes}m{sec:02d}s"
|
|
91
|
+
hours, minutes = divmod(minutes, 60)
|
|
92
|
+
return f"{hours}h{minutes:02d}m{sec:02d}s"
|
|
93
|
+
|
|
94
|
+
# Fixed-season parsing & expansion
|
|
95
|
+
def _parse_fixed(spec: str) -> List[Tuple[str, str]]:
|
|
96
|
+
"""'MM-DD:MM-DD[,MM-DD:MM-DD]' -> [(onset, cessation), ...]"""
|
|
97
|
+
out = []
|
|
98
|
+
for token in spec.split(','):
|
|
99
|
+
token = token.strip()
|
|
100
|
+
if not token:
|
|
101
|
+
continue
|
|
102
|
+
if ':' not in token:
|
|
103
|
+
raise ValueError(f"fixed-season token missing ':' -- {token!r}")
|
|
104
|
+
onset, cessation = (s.strip() for s in token.split(':', 1))
|
|
105
|
+
datetime.strptime(onset, '%m-%d') # validate
|
|
106
|
+
datetime.strptime(cessation, '%m-%d')
|
|
107
|
+
out.append((onset, cessation))
|
|
108
|
+
if not out:
|
|
109
|
+
raise ValueError("empty fixed-season specification")
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
def _yearcross(o: str, c: str) -> bool:
|
|
113
|
+
return (datetime.strptime(c, '%m-%d').replace(year=2001)
|
|
114
|
+
< datetime.strptime(o, '%m-%d').replace(year=2001))
|
|
115
|
+
|
|
116
|
+
def _isleap(y: int) -> bool:
|
|
117
|
+
return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)
|
|
118
|
+
|
|
119
|
+
def _iso(year: int, mmdd: str) -> str:
|
|
120
|
+
m, d = (int(x) for x in mmdd.split('-'))
|
|
121
|
+
if m == 2 and d == 29 and not _isleap(year):
|
|
122
|
+
d = 28
|
|
123
|
+
return f"{year:04d}-{m:02d}-{d:02d}"
|
|
124
|
+
|
|
125
|
+
def _expand_windows(sy: int, ey: int,
|
|
126
|
+
defs: List[Tuple[str, str]]) -> List[Dict]:
|
|
127
|
+
out = []
|
|
128
|
+
for y in range(sy, ey + 1):
|
|
129
|
+
for i, (o, c) in enumerate(defs, 1):
|
|
130
|
+
out.append({
|
|
131
|
+
'start': _iso(y, o),
|
|
132
|
+
'end': _iso(y + 1 if _yearcross(o, c) else y, c),
|
|
133
|
+
'season_number': i,
|
|
134
|
+
'year': y,
|
|
135
|
+
'total': len(defs),
|
|
136
|
+
})
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
def _fixed_windows_cross_year(defs: List[Tuple[str, str]]) -> bool:
|
|
140
|
+
return any(_yearcross(onset, cessation) for onset, cessation in defs)
|
|
141
|
+
|
|
142
|
+
def _year_crossing_tail_end(end_year: int, defs: List[Tuple[str, str]]) -> str | None:
|
|
143
|
+
crossings = [cessation for onset, cessation in defs if _yearcross(onset, cessation)]
|
|
144
|
+
if not crossings:
|
|
145
|
+
return None
|
|
146
|
+
return max(_iso(end_year + 1, cessation) for cessation in crossings)
|
|
147
|
+
|
|
148
|
+
def _prefetch_year_crossing_tail(
|
|
149
|
+
lat: float,
|
|
150
|
+
lon: float,
|
|
151
|
+
end_year: int,
|
|
152
|
+
models: List[str],
|
|
153
|
+
scenarios: List[str],
|
|
154
|
+
defs: List[Tuple[str, str]],
|
|
155
|
+
) -> None:
|
|
156
|
+
tail_end = _year_crossing_tail_end(end_year, defs)
|
|
157
|
+
if tail_end is None:
|
|
158
|
+
return
|
|
159
|
+
tail_start = f"{end_year + 1}-01-01"
|
|
160
|
+
print(
|
|
161
|
+
f" Prefetching next-year tail for year-crossing fixed windows: "
|
|
162
|
+
f"{tail_start} -> {tail_end}"
|
|
163
|
+
)
|
|
164
|
+
for scenario in scenarios:
|
|
165
|
+
for model in models:
|
|
166
|
+
print(f" tail prefetch [{scenario}] {model}")
|
|
167
|
+
try:
|
|
168
|
+
preprocess_data(
|
|
169
|
+
source='nex_gddp',
|
|
170
|
+
location_coord=(lat, lon),
|
|
171
|
+
variables=_VARS,
|
|
172
|
+
date_from=date.fromisoformat(tail_start),
|
|
173
|
+
date_to=date.fromisoformat(tail_end),
|
|
174
|
+
model=model,
|
|
175
|
+
scenario=scenario,
|
|
176
|
+
)
|
|
177
|
+
print(f" tail cache ready {tail_start}->{tail_end}")
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
print(f" tail prefetch failed: {exc}")
|
|
180
|
+
|
|
181
|
+
# NEX-GDDP fetching & per-window assessment
|
|
182
|
+
def _fetch(lat: float, lon: float, start: str, end: str,
|
|
183
|
+
model: str, scenario: str) -> pd.DataFrame:
|
|
184
|
+
df = preprocess_data(
|
|
185
|
+
source='nex_gddp',
|
|
186
|
+
location_coord=(lat, lon),
|
|
187
|
+
variables=_VARS,
|
|
188
|
+
date_from=date.fromisoformat(start),
|
|
189
|
+
date_to=date.fromisoformat(end),
|
|
190
|
+
model=model, scenario=scenario,
|
|
191
|
+
)
|
|
192
|
+
if df is None or df.empty:
|
|
193
|
+
raise RuntimeError(f"no data for {model}/{scenario} {start}->{end}")
|
|
194
|
+
rename = {c: _COLMAP[c] for c in df.columns if c in _COLMAP}
|
|
195
|
+
if rename:
|
|
196
|
+
df = df.rename(columns=rename)
|
|
197
|
+
|
|
198
|
+
# Attach Hargreaves ET0 so calculate_season_statistics can derive NDWS / NDWL0.
|
|
199
|
+
if {'min_temperature', 'max_temperature', 'date'}.issubset(df.columns):
|
|
200
|
+
view = df.rename(columns={'min_temperature': 'tmin',
|
|
201
|
+
'max_temperature': 'tmax'})
|
|
202
|
+
df['ET0_mm_day'] = add_et0(view, lat)['ET0_mm_day'].values
|
|
203
|
+
return df
|
|
204
|
+
|
|
205
|
+
def _detect_windows(lat: float, lon: float, sy: int, ey: int,
|
|
206
|
+
model: str, scenario: str) -> List[Dict]:
|
|
207
|
+
"""Auto-detect via fetch_and_analyze_years with NEX-GDDP source."""
|
|
208
|
+
try:
|
|
209
|
+
seasons_dict, _ = fetch_and_analyze_years(
|
|
210
|
+
lat, lon, start_year=sy, end_year=ey,
|
|
211
|
+
source='nex_gddp', model=model, scenario=scenario,
|
|
212
|
+
)
|
|
213
|
+
except TypeError as e:
|
|
214
|
+
raise RuntimeError(
|
|
215
|
+
"fetch_and_analyze_years did not accept NEX-GDDP arguments. "
|
|
216
|
+
"Either add a NEX-GDDP branch to seasons.py or use --fixed-season. "
|
|
217
|
+
f"({e})"
|
|
218
|
+
)
|
|
219
|
+
out = []
|
|
220
|
+
for y, seasons in sorted(seasons_dict.items()):
|
|
221
|
+
for i, s in enumerate(seasons, 1):
|
|
222
|
+
if not s.get('cessation'):
|
|
223
|
+
continue
|
|
224
|
+
out.append({
|
|
225
|
+
'start': pd.to_datetime(s['onset']).strftime('%Y-%m-%d'),
|
|
226
|
+
'end': pd.to_datetime(s['cessation']).strftime('%Y-%m-%d'),
|
|
227
|
+
'season_number': i,
|
|
228
|
+
'year': y,
|
|
229
|
+
'total': len(seasons),
|
|
230
|
+
})
|
|
231
|
+
if not out:
|
|
232
|
+
year_label = f"{sy}" if sy == ey else f"{sy}-{ey}"
|
|
233
|
+
raise RuntimeError(
|
|
234
|
+
"No onset-year seasons found in requested auto-detect range "
|
|
235
|
+
f"{year_label} for model={model} scenario={scenario}. "
|
|
236
|
+
"Season detection reassigns results to onset year, so narrow ranges "
|
|
237
|
+
"can end up empty. Widen year range or use --fixed-season."
|
|
238
|
+
)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
def _evaluate(crop: str, lat: float, lon: float,
|
|
242
|
+
w: Dict, model: str, scenario: str,
|
|
243
|
+
thresholds: Dict[str, Dict[str, Tuple]],
|
|
244
|
+
soilcp: float = DEFAULT_SOILCP,
|
|
245
|
+
soilsat: float = DEFAULT_SOILSAT) -> Dict:
|
|
246
|
+
"""hazards.py-style assessment for a single window using NEX-GDDP."""
|
|
247
|
+
crop_params = resolve_crop_water_balance_params(crop.capitalize())
|
|
248
|
+
water_balance_window = _normalize_water_balance_window_mode(
|
|
249
|
+
w.get("water_balance_window", FULL_WINDOW_WATER_BALANCE)
|
|
250
|
+
)
|
|
251
|
+
fetch_start = _shift_iso_date(w['start'], -DEFAULT_SPINUP_DAYS)
|
|
252
|
+
df = _fetch(lat, lon, fetch_start, w['end'], model, scenario)
|
|
253
|
+
soil_parameters = {
|
|
254
|
+
"soilcp": soilcp,
|
|
255
|
+
"soilsat": soilsat,
|
|
256
|
+
"root_depth_m": crop_params.get("root_depth_m"),
|
|
257
|
+
}
|
|
258
|
+
season_info = {
|
|
259
|
+
**w,
|
|
260
|
+
"onset_date": w["start"],
|
|
261
|
+
"cessation_date": w["end"],
|
|
262
|
+
"fetch_start_date": fetch_start,
|
|
263
|
+
"spinup_days": DEFAULT_SPINUP_DAYS,
|
|
264
|
+
"method": "fixed_season" if w.get("fixed_season") else "rainfall_based",
|
|
265
|
+
"source": "nex_gddp",
|
|
266
|
+
}
|
|
267
|
+
stats = calculate_season_statistics(
|
|
268
|
+
df,
|
|
269
|
+
soilcp=soilcp,
|
|
270
|
+
soilsat=soilsat,
|
|
271
|
+
kc=float(crop_params.get("kc_mid", 1.0)),
|
|
272
|
+
kc_init=float(crop_params.get("kc_init", crop_params.get("kc_mid", 1.0))),
|
|
273
|
+
kc_mid=float(crop_params.get("kc_mid", 1.0)),
|
|
274
|
+
kc_end=float(crop_params.get("kc_end", crop_params.get("kc_mid", 1.0))),
|
|
275
|
+
depletion_fraction_p=float(crop_params.get("depletion_fraction_p", 0.5)),
|
|
276
|
+
analysis_start=w['start'],
|
|
277
|
+
analysis_end=w['end'],
|
|
278
|
+
)
|
|
279
|
+
eto_seasons = []
|
|
280
|
+
eto_detection_note = None
|
|
281
|
+
if water_balance_window == CROP_ACTIVE_WATER_BALANCE and w.get("fixed_season"):
|
|
282
|
+
window_df = df[
|
|
283
|
+
(pd.to_datetime(df["date"]) >= pd.Timestamp(w["start"])) &
|
|
284
|
+
(pd.to_datetime(df["date"]) <= pd.Timestamp(w["end"]))
|
|
285
|
+
].copy()
|
|
286
|
+
if len(window_df) < 14:
|
|
287
|
+
eto_detection_note = "ETO window too short for detection (<14 days)."
|
|
288
|
+
else:
|
|
289
|
+
try:
|
|
290
|
+
eto_seasons = detect_onset_cessation(window_df)
|
|
291
|
+
if not eto_seasons:
|
|
292
|
+
eto_detection_note = "No ETO sub-season detected within fixed window."
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
eto_detection_note = str(exc)
|
|
295
|
+
eto_seasons = []
|
|
296
|
+
stats, count_window = _apply_water_balance_window_mode(
|
|
297
|
+
stats,
|
|
298
|
+
df,
|
|
299
|
+
season_info,
|
|
300
|
+
soil_parameters,
|
|
301
|
+
crop_params,
|
|
302
|
+
water_balance_window=water_balance_window,
|
|
303
|
+
eto_seasons=eto_seasons,
|
|
304
|
+
eto_detection_note=eto_detection_note,
|
|
305
|
+
)
|
|
306
|
+
hazards = evaluate_hazard_metrics(stats, thresholds)
|
|
307
|
+
methodology = build_water_balance_methodology(
|
|
308
|
+
season_info,
|
|
309
|
+
soil_parameters,
|
|
310
|
+
crop_params,
|
|
311
|
+
count_window=count_window,
|
|
312
|
+
)
|
|
313
|
+
length = (
|
|
314
|
+
datetime.fromisoformat(w['end'])
|
|
315
|
+
- datetime.fromisoformat(w['start'])
|
|
316
|
+
).days + 1
|
|
317
|
+
return {
|
|
318
|
+
'season_info': {**season_info, 'length_days': length},
|
|
319
|
+
'season_statistics': stats,
|
|
320
|
+
'water_balance_methodology': methodology,
|
|
321
|
+
'hazard_evaluation': hazards,
|
|
322
|
+
'projection': {'model': model, 'scenario': scenario},
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# Aggregation -- ensemble means
|
|
326
|
+
_SCALAR_KEYS = ['total_precipitation_mm', 'mean_daily_precipitation_mm',
|
|
327
|
+
'max_daily_precipitation_mm', 'rainy_days', 'dry_days',
|
|
328
|
+
'mean_temperature_c', 'mean_tmax_c', 'mean_tmin_c',
|
|
329
|
+
'max_temperature_c', 'min_temperature_c',
|
|
330
|
+
# Canonical hazard variables added in hazards.py
|
|
331
|
+
'max_tmax_c', 'min_tmin_c',
|
|
332
|
+
'NDD', 'NTx35', 'NTx40', 'NDWS', 'NDWL0']
|
|
333
|
+
|
|
334
|
+
def _avg_stats(rs: List[Dict]) -> Dict:
|
|
335
|
+
out = {}
|
|
336
|
+
if not rs:
|
|
337
|
+
return out
|
|
338
|
+
for k in _SCALAR_KEYS:
|
|
339
|
+
vs = [r['season_statistics'][k]
|
|
340
|
+
for r in rs if k in r.get('season_statistics', {})]
|
|
341
|
+
if vs:
|
|
342
|
+
out[k] = round(mean(vs), 2)
|
|
343
|
+
|
|
344
|
+
counts, max_l, mean_l = [], [], []
|
|
345
|
+
bucket_sums: Dict[str, float] = defaultdict(float)
|
|
346
|
+
for r in rs:
|
|
347
|
+
ds = r.get('season_statistics', {}).get('dry_spell_statistics')
|
|
348
|
+
if not ds:
|
|
349
|
+
continue
|
|
350
|
+
counts.append(ds['number_of_dry_spells'])
|
|
351
|
+
max_l.append(ds['max_dry_spell_length_days'])
|
|
352
|
+
if ds['number_of_dry_spells'] > 0:
|
|
353
|
+
mean_l.append(ds['mean_dry_spell_length_days'])
|
|
354
|
+
for bucket, n in (ds.get('length_distribution') or {}).items():
|
|
355
|
+
bucket_sums[bucket] += n
|
|
356
|
+
|
|
357
|
+
if counts:
|
|
358
|
+
ds_out = {
|
|
359
|
+
'number_of_dry_spells': round(mean(counts), 2),
|
|
360
|
+
'max_dry_spell_length_days': round(mean(max_l), 2) if max_l else 0,
|
|
361
|
+
'mean_dry_spell_length_days': round(mean(mean_l), 2) if mean_l else 0,
|
|
362
|
+
}
|
|
363
|
+
if bucket_sums:
|
|
364
|
+
n_total = len(rs)
|
|
365
|
+
ds_out['length_distribution'] = {
|
|
366
|
+
b: round(total / n_total, 2) for b, total in bucket_sums.items()
|
|
367
|
+
}
|
|
368
|
+
out['dry_spell_statistics'] = ds_out
|
|
369
|
+
return out
|
|
370
|
+
|
|
371
|
+
def _aggregate_hazard_statuses(bucket: List[Dict], agg: Dict) -> Dict:
|
|
372
|
+
out: Dict[str, Dict] = {}
|
|
373
|
+
specs = [spec for spec in HAZARD_EVAL_SPECS.values()]
|
|
374
|
+
for spec in specs:
|
|
375
|
+
hazard_key = spec['result_key']
|
|
376
|
+
value_key = spec['value_key']
|
|
377
|
+
agg_key = spec['stat_key']
|
|
378
|
+
statuses = []
|
|
379
|
+
for r in bucket:
|
|
380
|
+
hazard = (r.get('hazard_evaluation') or {}).get(hazard_key) or {}
|
|
381
|
+
status = hazard.get('status')
|
|
382
|
+
if status:
|
|
383
|
+
statuses.append(status)
|
|
384
|
+
if not statuses:
|
|
385
|
+
continue
|
|
386
|
+
counts = Counter(statuses)
|
|
387
|
+
dominant_status, dominant_count = sorted(
|
|
388
|
+
counts.items(),
|
|
389
|
+
key=lambda item: (-item[1], item[0]),
|
|
390
|
+
)[0]
|
|
391
|
+
n_status = len(statuses)
|
|
392
|
+
status_counts = dict(sorted(counts.items()))
|
|
393
|
+
out[hazard_key] = {
|
|
394
|
+
value_key: agg.get(agg_key),
|
|
395
|
+
'status': dominant_status if len(status_counts) == 1 else 'mixed',
|
|
396
|
+
'dominant_status': dominant_status,
|
|
397
|
+
'dominant_share': round(dominant_count / n_status, 3),
|
|
398
|
+
'status_counts': status_counts,
|
|
399
|
+
'status_shares': {
|
|
400
|
+
status: round(count / n_status, 3)
|
|
401
|
+
for status, count in status_counts.items()
|
|
402
|
+
},
|
|
403
|
+
'n_projections': n_status,
|
|
404
|
+
}
|
|
405
|
+
return out
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _auto_season_slot_warning(assessments: List[Dict]) -> Optional[str]:
|
|
409
|
+
counts_by_scenario_year: Dict[str, Dict[int, int]] = defaultdict(dict)
|
|
410
|
+
for assessment in assessments or []:
|
|
411
|
+
scenario = assessment.get('scenario')
|
|
412
|
+
year = assessment.get('year')
|
|
413
|
+
total = assessment.get('total_seasons_per_year')
|
|
414
|
+
if isinstance(scenario, str) and isinstance(year, int) and isinstance(total, int):
|
|
415
|
+
counts_by_scenario_year[scenario][year] = total
|
|
416
|
+
|
|
417
|
+
summaries: List[str] = []
|
|
418
|
+
for scenario, year_counts in sorted(counts_by_scenario_year.items()):
|
|
419
|
+
observed = set(year_counts.values())
|
|
420
|
+
if len(observed) <= 1:
|
|
421
|
+
continue
|
|
422
|
+
summary = ", ".join(
|
|
423
|
+
f"{year}:{count}" for year, count in sorted(year_counts.items())
|
|
424
|
+
)
|
|
425
|
+
summaries.append(f"{scenario} -> {summary}")
|
|
426
|
+
|
|
427
|
+
if not summaries:
|
|
428
|
+
return None
|
|
429
|
+
return (
|
|
430
|
+
"Auto-detected season counts differ across years within scenario, so "
|
|
431
|
+
"scenario_ensembles by season_number would blend incomparable seasons. "
|
|
432
|
+
f"Counts by scenario/year: {'; '.join(summaries)}. "
|
|
433
|
+
"Use --fixed-season for stable ensemble season summaries."
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _build_ensemble_hazard_season_detection(
|
|
438
|
+
*,
|
|
439
|
+
mode: str,
|
|
440
|
+
warning: Optional[str],
|
|
441
|
+
assessments: List[Dict],
|
|
442
|
+
) -> Dict[str, Any]:
|
|
443
|
+
counts_by_scenario_year: Dict[str, Dict[str, int]] = defaultdict(dict)
|
|
444
|
+
for assessment in assessments or []:
|
|
445
|
+
scenario = assessment.get("scenario")
|
|
446
|
+
year = assessment.get("year")
|
|
447
|
+
total = assessment.get("total_seasons_per_year")
|
|
448
|
+
if isinstance(scenario, str) and isinstance(year, int) and isinstance(total, int):
|
|
449
|
+
counts_by_scenario_year[scenario][str(year)] = total
|
|
450
|
+
|
|
451
|
+
if warning:
|
|
452
|
+
return {
|
|
453
|
+
"status": "prompt_required",
|
|
454
|
+
"reasons": ["unstable_season_counts"],
|
|
455
|
+
"human_review_recommended": True,
|
|
456
|
+
"calendar_override_recommended": True,
|
|
457
|
+
"guidance": [
|
|
458
|
+
"Auto season detection is not stable enough for comparable ensemble hazard summaries. Use --fixed-season."
|
|
459
|
+
],
|
|
460
|
+
"details": {
|
|
461
|
+
"mode": mode,
|
|
462
|
+
"counts_by_scenario_year": dict(counts_by_scenario_year),
|
|
463
|
+
"warning": warning,
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if mode == "fixed_season":
|
|
468
|
+
return {
|
|
469
|
+
"status": "ok",
|
|
470
|
+
"reasons": ["fixed_season_override"],
|
|
471
|
+
"human_review_recommended": False,
|
|
472
|
+
"calendar_override_recommended": False,
|
|
473
|
+
"guidance": [],
|
|
474
|
+
"details": {
|
|
475
|
+
"mode": mode,
|
|
476
|
+
"counts_by_scenario_year": dict(counts_by_scenario_year),
|
|
477
|
+
},
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
"status": "ok",
|
|
482
|
+
"reasons": [],
|
|
483
|
+
"human_review_recommended": False,
|
|
484
|
+
"calendar_override_recommended": False,
|
|
485
|
+
"guidance": [],
|
|
486
|
+
"details": {
|
|
487
|
+
"mode": mode,
|
|
488
|
+
"counts_by_scenario_year": dict(counts_by_scenario_year),
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _build_hazard_projection_task(
|
|
494
|
+
*,
|
|
495
|
+
crop: str,
|
|
496
|
+
lat: float,
|
|
497
|
+
lon: float,
|
|
498
|
+
start_year: int,
|
|
499
|
+
end_year: int,
|
|
500
|
+
model: str,
|
|
501
|
+
scenario: str,
|
|
502
|
+
fixed_w: Optional[List[Dict[str, Any]]],
|
|
503
|
+
thresholds: Dict[str, Dict[str, Tuple]],
|
|
504
|
+
soilcp: float,
|
|
505
|
+
soilsat: float,
|
|
506
|
+
water_balance_window: str,
|
|
507
|
+
suppress_child_stdout: bool,
|
|
508
|
+
) -> Dict[str, Any]:
|
|
509
|
+
return {
|
|
510
|
+
"crop": crop,
|
|
511
|
+
"lat": lat,
|
|
512
|
+
"lon": lon,
|
|
513
|
+
"start_year": start_year,
|
|
514
|
+
"end_year": end_year,
|
|
515
|
+
"model": model,
|
|
516
|
+
"scenario": scenario,
|
|
517
|
+
"fixed_w": fixed_w,
|
|
518
|
+
"thresholds": thresholds,
|
|
519
|
+
"soilcp": soilcp,
|
|
520
|
+
"soilsat": soilsat,
|
|
521
|
+
"water_balance_window": water_balance_window,
|
|
522
|
+
"suppress_child_stdout": suppress_child_stdout,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _run_hazard_projection_task(task: Dict[str, Any]) -> Dict[str, Any]:
|
|
527
|
+
started = perf_counter()
|
|
528
|
+
model = task["model"]
|
|
529
|
+
scenario = task["scenario"]
|
|
530
|
+
suppress_child_stdout = bool(task.get("suppress_child_stdout", False))
|
|
531
|
+
|
|
532
|
+
def _inner() -> Dict[str, Any]:
|
|
533
|
+
fixed_w = task.get("fixed_w")
|
|
534
|
+
windows = (
|
|
535
|
+
fixed_w
|
|
536
|
+
if fixed_w is not None
|
|
537
|
+
else _detect_windows(
|
|
538
|
+
task["lat"],
|
|
539
|
+
task["lon"],
|
|
540
|
+
task["start_year"],
|
|
541
|
+
task["end_year"],
|
|
542
|
+
model,
|
|
543
|
+
scenario,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
results: List[Dict[str, Any]] = []
|
|
547
|
+
failures: List[Dict[str, Any]] = []
|
|
548
|
+
for w in windows:
|
|
549
|
+
window_payload = {
|
|
550
|
+
**w,
|
|
551
|
+
"fixed_season": fixed_w is not None,
|
|
552
|
+
"water_balance_window": task["water_balance_window"],
|
|
553
|
+
}
|
|
554
|
+
try:
|
|
555
|
+
results.append(
|
|
556
|
+
_evaluate(
|
|
557
|
+
task["crop"],
|
|
558
|
+
task["lat"],
|
|
559
|
+
task["lon"],
|
|
560
|
+
window_payload,
|
|
561
|
+
model,
|
|
562
|
+
scenario,
|
|
563
|
+
thresholds=task["thresholds"],
|
|
564
|
+
soilcp=task["soilcp"],
|
|
565
|
+
soilsat=task["soilsat"],
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
except Exception as exc:
|
|
569
|
+
failures.append(
|
|
570
|
+
{
|
|
571
|
+
"model": model,
|
|
572
|
+
"scenario": scenario,
|
|
573
|
+
"stage": "evaluate",
|
|
574
|
+
"year": w.get("year"),
|
|
575
|
+
"season_number": w.get("season_number"),
|
|
576
|
+
"window": f"{w.get('start')}->{w.get('end')}",
|
|
577
|
+
"error": str(exc),
|
|
578
|
+
}
|
|
579
|
+
)
|
|
580
|
+
return {"results": results, "failures": failures}
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
if suppress_child_stdout:
|
|
584
|
+
with redirect_stdout(io.StringIO()):
|
|
585
|
+
payload = _inner()
|
|
586
|
+
else:
|
|
587
|
+
payload = _inner()
|
|
588
|
+
except Exception as exc:
|
|
589
|
+
elapsed = perf_counter() - started
|
|
590
|
+
return {
|
|
591
|
+
"model": model,
|
|
592
|
+
"scenario": scenario,
|
|
593
|
+
"results": [],
|
|
594
|
+
"failures": [
|
|
595
|
+
{
|
|
596
|
+
"model": model,
|
|
597
|
+
"scenario": scenario,
|
|
598
|
+
"stage": "detect",
|
|
599
|
+
"error": str(exc),
|
|
600
|
+
}
|
|
601
|
+
],
|
|
602
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
elapsed = perf_counter() - started
|
|
606
|
+
return {
|
|
607
|
+
"model": model,
|
|
608
|
+
"scenario": scenario,
|
|
609
|
+
"results": payload["results"],
|
|
610
|
+
"failures": payload["failures"],
|
|
611
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _print_hazard_projection_progress(
|
|
616
|
+
*,
|
|
617
|
+
done: int,
|
|
618
|
+
total: int,
|
|
619
|
+
outcome: Dict[str, Any],
|
|
620
|
+
run_started: float,
|
|
621
|
+
) -> None:
|
|
622
|
+
elapsed = perf_counter() - run_started
|
|
623
|
+
avg = elapsed / done if done else 0.0
|
|
624
|
+
eta = avg * max(total - done, 0)
|
|
625
|
+
successes = len(outcome.get("results") or [])
|
|
626
|
+
failures = len(outcome.get("failures") or [])
|
|
627
|
+
status = "ok" if successes and not failures else "partial" if successes else "failed"
|
|
628
|
+
message = (
|
|
629
|
+
f" [{done:02d}/{total:02d}] {outcome['scenario']} | {outcome['model']} | {status} | "
|
|
630
|
+
f"windows_ok={successes} | windows_failed={failures} | "
|
|
631
|
+
f"model={_format_elapsed(float(outcome.get('elapsed_seconds', 0.0)))} | "
|
|
632
|
+
f"total={_format_elapsed(elapsed)} | eta={_format_elapsed(eta)}"
|
|
633
|
+
)
|
|
634
|
+
if failures and not successes:
|
|
635
|
+
first = (outcome.get("failures") or [{}])[0]
|
|
636
|
+
if first.get("error"):
|
|
637
|
+
message += f" | error={first['error']}"
|
|
638
|
+
print(message, flush=True)
|
|
639
|
+
|
|
640
|
+
# Driver
|
|
641
|
+
def calculate_ensemble(crop: str, lat: float, lon: float,
|
|
642
|
+
start_year: int, end_year: int,
|
|
643
|
+
models: List[str], scenarios: List[str],
|
|
644
|
+
fixed_season: Optional[str] = None,
|
|
645
|
+
custom_thresholds: Optional[Dict[str, Dict[str, Tuple]]] = None,
|
|
646
|
+
soilcp: float = DEFAULT_SOILCP,
|
|
647
|
+
soilsat: float = DEFAULT_SOILSAT,
|
|
648
|
+
water_balance_window: str = FULL_WINDOW_WATER_BALANCE,
|
|
649
|
+
ensemble_policy: Optional[Dict[str, Any]] = None,
|
|
650
|
+
model_workers: int = 1) -> Dict:
|
|
651
|
+
water_balance_window = _normalize_water_balance_window_mode(water_balance_window)
|
|
652
|
+
mode = 'fixed_season' if fixed_season else 'auto_detect'
|
|
653
|
+
fixed_defs = _parse_fixed(fixed_season) if fixed_season else None
|
|
654
|
+
fixed_w = (_expand_windows(start_year, end_year, fixed_defs)
|
|
655
|
+
if fixed_defs else None)
|
|
656
|
+
thresholds = resolve_thresholds(crop.capitalize(), custom_thresholds)
|
|
657
|
+
|
|
658
|
+
print(f"\nNEX-GDDP ensemble: {crop} at ({lat:.4f}, {lon:.4f}) "
|
|
659
|
+
f"{start_year}-{end_year}")
|
|
660
|
+
print(f" Mode: {mode}" + (f" ({fixed_season})" if fixed_season else ""))
|
|
661
|
+
if ensemble_policy:
|
|
662
|
+
print(f" Policy: {ensemble_policy.get('policy_id')}")
|
|
663
|
+
if ensemble_policy.get("regional_context"):
|
|
664
|
+
print(f" Context: {ensemble_policy.get('regional_context')}")
|
|
665
|
+
for line in policy_runtime_messages(ensemble_policy):
|
|
666
|
+
print(f" Warning: {line}")
|
|
667
|
+
print(f" Models: {len(models)} Scenarios: {len(scenarios)}\n")
|
|
668
|
+
requested_model_workers = max(1, int(model_workers))
|
|
669
|
+
total_projection_tasks = len(models) * len(scenarios)
|
|
670
|
+
effective_model_workers = max(1, min(requested_model_workers, total_projection_tasks))
|
|
671
|
+
run_started = perf_counter()
|
|
672
|
+
mode_label = "parallel" if effective_model_workers > 1 else "serial"
|
|
673
|
+
worker_suffix = f" | workers={effective_model_workers}" if effective_model_workers > 1 else ""
|
|
674
|
+
print(f" Execution: {mode_label}{worker_suffix}")
|
|
675
|
+
if fixed_defs and _fixed_windows_cross_year(fixed_defs):
|
|
676
|
+
print(
|
|
677
|
+
f" ! Year-crossing fixed window detected. Final requested year {end_year} "
|
|
678
|
+
f"needs following-year tail data ({end_year + 1}) to complete last season.\n"
|
|
679
|
+
)
|
|
680
|
+
_prefetch_year_crossing_tail(
|
|
681
|
+
lat=lat,
|
|
682
|
+
lon=lon,
|
|
683
|
+
end_year=end_year,
|
|
684
|
+
models=models,
|
|
685
|
+
scenarios=scenarios,
|
|
686
|
+
defs=fixed_defs,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
results: List[Dict] = []
|
|
690
|
+
failed_projection_runs: List[Dict[str, Any]] = []
|
|
691
|
+
task_outcomes: List[Dict[str, Any]] = []
|
|
692
|
+
tasks = [
|
|
693
|
+
_build_hazard_projection_task(
|
|
694
|
+
crop=crop,
|
|
695
|
+
lat=lat,
|
|
696
|
+
lon=lon,
|
|
697
|
+
start_year=start_year,
|
|
698
|
+
end_year=end_year,
|
|
699
|
+
model=m,
|
|
700
|
+
scenario=sc,
|
|
701
|
+
fixed_w=fixed_w,
|
|
702
|
+
thresholds=thresholds,
|
|
703
|
+
soilcp=soilcp,
|
|
704
|
+
soilsat=soilsat,
|
|
705
|
+
water_balance_window=water_balance_window,
|
|
706
|
+
suppress_child_stdout=effective_model_workers > 1,
|
|
707
|
+
)
|
|
708
|
+
for sc in scenarios
|
|
709
|
+
for m in models
|
|
710
|
+
]
|
|
711
|
+
|
|
712
|
+
def _consume_outcome(outcome: Dict[str, Any], done: int) -> None:
|
|
713
|
+
task_outcomes.append(outcome)
|
|
714
|
+
results.extend(outcome.get("results") or [])
|
|
715
|
+
failed_projection_runs.extend(outcome.get("failures") or [])
|
|
716
|
+
_print_hazard_projection_progress(
|
|
717
|
+
done=done,
|
|
718
|
+
total=len(tasks),
|
|
719
|
+
outcome=outcome,
|
|
720
|
+
run_started=run_started,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
if effective_model_workers == 1:
|
|
724
|
+
for done, task in enumerate(tasks, 1):
|
|
725
|
+
_consume_outcome(_run_hazard_projection_task(task), done)
|
|
726
|
+
else:
|
|
727
|
+
try:
|
|
728
|
+
future_map = {}
|
|
729
|
+
with ProcessPoolExecutor(max_workers=effective_model_workers) as executor:
|
|
730
|
+
for task in tasks:
|
|
731
|
+
future = executor.submit(_run_hazard_projection_task, task)
|
|
732
|
+
future_map[future] = (task["scenario"], task["model"])
|
|
733
|
+
for done, future in enumerate(as_completed(future_map), 1):
|
|
734
|
+
scenario, model = future_map[future]
|
|
735
|
+
try:
|
|
736
|
+
outcome = future.result()
|
|
737
|
+
except Exception as exc:
|
|
738
|
+
outcome = {
|
|
739
|
+
"model": model,
|
|
740
|
+
"scenario": scenario,
|
|
741
|
+
"results": [],
|
|
742
|
+
"failures": [
|
|
743
|
+
{
|
|
744
|
+
"model": model,
|
|
745
|
+
"scenario": scenario,
|
|
746
|
+
"stage": "worker",
|
|
747
|
+
"error": str(exc),
|
|
748
|
+
}
|
|
749
|
+
],
|
|
750
|
+
"elapsed_seconds": 0.0,
|
|
751
|
+
}
|
|
752
|
+
_consume_outcome(outcome, done)
|
|
753
|
+
except (OSError, PermissionError) as exc:
|
|
754
|
+
print(
|
|
755
|
+
" Warning: parallel model workers unavailable in this environment "
|
|
756
|
+
f"({exc}); falling back to serial execution."
|
|
757
|
+
)
|
|
758
|
+
effective_model_workers = 1
|
|
759
|
+
for done, task in enumerate(tasks, 1):
|
|
760
|
+
_consume_outcome(_run_hazard_projection_task(task), done)
|
|
761
|
+
if not results:
|
|
762
|
+
return {
|
|
763
|
+
'error': 'No projections succeeded.',
|
|
764
|
+
'failed_projection_runs': failed_projection_runs,
|
|
765
|
+
'metadata': {
|
|
766
|
+
'timing': {
|
|
767
|
+
'total_seconds': round(perf_counter() - run_started, 3),
|
|
768
|
+
'mean_model_seconds': 0.0,
|
|
769
|
+
'model_workers_requested': requested_model_workers,
|
|
770
|
+
'model_workers_used': effective_model_workers,
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
# Bucket by (scenario, year, season_number) so SSPs never pool together.
|
|
776
|
+
buckets: Dict[Tuple[str, int, int], List[Dict]] = defaultdict(list)
|
|
777
|
+
for r in results:
|
|
778
|
+
si = r['season_info']
|
|
779
|
+
sc = r['projection']['scenario']
|
|
780
|
+
buckets[(sc, si['year'], si['season_number'])].append(r)
|
|
781
|
+
|
|
782
|
+
assessments = []
|
|
783
|
+
for (sc, y, sn), bucket in sorted(buckets.items()):
|
|
784
|
+
agg = _avg_stats(bucket)
|
|
785
|
+
onsets = sorted(pd.to_datetime(r['season_info']['start']) for r in bucket)
|
|
786
|
+
ends = sorted(pd.to_datetime(r['season_info']['end']) for r in bucket)
|
|
787
|
+
lengths = [r['season_info']['length_days'] for r in bucket]
|
|
788
|
+
total = max(r['season_info']['total'] for r in bucket)
|
|
789
|
+
projections = []
|
|
790
|
+
for r in bucket:
|
|
791
|
+
st = r.get('season_statistics', {})
|
|
792
|
+
projections.append({
|
|
793
|
+
'model': r['projection']['model'],
|
|
794
|
+
'scenario': r['projection']['scenario'],
|
|
795
|
+
'total_precipitation_mm': st.get('total_precipitation_mm'),
|
|
796
|
+
'rainy_days': st.get('rainy_days'),
|
|
797
|
+
'mean_temperature_c': st.get('mean_temperature_c'),
|
|
798
|
+
'mean_tmax_c': st.get('mean_tmax_c'),
|
|
799
|
+
'mean_tmin_c': st.get('mean_tmin_c'),
|
|
800
|
+
'max_tmax_c': st.get('max_tmax_c'),
|
|
801
|
+
'min_tmin_c': st.get('min_tmin_c'),
|
|
802
|
+
'NDD': st.get('NDD'),
|
|
803
|
+
'NTx35': st.get('NTx35'),
|
|
804
|
+
'NTx40': st.get('NTx40'),
|
|
805
|
+
'NDWS': st.get('NDWS'),
|
|
806
|
+
'NDWL0': st.get('NDWL0'),
|
|
807
|
+
})
|
|
808
|
+
assessments.append({
|
|
809
|
+
'scenario': sc,
|
|
810
|
+
'year': y,
|
|
811
|
+
'season_number': sn,
|
|
812
|
+
'total_seasons_per_year': total,
|
|
813
|
+
'n_projections': len(bucket),
|
|
814
|
+
'window': {
|
|
815
|
+
'start_median': onsets[len(onsets) // 2].strftime('%Y-%m-%d'),
|
|
816
|
+
'end_median': ends[len(ends) // 2].strftime('%Y-%m-%d'),
|
|
817
|
+
'length_days_mean': round(mean(lengths), 1),
|
|
818
|
+
},
|
|
819
|
+
'projections': projections,
|
|
820
|
+
'water_balance_methodology': bucket[0].get('water_balance_methodology'),
|
|
821
|
+
'season_statistics': agg,
|
|
822
|
+
'hazard_evaluation': _aggregate_hazard_statuses(bucket, agg),
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
season_slot_warning = None
|
|
826
|
+
if mode == 'auto_detect':
|
|
827
|
+
season_slot_warning = _auto_season_slot_warning(assessments)
|
|
828
|
+
season_detection = _build_ensemble_hazard_season_detection(
|
|
829
|
+
mode=mode,
|
|
830
|
+
warning=season_slot_warning,
|
|
831
|
+
assessments=assessments,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
scenario_ensembles = []
|
|
835
|
+
if not season_slot_warning:
|
|
836
|
+
summary_buckets: Dict[Tuple[str, int, int], List[Dict]] = defaultdict(list)
|
|
837
|
+
for r in results:
|
|
838
|
+
si = r['season_info']
|
|
839
|
+
sc = r['projection']['scenario']
|
|
840
|
+
summary_buckets[(sc, si['season_number'], si['total'])].append(r)
|
|
841
|
+
|
|
842
|
+
for (sc, sn, total), bucket in sorted(summary_buckets.items()):
|
|
843
|
+
agg = _avg_stats(bucket)
|
|
844
|
+
scenario_ensembles.append({
|
|
845
|
+
'scenario': sc,
|
|
846
|
+
'season_number': sn,
|
|
847
|
+
'total_seasons_per_year': total,
|
|
848
|
+
'n_projections': len(bucket),
|
|
849
|
+
'water_balance_methodology': bucket[0].get('water_balance_methodology'),
|
|
850
|
+
'season_statistics': agg,
|
|
851
|
+
'hazard_evaluation': _aggregate_hazard_statuses(bucket, agg),
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
overall_ensemble = None
|
|
855
|
+
unique_season_slots = sorted(
|
|
856
|
+
{(r['season_info']['season_number'], r['season_info']['total']) for r in results}
|
|
857
|
+
)
|
|
858
|
+
cross_scenario_rollup_disabled = len(scenarios) > 1
|
|
859
|
+
cross_season_rollup_disabled = len(unique_season_slots) > 1
|
|
860
|
+
if (not season_slot_warning
|
|
861
|
+
and not cross_scenario_rollup_disabled
|
|
862
|
+
and not cross_season_rollup_disabled):
|
|
863
|
+
overall = _avg_stats(results)
|
|
864
|
+
overall_ensemble = {
|
|
865
|
+
'n_projections': len(results),
|
|
866
|
+
'water_balance_methodology': results[0].get('water_balance_methodology'),
|
|
867
|
+
'season_statistics': overall,
|
|
868
|
+
'hazard_evaluation': _aggregate_hazard_statuses(results, overall),
|
|
869
|
+
'scenario': scenarios[0] if scenarios else None,
|
|
870
|
+
'scenarios': sorted({r['projection']['scenario'] for r in results}),
|
|
871
|
+
'mixed_scenarios': False,
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
out = {
|
|
875
|
+
'crop': crop,
|
|
876
|
+
'location': {'latitude': lat, 'longitude': lon},
|
|
877
|
+
'data_source': 'nex_gddp',
|
|
878
|
+
'period': {'start_year': start_year, 'end_year': end_year},
|
|
879
|
+
'season_mode': mode,
|
|
880
|
+
'season_definition': fixed_season,
|
|
881
|
+
'soil_water_balance': {
|
|
882
|
+
'soilcp': soilcp,
|
|
883
|
+
'soilsat': soilsat,
|
|
884
|
+
'water_balance_window': water_balance_window,
|
|
885
|
+
},
|
|
886
|
+
'water_balance_methodology': results[0].get('water_balance_methodology'),
|
|
887
|
+
'models': models,
|
|
888
|
+
'ensemble_policy': ensemble_policy,
|
|
889
|
+
'scenarios': scenarios,
|
|
890
|
+
'n_total_projections': len(results),
|
|
891
|
+
'assessments': assessments,
|
|
892
|
+
'scenario_ensembles': scenario_ensembles,
|
|
893
|
+
'overall_ensemble': overall_ensemble,
|
|
894
|
+
'season_detection': season_detection,
|
|
895
|
+
}
|
|
896
|
+
warnings: List[str] = []
|
|
897
|
+
if season_slot_warning:
|
|
898
|
+
warnings.append(season_slot_warning)
|
|
899
|
+
out['season_slot_warning'] = season_slot_warning
|
|
900
|
+
if cross_scenario_rollup_disabled:
|
|
901
|
+
warnings.append(
|
|
902
|
+
'Cross-scenario pooled overall ensemble disabled. '
|
|
903
|
+
'Interpret scenario_ensembles and scenario-tagged assessments separately.'
|
|
904
|
+
)
|
|
905
|
+
elif cross_season_rollup_disabled:
|
|
906
|
+
warnings.append(
|
|
907
|
+
'Cross-season pooled overall ensemble disabled. '
|
|
908
|
+
'Interpret scenario_ensembles and assessments by season_number.'
|
|
909
|
+
)
|
|
910
|
+
if warnings:
|
|
911
|
+
out['warning'] = " ".join(warnings)
|
|
912
|
+
if failed_projection_runs:
|
|
913
|
+
out['failed_projection_runs'] = failed_projection_runs
|
|
914
|
+
out['metadata'] = {
|
|
915
|
+
'timing': {
|
|
916
|
+
'total_seconds': round(perf_counter() - run_started, 3),
|
|
917
|
+
'mean_model_seconds': round(
|
|
918
|
+
sum(float(outcome.get('elapsed_seconds', 0.0)) for outcome in task_outcomes)
|
|
919
|
+
/ len(task_outcomes),
|
|
920
|
+
3,
|
|
921
|
+
) if task_outcomes else 0.0,
|
|
922
|
+
'model_workers_requested': requested_model_workers,
|
|
923
|
+
'model_workers_used': effective_model_workers,
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return out
|
|
927
|
+
|
|
928
|
+
# Pretty printer (mirrors hazards.py year/season blocks)
|
|
929
|
+
def _sym(status: str) -> str:
|
|
930
|
+
if status == 'mixed':
|
|
931
|
+
return '??'
|
|
932
|
+
return 'OK' if 'no_stress' in status else '!!' if 'moderate' in status else 'XX'
|
|
933
|
+
|
|
934
|
+
def _fmt_status_counts(block: Dict) -> str:
|
|
935
|
+
counts = block.get('status_counts') or {}
|
|
936
|
+
if not counts:
|
|
937
|
+
return 'n/a'
|
|
938
|
+
return ', '.join(f"{status}={count}" for status, count in sorted(counts.items()))
|
|
939
|
+
|
|
940
|
+
def _bucket_key(b: str) -> int:
|
|
941
|
+
try:
|
|
942
|
+
return int(b.split('-')[0])
|
|
943
|
+
except (ValueError, IndexError):
|
|
944
|
+
return 999
|
|
945
|
+
|
|
946
|
+
def _fmt(v, nd=2):
|
|
947
|
+
"""Format a numeric value, or 'n/a' for None — used in per-projection tables."""
|
|
948
|
+
return f"{v:.{nd}f}" if isinstance(v, (int, float)) else "n/a"
|
|
949
|
+
|
|
950
|
+
def _hazard_spec_by_result_key(result_key: str) -> Optional[Dict[str, str]]:
|
|
951
|
+
for spec in HAZARD_EVAL_SPECS.values():
|
|
952
|
+
if spec['result_key'] == result_key:
|
|
953
|
+
return spec
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
def _print_projection_breakdown(a: Dict) -> None:
|
|
957
|
+
"""
|
|
958
|
+
Show each contributing (model, scenario) projection before the ensemble means,
|
|
959
|
+
so the reader can see what feeds the averages for this year/season.
|
|
960
|
+
"""
|
|
961
|
+
projections = a.get('projections') or []
|
|
962
|
+
if not projections:
|
|
963
|
+
return
|
|
964
|
+
print(f"\n Per-Projection Breakdown ({len(projections)} projection(s) → ensemble mean)")
|
|
965
|
+
print(f" {'─'*66}")
|
|
966
|
+
rows = []
|
|
967
|
+
for p in projections:
|
|
968
|
+
rows.append({
|
|
969
|
+
'Model': p.get('model'),
|
|
970
|
+
'Scenario': p.get('scenario'),
|
|
971
|
+
'Precip_mm': _fmt(p.get('total_precipitation_mm')),
|
|
972
|
+
'Rainy_d': _fmt(p.get('rainy_days')),
|
|
973
|
+
'Tmean_c': _fmt(p.get('mean_temperature_c')),
|
|
974
|
+
'Tmax_c': _fmt(p.get('mean_tmax_c')),
|
|
975
|
+
'Tmin_c': _fmt(p.get('mean_tmin_c')),
|
|
976
|
+
'NTx35': _fmt(p.get('NTx35'), 0),
|
|
977
|
+
'NTx40': _fmt(p.get('NTx40'), 0),
|
|
978
|
+
'NDD': _fmt(p.get('NDD'), 0),
|
|
979
|
+
'NDWS': _fmt(p.get('NDWS'), 0),
|
|
980
|
+
'NDWL0': _fmt(p.get('NDWL0'), 0),
|
|
981
|
+
})
|
|
982
|
+
s = a['season_statistics']
|
|
983
|
+
rows.append({
|
|
984
|
+
'Model': f"ENSEMBLE (mean of {len(projections)})",
|
|
985
|
+
'Scenario': '',
|
|
986
|
+
'Precip_mm': _fmt(s.get('total_precipitation_mm')),
|
|
987
|
+
'Rainy_d': _fmt(s.get('rainy_days')),
|
|
988
|
+
'Tmean_c': _fmt(s.get('mean_temperature_c')),
|
|
989
|
+
'Tmax_c': _fmt(s.get('mean_tmax_c')),
|
|
990
|
+
'Tmin_c': _fmt(s.get('mean_tmin_c')),
|
|
991
|
+
'NTx35': _fmt(s.get('NTx35')),
|
|
992
|
+
'NTx40': _fmt(s.get('NTx40')),
|
|
993
|
+
'NDD': _fmt(s.get('NDD')),
|
|
994
|
+
'NDWS': _fmt(s.get('NDWS')),
|
|
995
|
+
'NDWL0': _fmt(s.get('NDWL0')),
|
|
996
|
+
})
|
|
997
|
+
for line in pd.DataFrame(rows).to_string(index=False).splitlines():
|
|
998
|
+
print(f" {line}")
|
|
999
|
+
|
|
1000
|
+
def _print_block(a: Dict, crop: str, lat: float, lon: float,
|
|
1001
|
+
mode: str, n_models: int, n_scenarios: int) -> None:
|
|
1002
|
+
y, sn, t = a['year'], a['season_number'], a['total_seasons_per_year']
|
|
1003
|
+
label = f"Year {y} - Season {sn} of {t}" if t > 1 else f"Year {y}"
|
|
1004
|
+
scenario = a.get('scenario')
|
|
1005
|
+
|
|
1006
|
+
print(f"\n{'─'*70}")
|
|
1007
|
+
print(f" {label} [{scenario}] (ensemble of {a['n_projections']} projections)")
|
|
1008
|
+
print(f"\n{'='*70}")
|
|
1009
|
+
print(f" CROP HAZARD ASSESSMENT (ENSEMBLE): {crop.upper()}")
|
|
1010
|
+
print(f"{'='*70}")
|
|
1011
|
+
print(f" Location: {lat:.4f}, {lon:.4f}")
|
|
1012
|
+
|
|
1013
|
+
w = a['window']
|
|
1014
|
+
print(f"\n Season Information")
|
|
1015
|
+
print(f" {'─'*66}")
|
|
1016
|
+
print(f" Onset (median): {w['start_median']:<20} End (median): {w['end_median']}")
|
|
1017
|
+
print(f" Length (mean): {w['length_days_mean']} days{'':10} Method: {mode}")
|
|
1018
|
+
print(f" Source: nex_gddp ({n_models} models × {n_scenarios} scenarios)")
|
|
1019
|
+
|
|
1020
|
+
_print_projection_breakdown(a)
|
|
1021
|
+
|
|
1022
|
+
s = a['season_statistics']
|
|
1023
|
+
if 'total_precipitation_mm' in s:
|
|
1024
|
+
print(f"\n Precipitation Statistics (ensemble means)")
|
|
1025
|
+
print(f" {'─'*66}")
|
|
1026
|
+
print(f" {'Metric':<32} {'Value':>15} Unit")
|
|
1027
|
+
print(f" {'─'*32} {'─'*15} {'─'*10}")
|
|
1028
|
+
print(f" {'Total':<32} {s['total_precipitation_mm']:>15.2f} mm")
|
|
1029
|
+
print(f" {'Daily Mean':<32} {s.get('mean_daily_precipitation_mm', 0):>15.2f} mm")
|
|
1030
|
+
print(f" {'Daily Maximum':<32} {s.get('max_daily_precipitation_mm', 0):>15.2f} mm")
|
|
1031
|
+
print(f" {'Rainy Days (>=1mm)':<32} {s.get('rainy_days', 0):>15.2f} days")
|
|
1032
|
+
print(f" {'Dry Days (<1mm)':<32} {s.get('dry_days', 0):>15.2f} days")
|
|
1033
|
+
|
|
1034
|
+
if 'dry_spell_statistics' in s:
|
|
1035
|
+
ds = s['dry_spell_statistics']
|
|
1036
|
+
print(f"\n Dry Spell Statistics (ensemble means; >=7 consecutive days <1mm)")
|
|
1037
|
+
print(f" {'─'*66}")
|
|
1038
|
+
print(f" {'Number of Dry Spells':<32} {ds['number_of_dry_spells']:>15.2f} spells")
|
|
1039
|
+
print(f" {'Max Dry Spell Length':<32} {ds['max_dry_spell_length_days']:>15.2f} days")
|
|
1040
|
+
print(f" {'Mean Dry Spell Length':<32} {ds['mean_dry_spell_length_days']:>15.2f} days")
|
|
1041
|
+
|
|
1042
|
+
if ds.get('length_distribution'):
|
|
1043
|
+
print(f"\n Length Distribution (mean spell count per bucket)")
|
|
1044
|
+
print(f" {'─'*66}")
|
|
1045
|
+
for rng in sorted(ds['length_distribution'].keys(), key=_bucket_key):
|
|
1046
|
+
cnt = ds['length_distribution'][rng]
|
|
1047
|
+
print(f" {rng:<15} days: {cnt:>6.2f} spell(s)")
|
|
1048
|
+
|
|
1049
|
+
if 'mean_temperature_c' in s:
|
|
1050
|
+
print(f"\n Temperature Statistics (ensemble means)")
|
|
1051
|
+
print(f" {'─'*66}")
|
|
1052
|
+
print(f" {'Metric':<32} {'Value':>15} Unit")
|
|
1053
|
+
print(f" {'─'*32} {'─'*15} {'─'*10}")
|
|
1054
|
+
print(f" {'Mean Temperature':<32} {s['mean_temperature_c']:>15.2f} deg C")
|
|
1055
|
+
print(f" {'Mean Tmax':<32} {s.get('mean_tmax_c', 0):>15.2f} deg C")
|
|
1056
|
+
print(f" {'Mean Tmin':<32} {s.get('mean_tmin_c', 0):>15.2f} deg C")
|
|
1057
|
+
print(f" {'Max Tmax':<32} {s.get('max_tmax_c', s.get('max_temperature_c', 0)):>15.2f} deg C")
|
|
1058
|
+
print(f" {'Min Tmin':<32} {s.get('min_tmin_c', s.get('min_temperature_c', 0)):>15.2f} deg C")
|
|
1059
|
+
|
|
1060
|
+
# Hazard index counts (NTx35, NTx40, NDD, NDWS, NDWL0) -- ensemble means
|
|
1061
|
+
has_counts = any(k in s for k in ('mean_humidex', 'humidex_high_days', 'humidex_extreme_days', 'NTx35', 'NTx40', 'NDD', 'NDWS', 'NDWL0', 'WRSI'))
|
|
1062
|
+
if has_counts:
|
|
1063
|
+
print(f"\n Hazard Index Counts (ensemble means)")
|
|
1064
|
+
print(f" {'─'*66}")
|
|
1065
|
+
print(f" {'Index':<32} {'Value':>15} Unit")
|
|
1066
|
+
print(f" {'─'*32} {'─'*15} {'─'*10}")
|
|
1067
|
+
if 'WRSI' in s:
|
|
1068
|
+
print(f" {'WRSI (seasonal satisfaction)':<32} {s['WRSI']:>15.2f} %")
|
|
1069
|
+
if 'mean_humidex' in s:
|
|
1070
|
+
print(f" {'Mean Humidex':<32} {s['mean_humidex']:>15.2f} index")
|
|
1071
|
+
if 'max_humidex' in s:
|
|
1072
|
+
print(f" {'Max Humidex':<32} {s['max_humidex']:>15.2f} index")
|
|
1073
|
+
if 'humidex_high_days' in s:
|
|
1074
|
+
print(f" {'Humidex high days':<32} {s['humidex_high_days']:>15.2f} days")
|
|
1075
|
+
if 'humidex_extreme_days' in s:
|
|
1076
|
+
print(f" {'Humidex extreme days':<32} {s['humidex_extreme_days']:>15.2f} days")
|
|
1077
|
+
if 'crop_water_requirement_mm' in s:
|
|
1078
|
+
print(f" {'Crop Water Requirement':<32} {s['crop_water_requirement_mm']:>15.2f} mm")
|
|
1079
|
+
if 'actual_crop_et_mm' in s:
|
|
1080
|
+
print(f" {'Actual Crop ET':<32} {s['actual_crop_et_mm']:>15.2f} mm")
|
|
1081
|
+
if 'NTx35' in s:
|
|
1082
|
+
print(f" {'NTx35 (days Tmax > 35C)':<32} {s['NTx35']:>15.2f} days")
|
|
1083
|
+
if 'NTx40' in s:
|
|
1084
|
+
print(f" {'NTx40 (days Tmax > 40C)':<32} {s['NTx40']:>15.2f} days")
|
|
1085
|
+
if 'NDD' in s:
|
|
1086
|
+
print(f" {'NDD (dry days, <1mm)':<32} {s['NDD']:>15.2f} days")
|
|
1087
|
+
if 'NDWS' in s:
|
|
1088
|
+
print(f" {'NDWS (water-stress days)':<32} {s['NDWS']:>15.2f} days")
|
|
1089
|
+
if 'NDWL0' in s:
|
|
1090
|
+
print(f" {'NDWL0 (water-logging days)':<32} {s['NDWL0']:>15.2f} days")
|
|
1091
|
+
if 'NDWS' in s or 'NDWL0' in s:
|
|
1092
|
+
print(" Note: NDWS/NDWL0 come from crop water-balance model, not raw precip-minus-ET0 counts.")
|
|
1093
|
+
methodology = a.get("water_balance_methodology") or {}
|
|
1094
|
+
count_window = methodology.get("count_window") or {}
|
|
1095
|
+
if count_window.get("applied_mode") == CROP_ACTIVE_WATER_BALANCE:
|
|
1096
|
+
print(" Count window: crop-active ETO sub-season(s) inside fixed window.")
|
|
1097
|
+
|
|
1098
|
+
h = a['hazard_evaluation']
|
|
1099
|
+
print(f"\n Hazard Assessment (aggregated from projection statuses)")
|
|
1100
|
+
print(f" {'─'*66}")
|
|
1101
|
+
print(f" {'Indicator':<25} {'Value':>18} Status")
|
|
1102
|
+
print(f" {'─'*25} {'─'*18} {'─'*20}")
|
|
1103
|
+
for result_key in HAZARD_PRINT_ORDER:
|
|
1104
|
+
if result_key not in h:
|
|
1105
|
+
continue
|
|
1106
|
+
spec = _hazard_spec_by_result_key(result_key)
|
|
1107
|
+
item = h[result_key]
|
|
1108
|
+
value = item.get(spec['value_key'])
|
|
1109
|
+
print(f" {spec['label']:<25} {value:>16.2f} {spec['unit']:<4} "
|
|
1110
|
+
f"[{_sym(item['status'])}] {item['status'].replace('_', ' ').upper()}")
|
|
1111
|
+
print(f" {'':<25} {'':>18} distribution: {_fmt_status_counts(item)}")
|
|
1112
|
+
print(f"\n{'='*70}")
|
|
1113
|
+
|
|
1114
|
+
def _print_overall_summary(summary: Dict, multi_scenario: bool) -> None:
|
|
1115
|
+
scenario = summary.get('scenario')
|
|
1116
|
+
s = summary.get('season_statistics', {})
|
|
1117
|
+
h = summary.get('hazard_evaluation', {})
|
|
1118
|
+
print(f"\n{'─'*70}")
|
|
1119
|
+
if multi_scenario:
|
|
1120
|
+
slot = summary.get('season_number')
|
|
1121
|
+
total = summary.get('total_seasons_per_year')
|
|
1122
|
+
if slot and total and total > 1:
|
|
1123
|
+
print(f" SCENARIO ENSEMBLE [{scenario}] season {slot}/{total}")
|
|
1124
|
+
else:
|
|
1125
|
+
print(f" SCENARIO ENSEMBLE [{scenario}]")
|
|
1126
|
+
else:
|
|
1127
|
+
print(" OVERALL ENSEMBLE")
|
|
1128
|
+
print(f" n = {summary['n_projections']} projections")
|
|
1129
|
+
if summary.get('warning'):
|
|
1130
|
+
print(f" Warning: {summary['warning']}")
|
|
1131
|
+
print(f" {'─'*66}")
|
|
1132
|
+
print(f" Precipitation (mean): {s.get('total_precipitation_mm', 0):.2f} mm per season")
|
|
1133
|
+
print(f" Temperature (mean): {s.get('mean_temperature_c', 0):.2f} deg C")
|
|
1134
|
+
if 'max_tmax_c' in s or 'min_tmin_c' in s:
|
|
1135
|
+
print(f" Max Tmax / Min Tmin : {s.get('max_tmax_c', 0):.2f} / "
|
|
1136
|
+
f"{s.get('min_tmin_c', 0):.2f} deg C")
|
|
1137
|
+
if any(k in s for k in ('mean_humidex', 'humidex_high_days', 'humidex_extreme_days', 'NTx35', 'NTx40', 'NDD', 'NDWS', 'NDWL0', 'WRSI')):
|
|
1138
|
+
parts = []
|
|
1139
|
+
if 'WRSI' in s: parts.append(f"WRSI={s['WRSI']:.2f}%")
|
|
1140
|
+
if 'mean_humidex' in s: parts.append(f"Humidex={s['mean_humidex']:.2f}")
|
|
1141
|
+
if 'humidex_high_days' in s: parts.append(f"HumidexHigh={s['humidex_high_days']:.2f}")
|
|
1142
|
+
if 'humidex_extreme_days' in s: parts.append(f"HumidexExtreme={s['humidex_extreme_days']:.2f}")
|
|
1143
|
+
if 'NTx35' in s: parts.append(f"NTx35={s['NTx35']:.2f}")
|
|
1144
|
+
if 'NTx40' in s: parts.append(f"NTx40={s['NTx40']:.2f}")
|
|
1145
|
+
if 'NDD' in s: parts.append(f"NDD={s['NDD']:.2f}")
|
|
1146
|
+
if 'NDWS' in s: parts.append(f"NDWS={s['NDWS']:.2f}")
|
|
1147
|
+
if 'NDWL0' in s: parts.append(f"NDWL0={s['NDWL0']:.2f}")
|
|
1148
|
+
print(f" Hazard indices : {' '.join(parts)} (mean days per season)")
|
|
1149
|
+
methodology = summary.get("water_balance_methodology") or {}
|
|
1150
|
+
count_window = methodology.get("count_window") or {}
|
|
1151
|
+
if count_window.get("applied_mode") == CROP_ACTIVE_WATER_BALANCE:
|
|
1152
|
+
print(" Count window : crop-active ETO sub-season(s) inside fixed window")
|
|
1153
|
+
if 'dry_spell_statistics' in s:
|
|
1154
|
+
ds = s['dry_spell_statistics']
|
|
1155
|
+
print(f" Dry spells (mean): {ds['number_of_dry_spells']:.2f} per season "
|
|
1156
|
+
f"(max length {ds['max_dry_spell_length_days']:.2f} days)")
|
|
1157
|
+
for result_key in HAZARD_PRINT_ORDER:
|
|
1158
|
+
if result_key not in h:
|
|
1159
|
+
continue
|
|
1160
|
+
spec = _hazard_spec_by_result_key(result_key)
|
|
1161
|
+
print(f" {spec['label']:<18} status: [{_sym(h[result_key]['status'])}] "
|
|
1162
|
+
f"{h[result_key]['status'].replace('_', ' ').upper()} "
|
|
1163
|
+
f"({_fmt_status_counts(h[result_key])})")
|
|
1164
|
+
|
|
1165
|
+
def print_results(r: Dict) -> None:
|
|
1166
|
+
if 'error' in r:
|
|
1167
|
+
print(f"\nError: {r['error']}")
|
|
1168
|
+
_print_hazard_season_detection_summary(r.get('season_detection'))
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
crop = r['crop']
|
|
1172
|
+
lat, lon = r['location']['latitude'], r['location']['longitude']
|
|
1173
|
+
mode = r['season_mode'] + (f" ({r['season_definition']})" if r.get('season_definition') else '')
|
|
1174
|
+
nm, ns = len(r['models']), len(r['scenarios'])
|
|
1175
|
+
|
|
1176
|
+
print(f"\n{'='*70}")
|
|
1177
|
+
print(f" ENSEMBLE HAZARD ASSESSMENT (NEX-GDDP)")
|
|
1178
|
+
print(f"{'='*70}")
|
|
1179
|
+
print(f" Crop: {crop}")
|
|
1180
|
+
print(f" Location: {lat:.4f}, {lon:.4f}")
|
|
1181
|
+
print(f" Period: {r['period']['start_year']} -> {r['period']['end_year']}")
|
|
1182
|
+
print(f" Mode: {mode}")
|
|
1183
|
+
print(f" Models: {nm}")
|
|
1184
|
+
print(f" Scenarios: {', '.join(r['scenarios'])}")
|
|
1185
|
+
print(f" Total projections: {r['n_total_projections']}")
|
|
1186
|
+
_print_hazard_season_detection_summary(r.get('season_detection'))
|
|
1187
|
+
|
|
1188
|
+
for a in r['assessments']:
|
|
1189
|
+
_print_block(a, crop, lat, lon, mode, nm, ns)
|
|
1190
|
+
|
|
1191
|
+
summaries = r.get('scenario_ensembles') or []
|
|
1192
|
+
if len(summaries) > 1:
|
|
1193
|
+
print(f"\n{'─'*70}")
|
|
1194
|
+
print(" Scenario boundaries preserved in scenario_ensembles and assessments.")
|
|
1195
|
+
for summary in summaries:
|
|
1196
|
+
_print_overall_summary(summary, multi_scenario=True)
|
|
1197
|
+
if r.get('warning'):
|
|
1198
|
+
print(f"\n Warning: {r['warning']}")
|
|
1199
|
+
if r.get('overall_ensemble'):
|
|
1200
|
+
_print_overall_summary(r['overall_ensemble'], multi_scenario=False)
|
|
1201
|
+
print(f"\n{'='*70}\n")
|
|
1202
|
+
|
|
1203
|
+
def main() -> int:
|
|
1204
|
+
p = argparse.ArgumentParser(
|
|
1205
|
+
description='Ensemble of hazards.py across NEX-GDDP models x scenarios.',
|
|
1206
|
+
)
|
|
1207
|
+
p.add_argument('crop', nargs='?', default='maize',
|
|
1208
|
+
help='Crop name (default: maize). Same options as hazards.py.')
|
|
1209
|
+
p.add_argument('--list-models', action='store_true',
|
|
1210
|
+
help='Print available models & scenarios, then exit.')
|
|
1211
|
+
p.add_argument('--location', type=str, help='"lat,lon"')
|
|
1212
|
+
p.add_argument('--start-year', type=int)
|
|
1213
|
+
p.add_argument('--end-year', type=int)
|
|
1214
|
+
p.add_argument('--fixed-season', type=str, default=None,
|
|
1215
|
+
metavar='MM-DD:MM-DD[,MM-DD:MM-DD]',
|
|
1216
|
+
help="omit for auto-detect; otherwise single, two, or year-crossing windows")
|
|
1217
|
+
p.add_argument('--models', type=str, default=None,
|
|
1218
|
+
help='comma-separated GCMs (default: location-aware ensemble)')
|
|
1219
|
+
p.add_argument('--policy-profile', choices=POLICY_PROFILE_CHOICES, default='default',
|
|
1220
|
+
help=POLICY_PROFILE_HELP)
|
|
1221
|
+
p.add_argument('--scenarios', type=str, default=','.join(SCENARIOS),
|
|
1222
|
+
help=f"comma-separated scenarios (default: {','.join(SCENARIOS)})")
|
|
1223
|
+
p.add_argument('--soilcp', type=float, default=DEFAULT_SOILCP,
|
|
1224
|
+
help=f'Soil available water capacity at field capacity, mm '
|
|
1225
|
+
f'(water-balance NDWS/NDWL0; default: {DEFAULT_SOILCP})')
|
|
1226
|
+
p.add_argument('--soilsat', type=float, default=DEFAULT_SOILSAT,
|
|
1227
|
+
help=f'Extra soil water from field capacity to saturation, mm '
|
|
1228
|
+
f'(water-balance NDWL0; default: {DEFAULT_SOILSAT})')
|
|
1229
|
+
p.add_argument('--water-balance-window', type=str,
|
|
1230
|
+
choices=list(WATER_BALANCE_WINDOW_CHOICES),
|
|
1231
|
+
default=FULL_WINDOW_WATER_BALANCE,
|
|
1232
|
+
help='NDWS/NDWL0 count window for fixed seasons: full_window or crop_active')
|
|
1233
|
+
p.add_argument('--thresholds-file', type=str, default=None,
|
|
1234
|
+
help=('Optional JSON file overriding threshold bands by metric name. '
|
|
1235
|
+
'Metrics omitted from file keep package defaults.'))
|
|
1236
|
+
p.add_argument('--format', choices=['json', 'text'], default='text')
|
|
1237
|
+
p.add_argument('--output', type=str, default=None,
|
|
1238
|
+
help='write full result as JSON to this path')
|
|
1239
|
+
p.add_argument('--model-workers', type=int, default=8,
|
|
1240
|
+
help=("Parallel NEX-GDDP model workers for ensemble runs. "
|
|
1241
|
+
"8=safe default, 12=heavier jobs, 16=aggressive upper practical setting. "
|
|
1242
|
+
"Use 1 for serial deep debugging."))
|
|
1243
|
+
args = p.parse_args()
|
|
1244
|
+
|
|
1245
|
+
if args.list_models:
|
|
1246
|
+
print("Models:"); [print(f" {m}") for m in MODELS]
|
|
1247
|
+
print("\nScenarios:"); [print(f" {s}") for s in SCENARIOS]
|
|
1248
|
+
return 0
|
|
1249
|
+
|
|
1250
|
+
missing = [n for n, v in (('--location', args.location),
|
|
1251
|
+
('--start-year', args.start_year),
|
|
1252
|
+
('--end-year', args.end_year)) if v in (None, '')]
|
|
1253
|
+
if missing:
|
|
1254
|
+
p.error(f"missing required arguments: {', '.join(missing)}")
|
|
1255
|
+
|
|
1256
|
+
lat, lon = map(float, args.location.split(','))
|
|
1257
|
+
sub_models = [s.strip() for s in args.models.split(',') if s.strip()] if args.models else None
|
|
1258
|
+
ensemble_policy = resolve_ensemble_policy_for_location(
|
|
1259
|
+
(lat, lon),
|
|
1260
|
+
models=sub_models,
|
|
1261
|
+
policy_profile=None if args.policy_profile == 'default' else args.policy_profile,
|
|
1262
|
+
)
|
|
1263
|
+
models = ensemble_policy["models"]
|
|
1264
|
+
scenarios = [s.strip() for s in args.scenarios.split(',') if s.strip()]
|
|
1265
|
+
custom_thresholds = load_custom_thresholds_file(args.thresholds_file)
|
|
1266
|
+
|
|
1267
|
+
result = calculate_ensemble(
|
|
1268
|
+
crop=args.crop,
|
|
1269
|
+
lat=lat, lon=lon,
|
|
1270
|
+
start_year=args.start_year, end_year=args.end_year,
|
|
1271
|
+
models=models, scenarios=scenarios,
|
|
1272
|
+
fixed_season=args.fixed_season,
|
|
1273
|
+
custom_thresholds=custom_thresholds,
|
|
1274
|
+
soilcp=args.soilcp, soilsat=args.soilsat,
|
|
1275
|
+
water_balance_window=args.water_balance_window,
|
|
1276
|
+
ensemble_policy=ensemble_policy,
|
|
1277
|
+
model_workers=args.model_workers,
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
if args.format == 'json':
|
|
1281
|
+
print(json.dumps(result, indent=2, default=str))
|
|
1282
|
+
else:
|
|
1283
|
+
print_results(result)
|
|
1284
|
+
|
|
1285
|
+
if args.output:
|
|
1286
|
+
os.makedirs(os.path.dirname(os.path.abspath(args.output)) or '.', exist_ok=True)
|
|
1287
|
+
with open(args.output, 'w') as f:
|
|
1288
|
+
json.dump(result, f, indent=2, default=str)
|
|
1289
|
+
print(f"Saved to: {args.output}")
|
|
1290
|
+
return 1 if "error" in result else 0
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
if __name__ == "__main__":
|
|
1294
|
+
sys.exit(main())
|
|
1295
|
+
|
|
1296
|
+
# NOTE: the 1st command in a section includes all models/scenarios while the 2nd allows selection
|
|
1297
|
+
|
|
1298
|
+
# Fixed single season:
|
|
1299
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards millet --location="-1.286,36.817" --start-year 2040 --end-year 2060 --fixed-season "03-01:05-31" --scenarios ssp245,ssp585 --output ensemble_mam_all.json
|
|
1300
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards maize --location="-1.286,36.817" --start-year 2040 --end-year 2060 --fixed-season "03-01:05-31" --models "ACCESS-CM2,EC-Earth3,MRI-ESM2-0" --scenarios ssp585 --output ensemble_mam.json
|
|
1301
|
+
|
|
1302
|
+
# Fixed two seasons:
|
|
1303
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards rice --location="-1.286,36.817" --start-year 2040 --end-year 2060 --fixed-season "03-01:05-31,10-01:12-15" --scenarios ssp245,ssp585 --output ensemble_mam_ond_all.json
|
|
1304
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards beans --location="-1.286,36.817" --start-year 2040 --end-year 2060 --fixed-season "03-01:05-31,10-01:12-15" --models "ACCESS-CM2,EC-Earth3,MRI-ESM2-0" --scenarios ssp245,ssp585 --output ensemble_mam_ond.json
|
|
1305
|
+
|
|
1306
|
+
# Fixed year-crossing season:
|
|
1307
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards sorghum --location="-1.286,36.817" --start-year 2040 --end-year 2060 --fixed-season "11-01:02-28" --scenarios ssp245,ssp585 --output ensemble_njf_all.json
|
|
1308
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards cassava --location="-1.286,36.817" --start-year 2040 --end-year 2060 --fixed-season "11-01:02-28" --models "ACCESS-CM2,EC-Earth3,MRI-ESM2-0" --scenarios ssp585 --output ensemble_njf.json
|
|
1309
|
+
|
|
1310
|
+
# List available NEX-GDDP models and scenarios:
|
|
1311
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards --list-models
|
|
1312
|
+
|
|
1313
|
+
# Auto-detect season (NEX-GDDP per (model, scenario), no flag) -- all models, pick scenarios:
|
|
1314
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards maize --location="-1.286,36.817" --start-year 2040 --end-year 2060 --scenarios ssp245,ssp585 --output ensemble_auto_all.json
|
|
1315
|
+
|
|
1316
|
+
# Auto-detect season -- custom subset:
|
|
1317
|
+
# python -m climate_toolkit.calculate_hazards.ensemble_hazards maize --location="-1.286,36.817" --start-year 2040 --end-year 2060 --models "ACCESS-CM2,EC-Earth3,MRI-ESM2-0" --scenarios ssp245,ssp585 --output ensemble_auto.json
|