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.
Files changed (85) hide show
  1. climate_toolkit/__init__.py +109 -0
  2. climate_toolkit/_resources.py +45 -0
  3. climate_toolkit/calculate_hazards/__init__.py +14 -0
  4. climate_toolkit/calculate_hazards/crop_water_balance_params.json +58 -0
  5. climate_toolkit/calculate_hazards/ensemble_hazards.py +1317 -0
  6. climate_toolkit/calculate_hazards/hazards.py +3272 -0
  7. climate_toolkit/cli.py +89 -0
  8. climate_toolkit/climate_statistics/__init__.py +14 -0
  9. climate_toolkit/climate_statistics/ensemble_statistics.py +1065 -0
  10. climate_toolkit/climate_statistics/statistics.py +3375 -0
  11. climate_toolkit/climatology/__init__.py +104 -0
  12. climate_toolkit/climatology/_cli_common.py +172 -0
  13. climate_toolkit/climatology/heat_stress.py +675 -0
  14. climate_toolkit/climatology/human_heat_stress.py +389 -0
  15. climate_toolkit/climatology/long_term_climatology.py +1939 -0
  16. climate_toolkit/climatology/spei.py +745 -0
  17. climate_toolkit/climatology/vpd.py +246 -0
  18. climate_toolkit/climatology/xclim_reference.py +988 -0
  19. climate_toolkit/compare_datasets/__init__.py +14 -0
  20. climate_toolkit/compare_datasets/compare_datasets.py +1070 -0
  21. climate_toolkit/compare_periods/__init__.py +14 -0
  22. climate_toolkit/compare_periods/ensemble_periods.py +2899 -0
  23. climate_toolkit/compare_periods/periods.py +1892 -0
  24. climate_toolkit/crop_calendar/__init__.py +94 -0
  25. climate_toolkit/crop_calendar/ggcmi.py +198 -0
  26. climate_toolkit/crop_calendar/registry.py +102 -0
  27. climate_toolkit/data/__init__.py +1 -0
  28. climate_toolkit/data/ggcmi_phase3/__init__.py +1 -0
  29. climate_toolkit/data/ggcmi_phase3/crop_calendar.parquet +0 -0
  30. climate_toolkit/data/ggcmi_phase3/crop_calendar_manifest.json +42 -0
  31. climate_toolkit/fetch_data/__init__.py +61 -0
  32. climate_toolkit/fetch_data/cache_inventory.py +378 -0
  33. climate_toolkit/fetch_data/fetch_data.py +645 -0
  34. climate_toolkit/fetch_data/gee_xee_batch.py +1133 -0
  35. climate_toolkit/fetch_data/multi_site.py +220 -0
  36. climate_toolkit/fetch_data/nex_gddp_batch.py +1017 -0
  37. climate_toolkit/fetch_data/preprocess_data/__init__.py +18 -0
  38. climate_toolkit/fetch_data/preprocess_data/preprocess_data.py +332 -0
  39. climate_toolkit/fetch_data/runtime_notes.py +80 -0
  40. climate_toolkit/fetch_data/source_data/__init__.py +15 -0
  41. climate_toolkit/fetch_data/source_data/source_data.py +398 -0
  42. climate_toolkit/fetch_data/source_data/sources/agera_5.py +15 -0
  43. climate_toolkit/fetch_data/source_data/sources/chirps.py +63 -0
  44. climate_toolkit/fetch_data/source_data/sources/chirts.py +63 -0
  45. climate_toolkit/fetch_data/source_data/sources/era_5.py +15 -0
  46. climate_toolkit/fetch_data/source_data/sources/gee.py +999 -0
  47. climate_toolkit/fetch_data/source_data/sources/gee_xee.py +107 -0
  48. climate_toolkit/fetch_data/source_data/sources/ghcn_daily.py +87 -0
  49. climate_toolkit/fetch_data/source_data/sources/gsod.py +87 -0
  50. climate_toolkit/fetch_data/source_data/sources/imerg.py +106 -0
  51. climate_toolkit/fetch_data/source_data/sources/nasa_power.py +414 -0
  52. climate_toolkit/fetch_data/source_data/sources/nex_gddp.py +357 -0
  53. climate_toolkit/fetch_data/source_data/sources/nex_gddp_xee.py +1062 -0
  54. climate_toolkit/fetch_data/source_data/sources/soil_grid.py +58 -0
  55. climate_toolkit/fetch_data/source_data/sources/tamsat.py +647 -0
  56. climate_toolkit/fetch_data/source_data/sources/terraclimate.py +96 -0
  57. climate_toolkit/fetch_data/source_data/sources/utils/__init__.py +177 -0
  58. climate_toolkit/fetch_data/source_data/sources/utils/config.yaml +227 -0
  59. climate_toolkit/fetch_data/source_data/sources/utils/models.py +539 -0
  60. climate_toolkit/fetch_data/source_data/sources/utils/settings.py +206 -0
  61. climate_toolkit/fetch_data/source_data/sources/xee_common.py +221 -0
  62. climate_toolkit/fetch_data/transform_data/__init__.py +20 -0
  63. climate_toolkit/fetch_data/transform_data/data_dictionary.yaml +96 -0
  64. climate_toolkit/fetch_data/transform_data/transform_data.py +343 -0
  65. climate_toolkit/gee_check.py +280 -0
  66. climate_toolkit/pipeline.py +307 -0
  67. climate_toolkit/season_analysis/__init__.py +24 -0
  68. climate_toolkit/season_analysis/ensemble.py +1016 -0
  69. climate_toolkit/season_analysis/season_identity.py +58 -0
  70. climate_toolkit/season_analysis/seasons.py +1817 -0
  71. climate_toolkit/weather_station/__init__.py +73 -0
  72. climate_toolkit/weather_station/compare.py +3236 -0
  73. climate_toolkit/weather_station/custom_station.py +464 -0
  74. climate_toolkit/weather_station/dem.py +153 -0
  75. climate_toolkit/weather_station/download.py +1440 -0
  76. climate_toolkit/weather_station/ghcn_daily.py +832 -0
  77. climate_toolkit/weather_station/gsod.py +835 -0
  78. climate_toolkit/weather_station/overrides.py +174 -0
  79. climate_toolkit/weather_station/station_selector.py +607 -0
  80. climate_toolkit-0.1.0.dist-info/METADATA +1548 -0
  81. climate_toolkit-0.1.0.dist-info/RECORD +85 -0
  82. climate_toolkit-0.1.0.dist-info/WHEEL +5 -0
  83. climate_toolkit-0.1.0.dist-info/entry_points.txt +18 -0
  84. climate_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  85. 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