DHDRL commited on
Commit
9195c78
·
verified ·
1 Parent(s): 976eb45

Upload 3 files

Browse files
Files changed (3) hide show
  1. climatology.py +834 -0
  2. requirements.txt +62 -0
  3. train_curriculum.py +1045 -0
climatology.py ADDED
@@ -0,0 +1,834 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ climatology.py
3
+ ==============
4
+ Per-zone day-of-year climatology and anomaly (z-score) computation.
5
+
6
+ WHY THIS MODULE EXISTS (the gap it closes)
7
+ ------------------------------------------
8
+ Every real fetcher in era5_data_pipeline.py (_build_era5_obs,
9
+ _fetch_openmeteo, _fetch_imerg, _fetch_smap) sets precip_anomaly_idx /
10
+ temp_anomaly_idx / soil_moisture_anom to 0.0 with the comment "requires
11
+ climatology -- set in scorer". Nothing ever supplied that climatology, and
12
+ the scorer never set the fields either. The consequences are structural:
13
+
14
+ * ZoneObs.drought_signal() = 0.6 * clip(-precip_anomaly/3)
15
+ + 0.4 * clip(-soil_anom/3)
16
+ * ZoneObs.flood_signal() = 0.4 * clip(+precip_anomaly/3) + ...
17
+
18
+ With all anomalies pinned at 0.0, drought_signal() is identically 0.0 and
19
+ flood_signal() loses its primary term on every real observation. The whole
20
+ risk-scoring stack (crop_risk_scorer, the env's belief initialisation, the
21
+ hierarchical search gating) was effectively only sensitive on SYNTHETIC
22
+ data, where anomalies are injected directly by the event flags. This module
23
+ is the missing climatology layer: it turns absolute real-world readings
24
+ into the z-score anomalies the rest of the pipeline was designed around.
25
+
26
+ DATA SOURCES (two tiers, matching the codebase's degrade-safely philosophy)
27
+ ----------------------------------------------------------------------------
28
+ 1. Real: Open-Meteo historical archive API (free, no API key -- the same
29
+ endpoint era5_data_pipeline._fetch_openmeteo already uses). Daily
30
+ precipitation_sum + temperature_2m_mean are well-established archive
31
+ variables. soil_moisture_0_to_7cm_mean is requested as documented in
32
+ the Open-Meteo archive docs at write time; if the API rejects it or
33
+ returns nothing, soil climatology degrades to the precip-tracked
34
+ model below, logged -- never silently zeroed.
35
+ 2. Synthetic: a deterministic, latitude-aware maritime-continent monsoon
36
+ model (see _synthetic_climatology). It is a HEURISTIC, calibrated to
37
+ the broad shape of the Indonesian wet/dry season (SH monsoon: wet
38
+ Dec-Mar, dry Jun-Sep; weaker/bimodal near the equator; shifted peak
39
+ for northern Sumatra). It exists so the pipeline keeps producing
40
+ sensible anomalies offline and in tests. It is NOT a retrieval --
41
+ treat its absolute values as plausible shapes, not measurements.
42
+
43
+ IMPORTANT USAGE NOTE
44
+ --------------------
45
+ apply_climatology_anomalies() SKIPS observations whose source is
46
+ DataSource.SYNTHETIC. The synthetic generator injects its own meaningful
47
+ anomalies via the drought/flood event flags; re-scoring those against a
48
+ climatology would double-transform a deliberately-constructed signal.
49
+ Climatology anomalies are for REAL observations only.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ import logging
56
+ import math
57
+ import os
58
+ from dataclasses import dataclass, field
59
+ from datetime import datetime, timedelta, timezone
60
+ from pathlib import Path
61
+ from typing import Any, Dict, List, Optional, Tuple
62
+
63
+ import zone_observation as _zo
64
+
65
+ assert _zo.SCHEMA_VERSION == 3, (
66
+ f"climatology: zone_observation schema mismatch "
67
+ f"(expected 3, got {_zo.SCHEMA_VERSION})"
68
+ )
69
+
70
+ from zone_observation import DataSource, ZoneObs, _clip, _stable_seed
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ try:
75
+ import requests
76
+ _REQUESTS_AVAILABLE = True
77
+ except ImportError:
78
+ _REQUESTS_AVAILABLE = False
79
+ logger.info("requests not installed -- climatology will use the synthetic model")
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Constants
84
+ # ---------------------------------------------------------------------------
85
+
86
+ # Same archive endpoint era5_data_pipeline.py uses; duplicated here (rather
87
+ # than imported) so era5_data_pipeline can import THIS module lazily without
88
+ # a circular import at module load time.
89
+ _OPENMETEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
90
+ _TIMEOUT_S = int(os.environ.get("WEATHER_HTTP_TIMEOUT", "60"))
91
+
92
+ _CACHE_DIR = Path(os.environ.get("WEATHER_CACHE_DIR", ".cache/era5")) / "climatology"
93
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
94
+ _CACHE_TTL_DAYS = 90
95
+
96
+ _DAYS_PER_YEAR = 365.25
97
+ _TABLE_LEN = 366 # DOY table indexed doy-1; DOY 60 = Feb 29 (leap mapping below)
98
+
99
+ # Std floors: prevent division blow-ups in convectively uniform seasons.
100
+ _PRECIP_STD_FLOOR = 1.5 # mm/day
101
+ _TEMP_STD_FLOOR = 0.4 # deg C
102
+ _SOIL_STD_FLOOR = 2.0 # percent
103
+ _RH_STD_FLOOR = 3.0 # percent; prevents z-score blowup when tropical RH variance is naturally small
104
+
105
+ # Trailing window for the precipitation anomaly. Matches ZoneObs.precip_30d_mm,
106
+ # the longest aggregate every real fetcher populates.
107
+ _PRECIP_WINDOW_DAYS = 30
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Day-of-year helpers (fixed 366-entry table regardless of leap years)
112
+ # ---------------------------------------------------------------------------
113
+
114
+ def _is_leap(year: int) -> bool:
115
+ return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
116
+
117
+
118
+ def doy_index(dt: datetime) -> int:
119
+ """Map a date to a 1..366 index in the fixed climatology table.
120
+
121
+ In non-leap years, dates after Feb 28 are shifted up by one so that e.g.
122
+ Mar 1 always maps to the same table entry (61) in every year. Table entry
123
+ 60 (Feb 29) is only ever hit by leap-year dates.
124
+ """
125
+ doy = dt.timetuple().tm_yday
126
+ if not _is_leap(dt.year) and doy >= 60:
127
+ doy += 1
128
+ return min(doy, _TABLE_LEN)
129
+
130
+
131
+ def _circular_smooth(values: List[float], half_window: int = 7) -> List[float]:
132
+ """Circular moving average over the DOY table (Dec wraps to Jan)."""
133
+ n = len(values)
134
+ out = []
135
+ for i in range(n):
136
+ acc = 0.0
137
+ cnt = 0
138
+ for j in range(-half_window, half_window + 1):
139
+ acc += values[(i + j) % n]
140
+ cnt += 1
141
+ out.append(acc / cnt)
142
+ return out
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # ZoneClimatology
147
+ # ---------------------------------------------------------------------------
148
+
149
+ @dataclass
150
+ class ZoneClimatology:
151
+ """Day-of-year climatology for one zone.
152
+
153
+ All lists have length 366 and are indexed by (doy_index(dt) - 1).
154
+ precip is a DAILY mean rate (mm/day); window aggregates are computed by
155
+ summing daily means over the window (see window_precip_stats).
156
+ """
157
+ zone_id: str
158
+ source: str # 'openmeteo_archive' | 'synthetic_model' | 'mixed'
159
+ n_years: int
160
+ period_start_year: int
161
+ period_end_year: int
162
+ precip_mean_mm: List[float] = field(default_factory=list) # mm/day
163
+ precip_std_mm: List[float] = field(default_factory=list)
164
+ temp_mean_c: List[float] = field(default_factory=list)
165
+ temp_std_c: List[float] = field(default_factory=list)
166
+ soil_mean_pct: List[float] = field(default_factory=list)
167
+ soil_std_pct: List[float] = field(default_factory=list)
168
+ # FIX: baseline uses rh_mean (not rh_max -- unreliable in Open-Meteo);
169
+ # correlates well enough to correct fungi_risk_signal()'s unchanged
170
+ # max-based threshold.
171
+ rh_mean_pct: List[float] = field(default_factory=list)
172
+ rh_std_pct: List[float] = field(default_factory=list)
173
+
174
+ def __post_init__(self) -> None:
175
+ for name in ("precip_mean_mm", "precip_std_mm", "temp_mean_c",
176
+ "temp_std_c", "soil_mean_pct", "soil_std_pct",
177
+ "rh_mean_pct", "rh_std_pct"):
178
+ v = getattr(self, name)
179
+ if len(v) != _TABLE_LEN:
180
+ raise ValueError(
181
+ f"ZoneClimatology('{self.zone_id}'): {name} has length "
182
+ f"{len(v)}, expected {_TABLE_LEN}"
183
+ )
184
+
185
+ # --- Window statistics ------------------------------------------------
186
+ def window_precip_stats(self, dt: datetime, window_days: int) -> Tuple[float, float]:
187
+ """Climatological mean and std of a TRAILING `window_days` precip total
188
+ ending at dt's day-of-year.
189
+
190
+ Mean: sum of daily means (exact under the daily model).
191
+ Std: sqrt(sum of daily variances) -- assumes day-to-day independence,
192
+ so it UNDERSTATES true variance during correlated multi-day
193
+ spells, inflating anomaly magnitude for persistent events.
194
+ Bounded by the floors and the [-5, 5] clip in ZoneObs.
195
+ """
196
+ idx0 = doy_index(dt) - 1
197
+ mean = 0.0
198
+ var = 0.0
199
+ for k in range(window_days):
200
+ i = (idx0 - k) % _TABLE_LEN
201
+ mean += self.precip_mean_mm[i]
202
+ var += self.precip_std_mm[i] ** 2
203
+ return mean, max(math.sqrt(var), _PRECIP_STD_FLOOR)
204
+
205
+ def daily_temp_stats(self, dt: datetime) -> Tuple[float, float]:
206
+ i = doy_index(dt) - 1
207
+ return self.temp_mean_c[i], max(self.temp_std_c[i], _TEMP_STD_FLOOR)
208
+
209
+ def daily_soil_stats(self, dt: datetime) -> Tuple[float, float]:
210
+ i = doy_index(dt) - 1
211
+ return self.soil_mean_pct[i], max(self.soil_std_pct[i], _SOIL_STD_FLOOR)
212
+
213
+ def daily_rh_stats(self, dt: datetime) -> Tuple[float, float]:
214
+ i = doy_index(dt) - 1
215
+ return self.rh_mean_pct[i], max(self.rh_std_pct[i], _RH_STD_FLOOR)
216
+
217
+ # --- Serialisation (JSON cache) ---------------------------------------
218
+ def to_dict(self) -> Dict[str, Any]:
219
+ return {
220
+ "zone_id": self.zone_id,
221
+ "source": self.source,
222
+ "n_years": self.n_years,
223
+ "period_start_year": self.period_start_year,
224
+ "period_end_year": self.period_end_year,
225
+ "precip_mean_mm": self.precip_mean_mm,
226
+ "precip_std_mm": self.precip_std_mm,
227
+ "temp_mean_c": self.temp_mean_c,
228
+ "temp_std_c": self.temp_std_c,
229
+ "soil_mean_pct": self.soil_mean_pct,
230
+ "soil_std_pct": self.soil_std_pct,
231
+ "rh_mean_pct": self.rh_mean_pct,
232
+ "rh_std_pct": self.rh_std_pct,
233
+ }
234
+
235
+ @classmethod
236
+ def from_dict(cls, d: Dict[str, Any]) -> "ZoneClimatology":
237
+ # FIX: rh_mean_pct/rh_std_pct are new fields; old cached files
238
+ # predate them. Default to flat 85% (synthetic-model range) instead
239
+ # of crashing -- self-heals within _CACHE_TTL_DAYS as real RH is
240
+ # fetched.
241
+ rh_mean = d.get("rh_mean_pct")
242
+ rh_std = d.get("rh_std_pct")
243
+ if rh_mean is None or len(rh_mean) != _TABLE_LEN:
244
+ rh_mean = [85.0] * _TABLE_LEN
245
+ if rh_std is None or len(rh_std) != _TABLE_LEN:
246
+ rh_std = [_RH_STD_FLOOR] * _TABLE_LEN
247
+ return cls(
248
+ zone_id=d["zone_id"],
249
+ source=d.get("source", "unknown"),
250
+ n_years=int(d.get("n_years", 0)),
251
+ period_start_year=int(d.get("period_start_year", 0)),
252
+ period_end_year=int(d.get("period_end_year", 0)),
253
+ precip_mean_mm=[float(v) for v in d["precip_mean_mm"]],
254
+ precip_std_mm=[float(v) for v in d["precip_std_mm"]],
255
+ temp_mean_c=[float(v) for v in d["temp_mean_c"]],
256
+ temp_std_c=[float(v) for v in d["temp_std_c"]],
257
+ soil_mean_pct=[float(v) for v in d["soil_mean_pct"]],
258
+ soil_std_pct=[float(v) for v in d["soil_std_pct"]],
259
+ rh_mean_pct=[float(v) for v in rh_mean],
260
+ rh_std_pct=[float(v) for v in rh_std],
261
+ )
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Tier 2 -- deterministic synthetic maritime-continent climatology
266
+ # ---------------------------------------------------------------------------
267
+
268
+ def _synthetic_climatology(zone_id: str, lat: float, n_years: int = 0) -> ZoneClimatology:
269
+ """Deterministic heuristic climatology for the Indonesian maritime continent.
270
+ A documented heuristic, NOT a retrieval -- see module docstring.
271
+
272
+ * Precip: single-harmonic wet season, peak ~DOY 30 (late Jan) for the
273
+ southern archipelago (Java, Bali, Nusa Tenggara, Sulawesi, S. Sumatra);
274
+ peak shifts earlier (Oct-Dec) moving north past ~1 deg N (N. Sumatra).
275
+ Amplitude grows with distance from equator; equatorial belt stays wet
276
+ year-round.
277
+ * Temp: weak annual cycle (~2.6 deg C peak-to-peak), coolest Jul-Aug in
278
+ the south (SH dry season), weaker and phase-reversed north of equator.
279
+ * Soil: precip-tracked with ~20-day lag, scaled to ERA5 swvl1's typical
280
+ volumetric-% range for the region.
281
+
282
+ Per-zone jitter (+/-10% on base/amp, via _stable_seed) keeps neighbouring
283
+ zones numerically distinct without changing the seasonal shape.
284
+ """
285
+ seed = _stable_seed(f"clim_{zone_id}")
286
+ # Deterministic jitter in [0.9, 1.1] from the seed's low bits.
287
+ jitter = 0.9 + 0.2 * ((seed % 1000) / 1000.0)
288
+
289
+ abs_lat = abs(lat)
290
+ # Wet-season peak: late Jan in the south, shifting earlier north of ~1N.
291
+ peak_doy = 30.0 if lat <= 1.0 else max(300.0, 30.0 - 12.0 * lat)
292
+ amp_scale = _clip(abs_lat / 8.0, 0.35, 1.0)
293
+
294
+ precip_base = max(3.0, (7.0 - 0.25 * abs_lat) * jitter) # mm/day
295
+ precip_amp = 4.5 * amp_scale * jitter # mm/day
296
+ temp_base = 27.0 - 0.30 * abs_lat
297
+ # SH zones: coolest around DOY ~200 (mid-Jul). NH: weaker, reversed.
298
+ temp_amp = 1.3 if lat < 0.0 else -0.5
299
+
300
+ precip_mean, precip_std = [], []
301
+ temp_mean, temp_std = [], []
302
+ soil_mean, soil_std = [], []
303
+ rh_mean, rh_std = [], []
304
+
305
+ daily_precip_for_soil: List[float] = []
306
+ for doy in range(1, _TABLE_LEN + 1):
307
+ phase = 2.0 * math.pi * (doy - peak_doy) / _DAYS_PER_YEAR
308
+ p = precip_base + precip_amp * math.cos(phase)
309
+ p = max(0.8, p)
310
+ daily_precip_for_soil.append(p)
311
+ precip_mean.append(p)
312
+ precip_std.append(max(_PRECIP_STD_FLOOR, 0.9 * p))
313
+
314
+ t_phase = 2.0 * math.pi * (doy - 200.0) / _DAYS_PER_YEAR
315
+ t = temp_base - temp_amp * math.cos(t_phase)
316
+ temp_mean.append(t)
317
+ temp_std.append(0.7)
318
+
319
+ # RH baseline tracks the wet season (in phase with precip), range
320
+ # 78-94%. This is a baseline for ANOMALY detection only --
321
+ # fungi_risk_signal() still applies its own absolute threshold to
322
+ # rh_max_pct separately.
323
+ r = 86.0 + amp_scale * 6.0 * math.cos(phase)
324
+ rh_mean.append(_clip(r, 78.0, 94.0))
325
+ rh_std.append(max(_RH_STD_FLOOR, 3.5))
326
+
327
+ # Soil tracks precip with a 20-day lag.
328
+ for doy in range(1, _TABLE_LEN + 1):
329
+ lagged = daily_precip_for_soil[(doy - 1 - 20) % _TABLE_LEN]
330
+ s = _clip(16.0 + 2.4 * lagged, 8.0, 52.0)
331
+ soil_mean.append(s)
332
+ soil_std.append(max(_SOIL_STD_FLOOR, 4.0))
333
+
334
+ return ZoneClimatology(
335
+ zone_id=zone_id,
336
+ source="synthetic_model",
337
+ n_years=n_years,
338
+ period_start_year=0,
339
+ period_end_year=0,
340
+ precip_mean_mm=_circular_smooth(precip_mean),
341
+ precip_std_mm=precip_std,
342
+ temp_mean_c=_circular_smooth(temp_mean),
343
+ temp_std_c=temp_std,
344
+ soil_mean_pct=_circular_smooth(soil_mean),
345
+ soil_std_pct=soil_std,
346
+ rh_mean_pct=_circular_smooth(rh_mean),
347
+ rh_std_pct=rh_std,
348
+ )
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Tier 1 -- real climatology from the Open-Meteo archive
353
+ # ---------------------------------------------------------------------------
354
+
355
+ def _fetch_openmeteo_climatology(
356
+ zone_id: str,
357
+ lat: float,
358
+ lon: float,
359
+ years: int,
360
+ end_year: Optional[int] = None,
361
+ ) -> ZoneClimatology:
362
+ """Build a DOY climatology from the Open-Meteo historical archive.
363
+
364
+ Downloads `years` full calendar years of daily data in one request and
365
+ pools by day-of-year. Raises on any failure -- the caller
366
+ (get_zone_climatology) falls back to the synthetic model, matching the
367
+ pipeline-wide degrade-safely pattern.
368
+
369
+ soil_moisture_0_to_7cm_mean is requested per the Open-Meteo archive
370
+ documentation at write time (VERIFIED LIVE against the archive API:
371
+ the variable exists and returns daily means). UNITS: Open-Meteo returns
372
+ soil moisture in m3/m3; this function converts to percent (x100) so the
373
+ table matches ZoneObs.soil_moisture_pct and ERA5's swvl1 x 100 handling
374
+ in era5_data_pipeline._build_era5_obs. (Found via a z-score clipped at
375
+ +5.0 against an 18% observation -- the raw 0.2-0.4 m3/m3 values were
376
+ being read as ~0.3%.)
377
+
378
+ If the key is absent/empty in the response (API change, or variable not
379
+ in the daily list for this endpoint), the soil tables are derived from
380
+ the REAL precip series via the same lagged mapping the synthetic model
381
+ uses -- so a soil-variable outage degrades one field's provenance, not
382
+ the whole fetch. The result's `source` is then 'mixed' rather than
383
+ 'openmeteo_archive' so downstream auditing can tell.
384
+ """
385
+ if not _REQUESTS_AVAILABLE:
386
+ raise RuntimeError("requests not installed")
387
+
388
+ last_full_year = (end_year if end_year is not None
389
+ else datetime.now(timezone.utc).year - 1)
390
+ start_year = last_full_year - years + 1
391
+
392
+ params = {
393
+ "latitude": lat,
394
+ "longitude": lon,
395
+ "start_date": f"{start_year}-01-01",
396
+ "end_date": f"{last_full_year}-12-31",
397
+ "daily": ",".join([
398
+ "precipitation_sum",
399
+ "temperature_2m_mean",
400
+ "soil_moisture_0_to_7cm_mean",
401
+ "relative_humidity_2m_mean",
402
+ ]),
403
+ "timezone": "UTC",
404
+ }
405
+
406
+ resp = requests.get(_OPENMETEO_ARCHIVE_URL, params=params, timeout=_TIMEOUT_S)
407
+ resp.raise_for_status()
408
+ data = resp.json()
409
+ daily = data.get("daily", {})
410
+ dates = daily.get("time", [])
411
+ if not dates:
412
+ raise RuntimeError(f"Open-Meteo archive returned no daily rows for {zone_id}")
413
+
414
+ precip_series = daily.get("precipitation_sum", [])
415
+ temp_series = daily.get("temperature_2m_mean", [])
416
+ soil_series = daily.get("soil_moisture_0_to_7cm_mean", [])
417
+ rh_series = daily.get("relative_humidity_2m_mean", [])
418
+
419
+ p_sum = [0.0] * _TABLE_LEN
420
+ p_sq = [0.0] * _TABLE_LEN
421
+ p_n = [0] * _TABLE_LEN
422
+ t_sum = [0.0] * _TABLE_LEN
423
+ t_sq = [0.0] * _TABLE_LEN
424
+ t_n = [0] * _TABLE_LEN
425
+ s_sum = [0.0] * _TABLE_LEN
426
+ s_sq = [0.0] * _TABLE_LEN
427
+ s_n = [0] * _TABLE_LEN
428
+ r_sum = [0.0] * _TABLE_LEN
429
+ r_sq = [0.0] * _TABLE_LEN
430
+ r_n = [0] * _TABLE_LEN
431
+
432
+ def _val(series: List[Any], i: int) -> Optional[float]:
433
+ if i >= len(series):
434
+ return None
435
+ v = series[i]
436
+ if v is None:
437
+ return None
438
+ try:
439
+ return float(v)
440
+ except (TypeError, ValueError):
441
+ return None
442
+
443
+ for i, date_str in enumerate(dates):
444
+ try:
445
+ dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
446
+ except ValueError:
447
+ continue
448
+ k = doy_index(dt) - 1
449
+
450
+ p = _val(precip_series, i)
451
+ if p is not None:
452
+ p_sum[k] += p
453
+ p_sq[k] += p * p
454
+ p_n[k] += 1
455
+ t = _val(temp_series, i)
456
+ if t is not None:
457
+ t_sum[k] += t
458
+ t_sq[k] += t * t
459
+ t_n[k] += 1
460
+ s = _val(soil_series, i)
461
+ if s is not None:
462
+ s_pct = s * 100.0 # m3/m3 -> % (matches ZoneObs.soil_moisture_pct)
463
+ s_sum[k] += s_pct
464
+ s_sq[k] += s_pct * s_pct
465
+ s_n[k] += 1
466
+ r = _val(rh_series, i)
467
+ if r is not None:
468
+ r_sum[k] += r
469
+ r_sq[k] += r * r
470
+ r_n[k] += 1
471
+
472
+ if sum(p_n) < 300 * years or sum(t_n) < 300 * years:
473
+ raise RuntimeError(
474
+ f"Open-Meteo archive coverage too thin for {zone_id}: "
475
+ f"precip_days={sum(p_n)} temp_days={sum(t_n)} over {years}y"
476
+ )
477
+
478
+ def _mean_std(sums, sqs, ns, floor):
479
+ means, stds = [], []
480
+ for k in range(_TABLE_LEN):
481
+ n = ns[k]
482
+ if n == 0:
483
+ # Should not happen with full-year coverage; guard anyway.
484
+ means.append(0.0)
485
+ stds.append(floor)
486
+ continue
487
+ m = sums[k] / n
488
+ var = max(0.0, sqs[k] / n - m * m)
489
+ means.append(m)
490
+ stds.append(max(floor, math.sqrt(var)))
491
+ return means, stds
492
+
493
+ precip_mean, precip_std = _mean_std(p_sum, p_sq, p_n, _PRECIP_STD_FLOOR)
494
+ temp_mean, temp_std = _mean_std(t_sum, t_sq, t_n, _TEMP_STD_FLOOR)
495
+
496
+ soil_days = sum(s_n)
497
+ soil_thin = soil_days < 300 * years
498
+ if not soil_thin:
499
+ soil_mean, soil_std = _mean_std(s_sum, s_sq, s_n, _SOIL_STD_FLOOR)
500
+ else:
501
+ logger.warning(
502
+ "climatology: soil_moisture_0_to_7cm_mean coverage thin for %s "
503
+ "(%d days over %dy) -- deriving soil tables from the real precip "
504
+ "series (lagged mapping). Provenance marked 'mixed'.",
505
+ zone_id, soil_days, years,
506
+ )
507
+ soil_mean, soil_std = [], []
508
+ for doy in range(1, _TABLE_LEN + 1):
509
+ lagged = precip_mean[(doy - 1 - 20) % _TABLE_LEN]
510
+ soil_mean.append(_clip(16.0 + 2.4 * lagged, 8.0, 52.0))
511
+ soil_std.append(max(_SOIL_STD_FLOOR, 4.0))
512
+
513
+ rh_days = sum(r_n)
514
+ rh_thin = rh_days < 300 * years
515
+ if not rh_thin:
516
+ rh_mean, rh_std = _mean_std(r_sum, r_sq, r_n, _RH_STD_FLOOR)
517
+ else:
518
+ logger.warning(
519
+ "climatology: relative_humidity_2m_mean coverage thin for %s "
520
+ "(%d days over %dy) -- deriving RH tables from the real precip "
521
+ "series (wet-season correlation, same phase). Provenance marked "
522
+ "'mixed'.",
523
+ zone_id, rh_days, years,
524
+ )
525
+ rh_mean, rh_std = [], []
526
+ p_min, p_max = min(precip_mean), max(precip_mean)
527
+ p_span = max(p_max - p_min, 1e-6)
528
+ for doy in range(1, _TABLE_LEN + 1):
529
+ p_frac = (precip_mean[doy - 1] - p_min) / p_span # 0..1
530
+ rh_mean.append(_clip(78.0 + 12.0 * p_frac, 78.0, 94.0))
531
+ rh_std.append(max(_RH_STD_FLOOR, 3.5))
532
+
533
+ source = "openmeteo_archive" if not (soil_thin or rh_thin) else "mixed"
534
+
535
+ return ZoneClimatology(
536
+ zone_id=zone_id,
537
+ source=source,
538
+ n_years=years,
539
+ period_start_year=start_year,
540
+ period_end_year=last_full_year,
541
+ precip_mean_mm=_circular_smooth(precip_mean),
542
+ precip_std_mm=precip_std,
543
+ temp_mean_c=_circular_smooth(temp_mean),
544
+ temp_std_c=temp_std,
545
+ soil_mean_pct=_circular_smooth(soil_mean),
546
+ soil_std_pct=soil_std,
547
+ rh_mean_pct=_circular_smooth(rh_mean),
548
+ rh_std_pct=rh_std,
549
+ )
550
+
551
+
552
+ # ---------------------------------------------------------------------------
553
+ # Public API: cached climatology + anomaly application
554
+ # ---------------------------------------------------------------------------
555
+
556
+ def _cache_path(zone_id: str, years: int, end_year: Optional[int]) -> Path:
557
+ key = _stable_seed(f"{zone_id}|{years}|{end_year}")
558
+ return _CACHE_DIR / f"{zone_id}_{key}.json"
559
+
560
+
561
+ def get_zone_climatology(
562
+ zone_id: str,
563
+ lat: float,
564
+ lon: float,
565
+ years: int = 10,
566
+ end_year: Optional[int] = None,
567
+ prefer_real: bool = True,
568
+ use_cache: bool = True,
569
+ ) -> ZoneClimatology:
570
+ """Return the day-of-year climatology for a zone, cached on disk.
571
+
572
+ Resolution order:
573
+ 1. Fresh cache hit (same zone/years/end_year, < _CACHE_TTL_DAYS old).
574
+ 2. Real Open-Meteo archive fetch (if prefer_real and requests present).
575
+ 3. Deterministic synthetic monsoon model (never fails).
576
+
577
+ Args:
578
+ end_year: Last calendar year included in the climatology period.
579
+ Default: the most recent COMPLETE year (now.year - 1).
580
+ Pin this explicitly for backtests so the climatology
581
+ cannot see the period being backtested (look-ahead).
582
+ prefer_real: Set False to force the synthetic model (offline tests).
583
+ """
584
+ path = _cache_path(zone_id, years, end_year)
585
+ if use_cache and path.exists():
586
+ age_days = (
587
+ datetime.now(timezone.utc)
588
+ - datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
589
+ ).days
590
+ if age_days < _CACHE_TTL_DAYS:
591
+ try:
592
+ with open(path) as f:
593
+ return ZoneClimatology.from_dict(json.load(f))
594
+ except Exception as e:
595
+ logger.warning("climatology: cache read failed (%s) -- rebuilding", e)
596
+
597
+ clim: Optional[ZoneClimatology] = None
598
+ if prefer_real and _REQUESTS_AVAILABLE:
599
+ try:
600
+ clim = _fetch_openmeteo_climatology(zone_id, lat, lon, years, end_year)
601
+ logger.info(
602
+ "climatology: built real %dy climatology for %s (%d-%d)",
603
+ years, zone_id, clim.period_start_year, clim.period_end_year,
604
+ )
605
+ except Exception as e:
606
+ logger.warning(
607
+ "climatology: real fetch failed for %s (%s) -- synthetic model",
608
+ zone_id, e,
609
+ )
610
+ clim = None
611
+
612
+ if clim is None:
613
+ clim = _synthetic_climatology(zone_id, lat, n_years=years)
614
+
615
+ if use_cache:
616
+ try:
617
+ with open(path, "w") as f:
618
+ json.dump(clim.to_dict(), f)
619
+ except Exception as e:
620
+ logger.warning("climatology: cache write failed (%s) -- continuing", e)
621
+
622
+ return clim
623
+
624
+
625
+ def apply_climatology_anomalies(obs: ZoneObs, clim: ZoneClimatology) -> ZoneObs:
626
+ """Return a NEW ZoneObs with the four anomaly fields populated as
627
+ z-scores against `clim`. Never mutates the input (to_dict/from_dict
628
+ round-trip, matching the codebase idiom).
629
+
630
+ Skipped/Guarded cases (all deliberate, all logged at debug level):
631
+ * obs.source == SYNTHETIC: returned unchanged. Synthetic obs carry
632
+ injected anomalies from the event flags; re-scoring them against a
633
+ climatology would double-transform the training signal.
634
+ * precip anomaly only computed when at least one precip aggregate is
635
+ non-zero (a precip-less fetch like _fetch_smap would otherwise read
636
+ as a catastrophic false drought: (0 - mean)/std << 0).
637
+ * temp anomaly only when temp_mean_c != 0.0 (0.0 is the "unset"
638
+ default, not a real temperature in this pipeline's operating range).
639
+ * soil anomaly only when soil_moisture_pct > 0.0.
640
+ * rh anomaly only when rh_mean_pct > 0.0 (0.0 is "unset", not a real
641
+ humidity reading -- see rh_anomaly_idx's field comment in
642
+ zone_observation.py for why this exists: fungi_risk_signal()'s pure
643
+ absolute-RH threshold was flat across ENSO regimes in a tropical
644
+ climate, so it was masking correctly regime-sensitive drought/flood
645
+ signals in the actual alert_level output).
646
+
647
+ Z-scores are clipped to [-5, 5] by ZoneObs.__post_init__ as usual.
648
+ """
649
+ if obs.source == DataSource.SYNTHETIC:
650
+ logger.debug(
651
+ "climatology: %s source is SYNTHETIC -- anomalies left as injected",
652
+ obs.zone_id,
653
+ )
654
+ return obs
655
+
656
+ d = obs.to_dict()
657
+ d.pop("_schema_version", None)
658
+
659
+ has_precip = (obs.precip_30d_mm > 0.0) or (obs.precip_14d_mm > 0.0) \
660
+ or (obs.precip_7d_mm > 0.0) or (obs.precip_24h_mm > 0.0)
661
+ if has_precip:
662
+ mean_w, std_w = clim.window_precip_stats(obs.valid_time, _PRECIP_WINDOW_DAYS)
663
+ d["precip_anomaly_idx"] = _clip(
664
+ (obs.precip_30d_mm - mean_w) / std_w, -5.0, 5.0
665
+ )
666
+
667
+ if obs.temp_mean_c != 0.0:
668
+ t_mean, t_std = clim.daily_temp_stats(obs.valid_time)
669
+ d["temp_anomaly_idx"] = _clip((obs.temp_mean_c - t_mean) / t_std, -5.0, 5.0)
670
+
671
+ if obs.soil_moisture_pct > 0.0:
672
+ s_mean, s_std = clim.daily_soil_stats(obs.valid_time)
673
+ d["soil_moisture_anom"] = _clip(
674
+ (obs.soil_moisture_pct - s_mean) / s_std, -5.0, 5.0
675
+ )
676
+
677
+ if obs.rh_mean_pct > 0.0:
678
+ r_mean, r_std = clim.daily_rh_stats(obs.valid_time)
679
+ d["rh_anomaly_idx"] = _clip(
680
+ (obs.rh_mean_pct - r_mean) / r_std, -5.0, 5.0
681
+ )
682
+
683
+ return ZoneObs.from_dict(d)
684
+
685
+
686
+ def apply_anomalies_by_zone_id(
687
+ obs: ZoneObs,
688
+ lat: float,
689
+ lon: float,
690
+ years: int = 10,
691
+ prefer_real: bool = True,
692
+ ) -> ZoneObs:
693
+ """Convenience wrapper: resolve (or build) the cached climatology for
694
+ obs.zone_id, then apply it. This is the entry point era5_data_pipeline
695
+ calls; kept separate from apply_climatology_anomalies so callers that
696
+ already hold a ZoneClimatology (e.g. the backtester looping over days)
697
+ don't pay the cache lookup per step.
698
+ """
699
+ clim = get_zone_climatology(obs.zone_id, lat, lon, years=years,
700
+ prefer_real=prefer_real)
701
+ return apply_climatology_anomalies(obs, clim)
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # Self-test (python climatology.py) -- fully offline
706
+ # ---------------------------------------------------------------------------
707
+
708
+ if __name__ == "__main__":
709
+ import sys
710
+ from datetime import timezone as _tz
711
+ from zone_observation import make_synthetic_zone_obs
712
+
713
+ logging.basicConfig(level=logging.WARNING)
714
+ print("climatology.py self-test (offline: prefer_real=False)\n")
715
+ failures: List[str] = []
716
+
717
+ def _assert(cond: bool, msg: str) -> None:
718
+ if not cond:
719
+ failures.append(msg)
720
+ print(f" FAIL: {msg}")
721
+
722
+ LAT, LON = -6.3, 107.3 # Karawang, West Java
723
+
724
+ # 1. Synthetic climatology: shape, length, round-trip
725
+ clim = _synthetic_climatology("test_zone", LAT)
726
+ _assert(len(clim.precip_mean_mm) == _TABLE_LEN, "precip table length")
727
+ d = clim.to_dict()
728
+ clim2 = ZoneClimatology.from_dict(d)
729
+ _assert(clim2.zone_id == clim.zone_id, "ZoneClimatology round-trip zone_id")
730
+ _assert(abs(clim2.precip_mean_mm[100] - clim.precip_mean_mm[100]) < 1e-12,
731
+ "ZoneClimatology round-trip values")
732
+
733
+ # 2. Seasonality: Java should be much wetter in Jan than in Aug
734
+ jan_mean = sum(clim.precip_mean_mm[0:31]) / 31.0
735
+ aug_mean = sum(clim.precip_mean_mm[212:243]) / 31.0
736
+ _assert(jan_mean > aug_mean * 1.3,
737
+ f"monsoon shape wrong: Jan={jan_mean:.1f} vs Aug={aug_mean:.1f} mm/day")
738
+ print(f" Seasonality OK: Jan {jan_mean:.1f} mm/day vs Aug {aug_mean:.1f} mm/day")
739
+
740
+ # 3. doy_index leap mapping: Mar 1 maps to the same entry in every year
741
+ d1 = doy_index(datetime(2023, 3, 1, tzinfo=_tz.utc))
742
+ d2 = doy_index(datetime(2024, 3, 1, tzinfo=_tz.utc))
743
+ _assert(d1 == d2 == 61, f"Mar 1 mapping inconsistent: {d1} vs {d2}")
744
+ _assert(doy_index(datetime(2024, 2, 29, tzinfo=_tz.utc)) == 60, "Feb 29 mapping")
745
+ print(f" doy_index OK (Mar 1 -> {d1}, Feb 29 -> 60)")
746
+
747
+ # 4. Window stats: 30-day wet-season aggregate exceeds dry-season
748
+ wet_dt = datetime(2024, 1, 31, tzinfo=_tz.utc)
749
+ dry_dt = datetime(2024, 8, 31, tzinfo=_tz.utc)
750
+ wet_mean, wet_std = clim.window_precip_stats(wet_dt, 30)
751
+ dry_mean, _ = clim.window_precip_stats(dry_dt, 30)
752
+ _assert(wet_mean > dry_mean, "window aggregate seasonality wrong")
753
+ _assert(wet_std >= _PRECIP_STD_FLOOR, "window std floor violated")
754
+ print(f" Window stats OK: wet30={wet_mean:.0f}mm dry30={dry_mean:.0f}mm")
755
+
756
+ # 5. apply_climatology_anomalies: real-source obs gets anomalies
757
+ obs = make_synthetic_zone_obs("realish_zone", seed=1)
758
+ od = obs.to_dict()
759
+ od.pop("_schema_version", None)
760
+ od["source"] = DataSource.OPENMETEO_LIVE.value # pretend real
761
+ # A real fetcher leaves anomaly fields at 0.0 ("unset") -- mirror that so
762
+ # this test measures exactly what this module adds.
763
+ od["precip_anomaly_idx"] = od["temp_anomaly_idx"] = od["soil_moisture_anom"] = 0.0
764
+ real_obs = ZoneObs.from_dict(od)
765
+ pre_precip_z = real_obs.precip_anomaly_idx
766
+ out = apply_climatology_anomalies(real_obs, clim)
767
+ _assert(out is not real_obs, "apply should return a NEW object")
768
+ _assert(real_obs.precip_anomaly_idx == pre_precip_z, "input obs was mutated!")
769
+ # The obs built by make_synthetic_zone_obs has neutral-ish aggregates;
770
+ # anomaly must be finite and within clip range.
771
+ _assert(-5.0 <= out.precip_anomaly_idx <= 5.0, "anomaly outside clip")
772
+ _assert(-5.0 <= out.temp_anomaly_idx <= 5.0, "temp anomaly outside clip")
773
+ _assert(-5.0 <= out.soil_moisture_anom <= 5.0, "soil anomaly outside clip")
774
+ print(f" Anomaly application OK: precip_z={out.precip_anomaly_idx:+.2f} "
775
+ f"temp_z={out.temp_anomaly_idx:+.2f} soil_z={out.soil_moisture_anom:+.2f}")
776
+
777
+ # 6. Drought/wet extremes produce correctly-signed anomalies
778
+ # (keep aggregates monotonic: 24h <= 7d <= 14d <= 30d)
779
+ dry_obs_d = dict(od)
780
+ dry_obs_d["precip_24h_mm"] = 0.0
781
+ dry_obs_d["precip_7d_mm"] = 0.01 * wet_mean / 4.0
782
+ dry_obs_d["precip_14d_mm"] = 0.02 * wet_mean / 2.0
783
+ dry_obs_d["precip_30d_mm"] = 0.05 * wet_mean # 5% of wet climatology
784
+ dry_obs_d["valid_time"] = wet_dt.isoformat()
785
+ dry_out = apply_climatology_anomalies(ZoneObs.from_dict(dry_obs_d), clim)
786
+ _assert(dry_out.precip_anomaly_idx < -1.0,
787
+ f"dry obs should get negative anomaly, got {dry_out.precip_anomaly_idx}")
788
+ wet_obs_d = dict(od)
789
+ wet_obs_d["precip_24h_mm"] = 2.5 * dry_mean / 30.0
790
+ wet_obs_d["precip_7d_mm"] = 2.5 * dry_mean / 4.0
791
+ wet_obs_d["precip_14d_mm"] = 2.5 * dry_mean / 2.0
792
+ wet_obs_d["precip_30d_mm"] = 2.5 * dry_mean
793
+ wet_obs_d["valid_time"] = dry_dt.isoformat()
794
+ wet_out = apply_climatology_anomalies(ZoneObs.from_dict(wet_obs_d), clim)
795
+ _assert(wet_out.precip_anomaly_idx > 1.0,
796
+ f"wet obs should get positive anomaly, got {wet_out.precip_anomaly_idx}")
797
+ print(f" Sign check OK: dry_z={dry_out.precip_anomaly_idx:+.2f} "
798
+ f"wet_z={wet_out.precip_anomaly_idx:+.2f}")
799
+
800
+ # 7. SYNTHETIC-source obs is skipped unchanged
801
+ syn = make_synthetic_zone_obs("syn_zone", drought=True, seed=2)
802
+ syn_out = apply_climatology_anomalies(syn, clim)
803
+ _assert(syn_out.precip_anomaly_idx == syn.precip_anomaly_idx,
804
+ "SYNTHETIC obs anomaly was modified (should be skipped)")
805
+ print(" SYNTHETIC skip OK")
806
+
807
+ # 8. Zero-precip obs (smap-style real fetch: anomaly fields unset at 0.0)
808
+ # must NOT read as a catastrophic false drought.
809
+ zero_d = dict(od)
810
+ zero_d["precip_24h_mm"] = zero_d["precip_7d_mm"] = 0.0
811
+ zero_d["precip_14d_mm"] = zero_d["precip_30d_mm"] = 0.0
812
+ zero_d["precip_anomaly_idx"] = 0.0 # real fetchers leave this unset
813
+ zero_out = apply_climatology_anomalies(ZoneObs.from_dict(zero_d), clim)
814
+ _assert(zero_out.precip_anomaly_idx == 0.0,
815
+ "precip-less fetch should keep anomaly 0.0 (no false drought)")
816
+ print(" Zero-precip guard OK")
817
+
818
+ # 9. Cache round-trip through get_zone_climatology (offline path)
819
+ clim_c = get_zone_climatology("cache_zone", LAT, LON, years=5,
820
+ prefer_real=False, use_cache=True)
821
+ clim_c2 = get_zone_climatology("cache_zone", LAT, LON, years=5,
822
+ prefer_real=False, use_cache=True)
823
+ _assert(clim_c.precip_mean_mm == clim_c2.precip_mean_mm,
824
+ "cached climatology not identical")
825
+ print(" Cache round-trip OK")
826
+
827
+ print()
828
+ if failures:
829
+ print(f"FAILED {len(failures)} test(s):")
830
+ for f in failures:
831
+ print(f" - {f}")
832
+ sys.exit(1)
833
+ else:
834
+ print("All 9 test groups passed.")
requirements.txt ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core (needed for the data/scoring/Indonesia stack -- climatology.py,
2
+ # indonesia_zones.py, backtest_indonesia.py, era5_data_pipeline.py,
3
+ # zone_observation.py, crop_risk_scorer.py, hierarchical_search.py)
4
+ numpy>=1.24
5
+
6
+ # RL training stack (train_curriculum.py, train_kaggle.py,
7
+ # weather_forecast_env.py, gru_weather_policy.py)
8
+ torch>=2.0
9
+ gymnasium>=0.29
10
+ stable-baselines3>=2.0
11
+ sb3-contrib>=2.0
12
+
13
+ # Hyperparameter sweeps (sweep_reward_shaping.py) -- this file was temporarily
14
+ # created to assess optimal hyperparameters. Train_kaggle.py's best values
15
+ # dict (learning_rate=6.916624987609979e-05, ent_coef=0.08779238696445962,
16
+ # etc.) was "found by the Optuna sweep (trial 6 of the 20-trial run against
17
+ # n_zones=3 / max_steps=250)" and hardcoded as the CLI defaults --
18
+ # consistent with this having been a one-time iteration tool whose winning
19
+ # trial's output was captured inline, rather than a script meant to persist
20
+ # in the repo. optuna is essential if you wish to reexplore optimal parameters.
21
+ optuna>=3.5
22
+
23
+ # Edge export (mnn_export.py) -- MNN itself has no pip package; build/install
24
+ # per https://github.com/alibaba/MNN, this only covers the ONNX/export side.
25
+ onnx>=1.15
26
+
27
+ # Real-data fetching (era5_data_pipeline.py, climatology.py) -- all optional;
28
+ # each degrades to synthetic/cached data gracefully without it, but any real
29
+ # (non-synthetic) fetch needs at least `requests`. The comment at the top of
30
+ # this file listing era5_data_pipeline.py under "Core -- numpy>=1.24" is
31
+ # incomplete: numpy alone is enough for the module to import, not for its
32
+ # real-data code paths to work.
33
+ requests>=2.31
34
+ cdsapi>=0.6 # ERA5 reanalysis tier only
35
+ earthengine-api # `import ee` -- IMERG/CHIRPS/SMAP satellite tier only
36
+ netCDF4>=1.6 # ERA5 NetCDF reads -- tried first
37
+ xarray>=2023.1 # ERA5 NetCDF reads -- fallback if netCDF4 unavailable
38
+
39
+ # LocalTimesFMBackend only (timesfm_wrapper.py) -- a deliberately opt-in
40
+ # forecast tier gated behind a manually downloaded, SHA256-verified
41
+ # checkpoint (see LocalTimesFMBackend.__post_init__); most users won't hit
42
+ # this path. Note: timesfm_wrapper.py imports pandas without a try/except
43
+ # guard (unlike its `import timesfm` a few lines above, which does have
44
+ # one) -- if pandas is missing, this fails with an unhelpful raw
45
+ # ImportError rather than the graceful message the rest of this codebase
46
+ # uses for optional deps.
47
+ timesfm
48
+ pandas>=2.0
49
+
50
+ # TensorBoard training logs (train_kaggle.py) -- optional; training runs
51
+ # fine without it, just without tfevents output. Listed here despite being
52
+ # wrapped in a try/except in code because train_kaggle.py's own quickstart
53
+ # docstring tells users to install it, and every verified training run in
54
+ # this project's history had it installed.
55
+ tensorboard>=2.14
56
+
57
+ # Optional -- only needed if you actually connect to a broker
58
+ # (node_transport.py's MQTTTransport falls back to LocalTransport without it)
59
+ paho-mqtt>=1.6
60
+
61
+ # Testing
62
+ pytest>=7.0
train_curriculum.py ADDED
@@ -0,0 +1,1045 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_curriculum.py
3
+ ===================
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import logging
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional
14
+
15
+ import zone_observation as _zo
16
+
17
+ assert _zo.SCHEMA_VERSION == 3, (
18
+ f"train_curriculum: zone_observation schema mismatch "
19
+ f"(expected 3, got {_zo.SCHEMA_VERSION})"
20
+ )
21
+
22
+ from zone_observation import ForecastConfig
23
+ from crop_risk_scorer import RiskWeights
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Optional ML imports (graceful degradation)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ try:
30
+ import torch
31
+ _TORCH_AVAILABLE = True
32
+ except ImportError:
33
+ _TORCH_AVAILABLE = False
34
+
35
+ try:
36
+ from weather_forecast_env import make_weather_env
37
+ from sb3_contrib import MaskablePPO
38
+ from stable_baselines3.common.monitor import Monitor
39
+ from stable_baselines3.common.callbacks import BaseCallback
40
+ _ML_AVAILABLE = True
41
+ except ImportError as _e:
42
+ _ML_AVAILABLE = False
43
+ _ML_IMPORT_ERROR = str(_e)
44
+ make_weather_env = None # type: ignore
45
+ MaskablePPO = None # type: ignore
46
+ Monitor = None # type: ignore
47
+ BaseCallback = object # type: ignore
48
+
49
+ # GRU policy is optional — falls back to MlpPolicy if not present
50
+ try:
51
+ from gru_weather_policy import (
52
+ create_gru_weather_policy_kwargs,
53
+ ZoneEquivariantMaskablePolicy,
54
+ )
55
+ _GRU_AVAILABLE = True
56
+ except ImportError:
57
+ _GRU_AVAILABLE = False
58
+ create_gru_weather_policy_kwargs = None # type: ignore
59
+ ZoneEquivariantMaskablePolicy = None # type: ignore
60
+
61
+ # Physics dynamics is optional — Dyna augmentation disabled if unavailable.
62
+ # Import failure is silent: train_phase() runs identically to the original
63
+ # when dynamics_config=None or _DYNAMICS_AVAILABLE=False.
64
+ try:
65
+ from physics_dynamics import TemporalDynamicsModel, DynaRolloutBuffer, ZoneStateTensor
66
+ _DYNAMICS_AVAILABLE = True
67
+ except ImportError:
68
+ _DYNAMICS_AVAILABLE = False
69
+ TemporalDynamicsModel = None # type: ignore
70
+ DynaRolloutBuffer = None # type: ignore
71
+ ZoneStateTensor = None # type: ignore
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Logging
76
+ # ---------------------------------------------------------------------------
77
+
78
+ logging.basicConfig(
79
+ level=logging.INFO,
80
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
81
+ handlers=[
82
+ logging.FileHandler("training.log"),
83
+ logging.StreamHandler(),
84
+ ],
85
+ )
86
+ logger = logging.getLogger(__name__)
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Device selection
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def _select_device(requested: str) -> str:
94
+ """Return 'cuda' if available and requested, else 'cpu'."""
95
+ if requested == "cuda":
96
+ if _TORCH_AVAILABLE and torch.cuda.is_available():
97
+ return "cuda"
98
+ logger.warning("CUDA requested but not available — falling back to CPU.")
99
+ return "cpu"
100
+ return requested
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Dynamics configuration
105
+ # ---------------------------------------------------------------------------
106
+
107
+ @dataclass
108
+ class DynamicsConfig:
109
+ """
110
+ Configuration for Dyna-style physics dynamics augmentation.
111
+
112
+ When dynamics_model_path is set and the model file exists, DynaCallback
113
+ loads the pre-trained TemporalDynamicsModel and adds a surprise bonus
114
+ to the PPO reward at each step. When dynamics_model_path is None (default),
115
+ training is identical to the original curriculum — no overhead, no change.
116
+
117
+ Fields
118
+ ------
119
+ dynamics_model_path:
120
+ Path to a pre-trained TemporalDynamicsModel checkpoint (.pt).
121
+ Produced by DynamicsTrainer.save() in physics_dynamics.py.
122
+ If None or the file does not exist, DynaCallback is not attached.
123
+
124
+ surprise_weight:
125
+ Scalar multiplier for the surprise bonus added to the PPO reward.
126
+ Start at 0.05. Increase to 0.1 if the agent is under-exploring;
127
+ decrease to 0.01 if the dynamics bonus dominates task reward.
128
+ The bonus is clipped to [0, surprise_weight] before adding, so
129
+ this value is also the maximum bonus per step.
130
+
131
+ update_dynamics_every_n_steps:
132
+ Fine-tune the dynamics model on transitions collected during RL
133
+ training every N environment steps. 0 = no fine-tuning (frozen model).
134
+ Fine-tuning closes the Dyna loop: better policy -> richer data ->
135
+ better dynamics -> better policy. Start with 0 until baseline training
136
+ is stable, then enable at 50_000 steps.
137
+
138
+ fine_tune_epochs:
139
+ Number of gradient steps per fine-tuning update. Keep low (3-5)
140
+ to avoid overfitting to the most recent transitions.
141
+
142
+ transition_buffer_size:
143
+ Maximum number of (current, next) transition pairs stored for
144
+ fine-tuning. Ring buffer: oldest pairs dropped when full.
145
+ """
146
+ dynamics_model_path: Optional[str] = None
147
+ surprise_weight: float = 0.05
148
+ update_dynamics_every_n_steps: int = 0 # 0 = frozen
149
+ fine_tune_epochs: int = 3
150
+ transition_buffer_size: int = 10_000
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Curriculum definition
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def resolve_phase_max_steps(n_zones: int, budget_mode: str, episode_length: int) -> int:
158
+ """
159
+ Map budget_mode → max_steps for a curriculum phase.
160
+
161
+ The visit-once mask makes the structural ceiling n_zones+1. Old phases
162
+ used episode_length of 150–300, which never forced zone selection.
163
+ budget_mode overrides that so later phases train allocation skill.
164
+
165
+ full → n_zones + 1
166
+ scarce → n_zones
167
+ triage → max(1, n_zones - 1)
168
+ legacy → keep episode_length (old behaviour)
169
+ """
170
+ n = max(1, int(n_zones))
171
+ mode = (budget_mode or "triage").strip().lower()
172
+ if mode == "legacy":
173
+ return max(1, int(episode_length))
174
+ if mode == "full":
175
+ return n + 1
176
+ if mode == "scarce":
177
+ return n
178
+ if mode == "triage":
179
+ return max(1, n - 1)
180
+ raise ValueError(f"Unknown budget_mode {budget_mode!r}")
181
+
182
+
183
+ @dataclass
184
+ class CurriculumPhase:
185
+ name: str
186
+ total_steps: int
187
+ episode_length: int # used only when budget_mode="legacy"
188
+ n_zones: int
189
+ risk_weights: RiskWeights
190
+ # Default triage: no phase can pass without learning which zones to skip.
191
+ budget_mode: str = "triage" # full | scarce | triage | legacy
192
+
193
+ learning_rate: float = 3e-4
194
+ n_steps: int = 4_096
195
+ batch_size: int = 256
196
+ n_epochs: int = 10
197
+ gamma: float = 0.995
198
+ gae_lambda: float = 0.95
199
+ clip_range: float = 0.2
200
+ ent_coef: float = 0.02
201
+ vf_coef: float = 0.5
202
+ max_grad_norm: float = 0.5
203
+
204
+ def resolved_max_steps(self) -> int:
205
+ return resolve_phase_max_steps(self.n_zones, self.budget_mode, self.episode_length)
206
+
207
+
208
+ class WeatherCurriculum:
209
+ """Five-phase climate curriculum from baseline through stress extremes.
210
+
211
+ Budget progression (forces zone differentiation):
212
+ normal → full (learn inspection has value; 2 zones)
213
+ monsoon → scarce (start leaving someone out; 3 zones)
214
+ drought → triage (must skip ≥1; 3 zones)
215
+ heatwave → triage (4 zones)
216
+ humidity → triage (4 zones)
217
+ """
218
+
219
+ PHASES: Dict[str, CurriculumPhase] = {
220
+
221
+ "normal": CurriculumPhase(
222
+ name="normal",
223
+ total_steps=200_000,
224
+ episode_length=150,
225
+ n_zones=2,
226
+ budget_mode="full",
227
+ risk_weights=RiskWeights(),
228
+ n_steps=4_096,
229
+ ent_coef=0.05,
230
+ ),
231
+
232
+ "monsoon": CurriculumPhase(
233
+ name="monsoon",
234
+ total_steps=150_000,
235
+ episode_length=300,
236
+ n_zones=3,
237
+ budget_mode="scarce",
238
+ risk_weights=RiskWeights(
239
+ drought_obs_weight=0.40, drought_forecast_weight=0.60,
240
+ flood_obs_weight=0.70, flood_forecast_weight=0.30,
241
+ fungi_obs_weight=0.75, fungi_forecast_weight=0.25,
242
+ supply_drought_weight=0.25,
243
+ supply_flood_weight=0.50,
244
+ supply_harvest_pressure_weight=0.25,
245
+ ),
246
+ n_steps=4_096,
247
+ ),
248
+
249
+ "drought": CurriculumPhase(
250
+ name="drought",
251
+ total_steps=120_000,
252
+ episode_length=250,
253
+ n_zones=3,
254
+ budget_mode="triage",
255
+ risk_weights=RiskWeights(
256
+ drought_obs_weight=0.80, drought_forecast_weight=0.20,
257
+ flood_obs_weight=0.30, flood_forecast_weight=0.70,
258
+ fungi_obs_weight=0.55, fungi_forecast_weight=0.45,
259
+ supply_drought_weight=0.55,
260
+ supply_flood_weight=0.25,
261
+ supply_harvest_pressure_weight=0.20,
262
+ ),
263
+ n_steps=4_096,
264
+ ),
265
+
266
+ "heatwave": CurriculumPhase(
267
+ name="heatwave",
268
+ total_steps=120_000,
269
+ episode_length=220,
270
+ n_zones=4,
271
+ budget_mode="triage",
272
+ risk_weights=RiskWeights(
273
+ drought_obs_weight=0.75, drought_forecast_weight=0.25,
274
+ flood_obs_weight=0.25, flood_forecast_weight=0.75,
275
+ fungi_obs_weight=0.50, fungi_forecast_weight=0.50,
276
+ supply_drought_weight=0.60,
277
+ supply_flood_weight=0.15,
278
+ supply_harvest_pressure_weight=0.25,
279
+ ),
280
+ n_steps=4_096,
281
+ ),
282
+
283
+ "humidity": CurriculumPhase(
284
+ name="humidity",
285
+ total_steps=100_000,
286
+ episode_length=200,
287
+ n_zones=4,
288
+ budget_mode="triage",
289
+ risk_weights=RiskWeights(
290
+ drought_obs_weight=0.30, drought_forecast_weight=0.70,
291
+ flood_obs_weight=0.50, flood_forecast_weight=0.50,
292
+ fungi_obs_weight=0.85, fungi_forecast_weight=0.15,
293
+ supply_drought_weight=0.20,
294
+ supply_flood_weight=0.30,
295
+ supply_harvest_pressure_weight=0.50,
296
+ quality_fungi_weight=0.80,
297
+ quality_delay_weight=0.20,
298
+ ),
299
+ n_steps=4_096,
300
+ ),
301
+ }
302
+
303
+ @classmethod
304
+ def get_phase(cls, name: str) -> CurriculumPhase:
305
+ if name not in cls.PHASES:
306
+ raise ValueError(
307
+ f"Unknown phase '{name}'. Options: {sorted(cls.PHASES)}"
308
+ )
309
+ return cls.PHASES[name]
310
+
311
+ @classmethod
312
+ def phase_order(cls) -> List[str]:
313
+ return ["normal", "monsoon", "drought", "heatwave", "humidity"]
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Checkpoint callback
318
+ # ---------------------------------------------------------------------------
319
+
320
+ class CheckpointCallback(BaseCallback):
321
+ """Save a checkpoint every `save_freq` timesteps."""
322
+
323
+ def __init__(self, output_dir: Path, save_freq: int = 25_000) -> None:
324
+ super().__init__()
325
+ self.output_dir = output_dir
326
+ self.save_freq = save_freq
327
+ self._last_save = 0
328
+
329
+ def _on_step(self) -> bool:
330
+ if self.num_timesteps - self._last_save >= self.save_freq:
331
+ self._last_save = self.num_timesteps
332
+ path = self.output_dir / f"checkpoint_{self.num_timesteps}.zip"
333
+ self.model.save(str(path))
334
+ logger.info("Checkpoint saved: %s", path.name)
335
+ return True
336
+
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Dyna callback
341
+ # ---------------------------------------------------------------------------
342
+
343
+ class DynaCallback(BaseCallback):
344
+ """
345
+ Augments PPO rewards with a physics-dynamics surprise bonus (Dyna-style).
346
+
347
+ At each environment step, this callback:
348
+ 1. Extracts the current and next observation as ZoneStateTensor objects.
349
+ 2. Calls DynaRolloutBuffer.compute_surprise_bonus() — the normalised
350
+ prediction error of the dynamics model for this transition.
351
+ 3. Writes the bonus directly into the PPO rollout buffer at the position
352
+ that was just written by env.step().
353
+ 4. Optionally fine-tunes the dynamics model on accumulated transitions.
354
+
355
+ Reward injection mechanism
356
+ --------------------------
357
+ SB3's RolloutBuffer stores rewards at self.model.rollout_buffer.rewards[pos-1]
358
+ immediately after env.step() returns, where pos is the buffer write pointer.
359
+ The callback's _on_step() runs after that write, so we can read and modify
360
+ the reward before any PPO computation sees it.
361
+
362
+ The pos pointer advances BEFORE _on_step() is called, so the correct
363
+ index is (self.model.rollout_buffer.pos - 1) % n_steps.
364
+
365
+ This is the same approach used by SB3's RND and curiosity implementations.
366
+
367
+ Safe degradation
368
+ ----------------
369
+ If the dynamics model is unavailable, or if obs keys are missing (e.g.
370
+ during the first step of an episode), the callback returns True silently
371
+ without modifying any reward. It never raises or interrupts training.
372
+
373
+ Args:
374
+ dyna_buffer: DynaRolloutBuffer wrapping the loaded dynamics model.
375
+ dynamics_cfg: DynamicsConfig controlling weights and fine-tuning.
376
+ n_zones: Must match the environment's n_zones.
377
+ horizon_days: Must match ForecastConfig.horizon_days.
378
+ device: Torch device string for tensor ops.
379
+ """
380
+
381
+ _OBS_KEYS = ("forecast_precip", "forecast_uncertainty", "zone_belief")
382
+
383
+ def __init__(
384
+ self,
385
+ dyna_buffer: "DynaRolloutBuffer",
386
+ dynamics_cfg: DynamicsConfig,
387
+ n_zones: int,
388
+ horizon_days: int,
389
+ device: str = "cpu",
390
+ ) -> None:
391
+ super().__init__()
392
+ self.dyna_buffer = dyna_buffer
393
+ self.dynamics_cfg = dynamics_cfg
394
+ self.n_zones = n_zones
395
+ self.horizon_days = horizon_days
396
+ self.device = device
397
+
398
+ # Ring buffer for fine-tuning transitions
399
+ # Stored as (current_ZoneStateTensor, next_ZoneStateTensor) pairs
400
+ self._transition_buffer: list = []
401
+ self._tb_max = dynamics_cfg.transition_buffer_size
402
+
403
+ # Statistics logged every 10k steps
404
+ self._bonus_sum = 0.0
405
+ self._bonus_count = 0
406
+ self._log_freq = 10_000
407
+ self._last_log = 0
408
+
409
+ # Previous obs for transition construction (obs_t → obs_t+1)
410
+ self._prev_obs: Optional[dict] = None
411
+
412
+ def _obs_to_state_tensor(self, obs: dict) -> Optional["ZoneStateTensor"]:
413
+ """
414
+ Convert a raw SB3 obs dict to ZoneStateTensor.
415
+
416
+ SB3 stores observations as numpy arrays with a leading env-count
417
+ dimension even for a single env: shape [1, ...]. We squeeze that dim.
418
+
419
+ Returns None if any required key is missing (safe degradation).
420
+ """
421
+ if not all(k in obs for k in self._OBS_KEYS):
422
+ return None
423
+
424
+ import torch
425
+ import numpy as np
426
+
427
+ try:
428
+ # SB3 obs shapes: [n_envs, ...] — squeeze env dim (n_envs=1)
429
+ precip = np.array(obs["forecast_precip"], dtype=np.float32)
430
+ uncert = np.array(obs["forecast_uncertainty"], dtype=np.float32)
431
+ belief = np.array(obs["zone_belief"], dtype=np.float32)
432
+
433
+ # Handle both [1, n_zones, H] and [n_zones, H] shapes gracefully
434
+ if precip.ndim == 2:
435
+ precip = precip[np.newaxis] # [n_zones, H] -> [1, n_zones, H]
436
+ if uncert.ndim == 1:
437
+ uncert = uncert[np.newaxis] # [n_zones] -> [1, n_zones]
438
+ if belief.ndim == 1:
439
+ belief = belief[np.newaxis]
440
+
441
+ return ZoneStateTensor(
442
+ precip=torch.from_numpy(precip).to(self.device),
443
+ uncertainty=torch.from_numpy(uncert).to(self.device),
444
+ belief=torch.from_numpy(belief).to(self.device),
445
+ )
446
+ except Exception as e:
447
+ logger.debug("DynaCallback._obs_to_state_tensor failed: %s", e)
448
+ return None
449
+
450
+ def _on_step(self) -> bool:
451
+ """
452
+ Called after every env.step(). Injects surprise bonus into reward buffer.
453
+ """
454
+ # --- Extract current and next observations ---
455
+ # self.locals["obs_tensor"] is the obs BEFORE the step (obs_t).
456
+ # self.locals["new_obs"] is the obs AFTER the step (obs_t+1).
457
+ # Both are available in SB3 >= 1.8 on_step locals.
458
+ try:
459
+ obs_now = self.locals.get("obs_tensor") or self.locals.get("obs")
460
+ obs_next = self.locals.get("new_obs")
461
+
462
+ if obs_now is None or obs_next is None:
463
+ return True # safe: missing locals, skip silently
464
+
465
+ # Convert to ZoneStateTensor
466
+ if hasattr(obs_now, "numpy"):
467
+ # Tensor: convert dict-of-tensors or single tensor
468
+ obs_now_np = {k: v.cpu().numpy() for k, v in obs_now.items()} if hasattr(obs_now, "items") else {"_raw": obs_now.cpu().numpy()}
469
+ else:
470
+ obs_now_np = obs_now
471
+
472
+ if hasattr(obs_next, "items"):
473
+ obs_next_np = {k: (v.cpu().numpy() if hasattr(v, "cpu") else v)
474
+ for k, v in obs_next.items()}
475
+ else:
476
+ obs_next_np = obs_next
477
+
478
+ curr_state = self._obs_to_state_tensor(obs_now_np)
479
+ next_state = self._obs_to_state_tensor(obs_next_np)
480
+
481
+ if curr_state is None or next_state is None:
482
+ return True # safe: obs keys not present yet
483
+
484
+ # --- Compute surprise bonus ---
485
+ bonus = self.dyna_buffer.compute_surprise_bonus(curr_state, next_state)
486
+ bonus_val = float(bonus.item())
487
+ bonus_clipped = min(bonus_val, self.dynamics_cfg.surprise_weight)
488
+
489
+ # --- Inject into PPO rollout buffer ---
490
+ # The rollout buffer pos pointer has already advanced; the reward
491
+ # for the current step is at (pos - 1) % n_steps.
492
+ rb = self.model.rollout_buffer
493
+ if rb is not None and hasattr(rb, "rewards") and rb.rewards is not None:
494
+ idx = (rb.pos - 1) % rb.buffer_size
495
+ rb.rewards[idx] += bonus_clipped
496
+
497
+ # --- Accumulate for fine-tuning ---
498
+ if self.dynamics_cfg.update_dynamics_every_n_steps > 0:
499
+ self._transition_buffer.append((curr_state, next_state))
500
+ if len(self._transition_buffer) > self._tb_max:
501
+ self._transition_buffer.pop(0) # ring buffer: drop oldest
502
+
503
+ # --- Statistics ---
504
+ self._bonus_sum += bonus_clipped
505
+ self._bonus_count += 1
506
+
507
+ if self.num_timesteps - self._last_log >= self._log_freq:
508
+ avg_bonus = (
509
+ self._bonus_sum / self._bonus_count
510
+ if self._bonus_count > 0 else 0.0
511
+ )
512
+ logger.info(
513
+ "DynaCallback: step=%d avg_surprise_bonus=%.4f "
514
+ "buffer_size=%d",
515
+ self.num_timesteps, avg_bonus,
516
+ len(self._transition_buffer),
517
+ )
518
+ self._bonus_sum = 0.0
519
+ self._bonus_count = 0
520
+ self._last_log = self.num_timesteps
521
+
522
+ except Exception as e:
523
+ # Never interrupt training on callback error — log and continue
524
+ logger.debug("DynaCallback._on_step error (non-fatal): %s", e)
525
+
526
+ return True
527
+
528
+ def _on_rollout_end(self) -> None:
529
+ """
530
+ Called at the end of each rollout collection. Optionally fine-tunes
531
+ the dynamics model on accumulated transitions.
532
+ """
533
+ if (
534
+ self.dynamics_cfg.update_dynamics_every_n_steps <= 0
535
+ or self.num_timesteps % self.dynamics_cfg.update_dynamics_every_n_steps != 0
536
+ or len(self._transition_buffer) < 16 # need at least one batch
537
+ ):
538
+ return
539
+
540
+ try:
541
+ from physics_dynamics import DynamicsTrainer
542
+ # Access the dynamics model directly from the buffer
543
+ dynamics_model = self.dyna_buffer.dynamics
544
+
545
+ # Minimal fine-tune: a few gradient steps on recent transitions
546
+ # We construct a temporary DynamicsTrainer around the existing model
547
+ # rather than creating a new one, to avoid re-initialising weights.
548
+ import torch
549
+ import torch.nn.functional as F
550
+
551
+ optimizer = torch.optim.AdamW(
552
+ dynamics_model.parameters(), lr=1e-4, weight_decay=1e-4
553
+ )
554
+ dynamics_model.train()
555
+
556
+ pairs = list(self._transition_buffer) # snapshot
557
+ batch_size = min(32, len(pairs))
558
+
559
+ for epoch in range(self.dynamics_cfg.fine_tune_epochs):
560
+ import random
561
+ random.shuffle(pairs)
562
+ total_loss = 0.0
563
+ n_batches = 0
564
+
565
+ for i in range(0, len(pairs) - batch_size, batch_size):
566
+ batch = pairs[i : i + batch_size]
567
+ curr_list = [p[0] for p in batch]
568
+ next_list = [p[1] for p in batch]
569
+
570
+ import torch as _t
571
+ # Stack batch dimension
572
+ curr_b = ZoneStateTensor(
573
+ precip=_t.cat([s.precip for s in curr_list], dim=0),
574
+ uncertainty=_t.cat([s.uncertainty for s in curr_list], dim=0),
575
+ belief=_t.cat([s.belief for s in curr_list], dim=0),
576
+ )
577
+ next_b = ZoneStateTensor(
578
+ precip=_t.cat([s.precip for s in next_list], dim=0),
579
+ uncertainty=_t.cat([s.uncertainty for s in next_list], dim=0),
580
+ belief=_t.cat([s.belief for s in next_list], dim=0),
581
+ )
582
+
583
+ pred, phys_loss = dynamics_model(curr_b, return_physics_loss=True)
584
+ data_loss = (
585
+ F.mse_loss(pred.precip / 500.0, next_b.precip / 500.0)
586
+ + F.mse_loss(pred.uncertainty, next_b.uncertainty)
587
+ + F.mse_loss(pred.belief, next_b.belief)
588
+ )
589
+ loss = data_loss + 0.01 * phys_loss
590
+
591
+ optimizer.zero_grad()
592
+ loss.backward()
593
+ _t.nn.utils.clip_grad_norm_(dynamics_model.parameters(), 1.0)
594
+ optimizer.step()
595
+
596
+ total_loss += loss.item()
597
+ n_batches += 1
598
+
599
+ dynamics_model.eval()
600
+ logger.info(
601
+ "DynaCallback: fine-tuned dynamics model at step=%d "
602
+ "avg_loss=%.4f n_transitions=%d",
603
+ self.num_timesteps,
604
+ total_loss / max(n_batches, 1),
605
+ len(self._transition_buffer),
606
+ )
607
+
608
+ except Exception as e:
609
+ logger.warning(
610
+ "DynaCallback._on_rollout_end fine-tune failed (non-fatal): %s", e
611
+ )
612
+
613
+
614
+ def _build_dyna_callback(
615
+ dynamics_cfg: Optional[DynamicsConfig],
616
+ n_zones: int,
617
+ horizon_days: int,
618
+ device: str,
619
+ ) -> Optional["DynaCallback"]:
620
+ """
621
+ Build a DynaCallback if dynamics are configured and available.
622
+
623
+ Returns None (no Dyna augmentation) if:
624
+ - dynamics_cfg is None
625
+ - dynamics_model_path is not set
626
+ - the model file does not exist
627
+ - physics_dynamics module is unavailable
628
+ - any load/init error occurs
629
+
630
+ Callers pass the return value directly to CallbackList — None is ignored.
631
+ """
632
+ if dynamics_cfg is None or dynamics_cfg.dynamics_model_path is None:
633
+ return None
634
+
635
+ if not _DYNAMICS_AVAILABLE:
636
+ logger.warning(
637
+ "DynamicsConfig provided but physics_dynamics not installed — "
638
+ "Dyna augmentation disabled."
639
+ )
640
+ return None
641
+
642
+ model_path = Path(dynamics_cfg.dynamics_model_path)
643
+ if not model_path.exists():
644
+ logger.warning(
645
+ "Dynamics model not found at %s — Dyna augmentation disabled.",
646
+ model_path,
647
+ )
648
+ return None
649
+
650
+ try:
651
+ dynamics_model = TemporalDynamicsModel.load(str(model_path))
652
+ dynamics_model.eval()
653
+ dyna_buffer = DynaRolloutBuffer(
654
+ dynamics=dynamics_model,
655
+ uncertainty_weight=dynamics_cfg.surprise_weight,
656
+ )
657
+ callback = DynaCallback(
658
+ dyna_buffer=dyna_buffer,
659
+ dynamics_cfg=dynamics_cfg,
660
+ n_zones=n_zones,
661
+ horizon_days=horizon_days,
662
+ device=device,
663
+ )
664
+ logger.info(
665
+ "DynaCallback loaded: model=%s surprise_weight=%.3f "
666
+ "fine_tune_every=%d",
667
+ model_path.name,
668
+ dynamics_cfg.surprise_weight,
669
+ dynamics_cfg.update_dynamics_every_n_steps,
670
+ )
671
+ return callback
672
+
673
+ except Exception as e:
674
+ logger.warning(
675
+ "Failed to build DynaCallback (%s) — Dyna augmentation disabled.", e
676
+ )
677
+ return None
678
+
679
+
680
+
681
+ # ---------------------------------------------------------------------------
682
+ # Training
683
+ # ---------------------------------------------------------------------------
684
+
685
+ def transfer_curriculum_weights(
686
+ resume_from: str,
687
+ model: "MaskablePPO",
688
+ device: str = "auto",
689
+ ) -> "MaskablePPO":
690
+ """
691
+ Warm-start `model` (freshly constructed for the CURRENT phase's env/n_zones)
692
+ from `resume_from`'s checkpoint, transferring every parameter whose shape
693
+ matches exactly and leaving the rest at fresh random initialization.
694
+
695
+ Exists because MaskablePPO.load(path, env=new_env) raises "Observation
696
+ spaces do not match" whenever n_zones changes between curriculum phases
697
+ -- a hard SB3-level space-equality check that fires before any weight-
698
+ shape question is even considered. Loading without `env=` sidesteps that
699
+ (the checkpoint reconstructs against its own saved spaces); this function
700
+ then transfers whatever's compatible directly via the two state_dicts.
701
+
702
+ With the permutation-invariant GRUWeatherFeaturesExtractor (see
703
+ gru_weather_policy.py), every parameter except the action_net output
704
+ layer (shape tied to n_zones+1, the discrete action count) now matches
705
+ across any n_zones -- verified empirically at 61/63 tensors transferred
706
+ in a 2-zone -> 3-zone test. value_net transfers too (scalar output,
707
+ always n_zones-independent); only action_net needs relearning.
708
+ """
709
+ old_model = MaskablePPO.load(resume_from, device=device)
710
+ old_state = old_model.policy.state_dict()
711
+ new_state = model.policy.state_dict()
712
+
713
+ transferred, skipped = [], []
714
+ merged = {}
715
+ for key, new_tensor in new_state.items():
716
+ old_tensor = old_state.get(key)
717
+ if old_tensor is not None and old_tensor.shape == new_tensor.shape:
718
+ merged[key] = old_tensor.clone()
719
+ transferred.append(key)
720
+ else:
721
+ merged[key] = new_tensor
722
+ skipped.append(key)
723
+
724
+ model.policy.load_state_dict(merged)
725
+
726
+ logger.info(
727
+ "transfer_curriculum_weights: transferred %d/%d parameter tensors from %s "
728
+ "(freshly initialized: %s)",
729
+ len(transferred), len(new_state), resume_from, skipped or "none",
730
+ )
731
+ if not transferred:
732
+ logger.warning(
733
+ "transfer_curriculum_weights: transferred ZERO parameters -- the "
734
+ "architectures are likely genuinely incompatible (e.g. resuming "
735
+ "from a pre-permutation-invariant checkpoint), not just a normal "
736
+ "n_zones change. Check resume_from's origin before trusting this run."
737
+ )
738
+ return model
739
+
740
+
741
+ def train_phase(
742
+ phase_name: str,
743
+ output_dir: Path,
744
+ resume_from: Optional[str] = None,
745
+ override_steps: Optional[int] = None,
746
+ hidden_size: int = 64,
747
+ device: str = "auto",
748
+ seed: int = 42,
749
+ dynamics_cfg: Optional[DynamicsConfig] = None,
750
+ precip_scale: float = 40.0,
751
+ ) -> str:
752
+ """Train one curriculum phase. Returns path to the saved final model.
753
+
754
+ Args:
755
+ phase_name: One of the WeatherCurriculum phase names.
756
+ output_dir: Root directory for checkpoints and final model.
757
+ resume_from: Path to a previous phase checkpoint to resume from.
758
+ override_steps: Override total_steps (useful for quick tests).
759
+ hidden_size: GRU hidden size; must match any checkpoint being resumed.
760
+ device: 'cpu', 'cuda', or 'auto'.
761
+ seed: Random seed.
762
+ dynamics_cfg: Optional DynamicsConfig for Dyna surprise-bonus augmentation.
763
+ Pass None (default) for standard training with no overhead.
764
+ precip_scale: Fixed (non-learned) divisor applied to forecast_precip
765
+ before the GRU extractor. See train_kaggle.py's
766
+ INPUT NORMALIZATION docstring section for why this
767
+ exists. Default 40.0 matches the value that produced
768
+ the validated single-dirty selection-accuracy result
769
+ (see model card) -- confirmed working, not confirmed
770
+ optimal, and not yet validated across curriculum
771
+ phase transitions specifically (only within a single
772
+ train_kaggle.py run). Must match across resumed
773
+ checkpoints the same way hidden_size must.
774
+ """
775
+
776
+ if not _ML_AVAILABLE:
777
+ raise RuntimeError(
778
+ f"ML stack not available: {_ML_IMPORT_ERROR}\n"
779
+ "Install: pip install stable-baselines3 sb3-contrib torch"
780
+ )
781
+
782
+ output_dir.mkdir(parents=True, exist_ok=True)
783
+ models_dir = output_dir / "models"
784
+ models_dir.mkdir(exist_ok=True)
785
+
786
+ device = _select_device(
787
+ device if device != "auto"
788
+ else ("cuda" if _TORCH_AVAILABLE and torch.cuda.is_available() else "cpu")
789
+ )
790
+
791
+ phase = WeatherCurriculum.get_phase(phase_name)
792
+ total_steps = override_steps or phase.total_steps
793
+ max_steps = phase.resolved_max_steps()
794
+ full_ceiling = phase.n_zones + 1
795
+
796
+ logger.info(
797
+ "Phase=%s steps=%d max_steps=%d (budget_mode=%s, full_ceiling=%d) "
798
+ "n_zones=%d device=%s must_skip=%s",
799
+ phase.name, total_steps, max_steps, phase.budget_mode, full_ceiling,
800
+ phase.n_zones, device,
801
+ "yes" if max_steps < full_ceiling else "no",
802
+ )
803
+ if max_steps >= full_ceiling and phase.budget_mode not in ("full", "legacy"):
804
+ logger.warning(
805
+ "Phase %s: max_steps=%d >= full_ceiling=%d despite budget_mode=%s — "
806
+ "check resolve_phase_max_steps.",
807
+ phase.name, max_steps, full_ceiling, phase.budget_mode,
808
+ )
809
+
810
+ # --- Environment ---
811
+ config = ForecastConfig(
812
+ n_zones=phase.n_zones,
813
+ seed=seed,
814
+ soft_reset=True,
815
+ max_steps=max_steps,
816
+ )
817
+ phase.risk_weights.attach_to_config(config)
818
+
819
+ # Monitor wraps correctly: get_wrapper_attr('action_masks') walks the
820
+ # wrapper stack and finds action_masks() on WeatherForecastEnv.
821
+ env = Monitor(make_weather_env(config))
822
+
823
+ # --- Policy kwargs ---
824
+ if _GRU_AVAILABLE:
825
+ policy_kwargs = create_gru_weather_policy_kwargs(
826
+ hidden_size=hidden_size,
827
+ precip_scale=precip_scale,
828
+ )
829
+ # ZoneEquivariantMaskablePolicy, NOT the "MultiInputPolicy" string.
830
+ # The default policy builds action logits from action_net(latent_pi)
831
+ # on top of the extractor's pooled (permutation-invariant) feature
832
+ # vector -- zone identity is erased before the action head ever
833
+ # sees it, so under triage the policy can only express a static
834
+ # per-slot bias (empirically: always inspects action index 0,
835
+ # regardless of which zone's content actually looks risky). This
836
+ # was an active bug in this function: GRU features were used, but
837
+ # every prior curriculum-trained checkpoint went through the same
838
+ # pooled action_net as the plain-MLP fallback below and could not
839
+ # have learned risk-conditioned zone selection. See
840
+ # gru_weather_policy.py module docstring and train_kaggle.py's
841
+ # POLICY section for the full diagnosis.
842
+ policy = ZoneEquivariantMaskablePolicy
843
+ logger.info(
844
+ "Using zone-equivariant GRU policy (hidden_size=%d, "
845
+ "precip_scale=%.3g)",
846
+ hidden_size, precip_scale,
847
+ )
848
+ else:
849
+ # dict with 'pi'/'vf' keys is the correct net_arch format for
850
+ # MultiInputPolicy in SB3 >= 1.8 (validated on SB3 2.8.0)
851
+ policy_kwargs = dict(net_arch=dict(pi=[128, 64], vf=[128, 64]))
852
+ policy = "MultiInputPolicy"
853
+ logger.info("GRU policy unavailable — using MLP policy (net_arch=128,64)")
854
+
855
+ # --- Model ---
856
+ ppo_kwargs = dict(
857
+ learning_rate=phase.learning_rate,
858
+ n_steps=phase.n_steps,
859
+ batch_size=phase.batch_size,
860
+ n_epochs=phase.n_epochs,
861
+ gamma=phase.gamma,
862
+ gae_lambda=phase.gae_lambda,
863
+ clip_range=phase.clip_range,
864
+ ent_coef=phase.ent_coef,
865
+ vf_coef=phase.vf_coef,
866
+ max_grad_norm=phase.max_grad_norm,
867
+ device=device,
868
+ verbose=1,
869
+ seed=seed,
870
+ )
871
+
872
+ if resume_from:
873
+ logger.info("Resuming from %s", resume_from)
874
+ # Build a FRESH model for the CURRENT phase's env/n_zones (correct
875
+ # observation/action spaces throughout), then warm-start it from the
876
+ # checkpoint wherever shapes match. MaskablePPO.load(resume_from,
877
+ # env=env) directly would raise "Observation spaces do not match"
878
+ # the moment n_zones changes between phases -- see
879
+ # transfer_curriculum_weights()'s docstring for why, and what
880
+ # actually transfers (everything except action_net).
881
+ model = MaskablePPO(
882
+ policy=policy,
883
+ env=env,
884
+ policy_kwargs=policy_kwargs,
885
+ **ppo_kwargs,
886
+ )
887
+ model = transfer_curriculum_weights(resume_from, model, device=device)
888
+ reset_timesteps = False
889
+ else:
890
+ model = MaskablePPO(
891
+ policy=policy,
892
+ env=env,
893
+ policy_kwargs=policy_kwargs,
894
+ **ppo_kwargs,
895
+ )
896
+ reset_timesteps = True
897
+
898
+ # --- Callbacks ---
899
+ from stable_baselines3.common.callbacks import CallbackList
900
+ callbacks = [CheckpointCallback(output_dir)]
901
+
902
+ horizon_days = getattr(config, "horizon_days", 14)
903
+ dyna_cb = _build_dyna_callback(
904
+ dynamics_cfg=dynamics_cfg,
905
+ n_zones=phase.n_zones,
906
+ horizon_days=horizon_days,
907
+ device=device,
908
+ )
909
+ if dyna_cb is not None:
910
+ callbacks.append(dyna_cb)
911
+ logger.info("Dyna augmentation active for phase=%s", phase.name)
912
+ else:
913
+ logger.info("Dyna augmentation inactive for phase=%s", phase.name)
914
+
915
+ # --- Train ---
916
+ # use_masking=True is the default; MaskablePPO calls action_masks()
917
+ # automatically via get_wrapper_attr during rollout collection.
918
+ # Do NOT pass action_masks= to learn() — it is not a valid parameter.
919
+ model.learn(
920
+ total_timesteps=total_steps,
921
+ callback=CallbackList(callbacks),
922
+ reset_num_timesteps=reset_timesteps,
923
+ use_masking=True,
924
+ )
925
+
926
+ final_path = models_dir / f"final_{phase.name}.zip"
927
+ model.save(str(final_path))
928
+ logger.info("Saved final model: %s", final_path)
929
+
930
+ return str(final_path)
931
+
932
+
933
+ def train_full_curriculum(
934
+ output_dir: Path,
935
+ device: str = "auto",
936
+ seed: int = 42,
937
+ dynamics_cfg: Optional[DynamicsConfig] = None,
938
+ precip_scale: float = 40.0,
939
+ ) -> None:
940
+ """Run all phases in order, chaining each phase from the previous."""
941
+ phases = WeatherCurriculum.phase_order()
942
+ resume = None
943
+ for phase_name in phases:
944
+ logger.info("=== Starting phase: %s ===", phase_name)
945
+ resume = train_phase(
946
+ phase_name=phase_name,
947
+ output_dir=output_dir / phase_name,
948
+ resume_from=resume,
949
+ device=device,
950
+ seed=seed,
951
+ dynamics_cfg=dynamics_cfg,
952
+ precip_scale=precip_scale,
953
+ )
954
+ logger.info("=== Completed phase: %s ===", phase_name)
955
+
956
+
957
+ # ---------------------------------------------------------------------------
958
+ # CLI
959
+ # ---------------------------------------------------------------------------
960
+
961
+ def main() -> None:
962
+ p = argparse.ArgumentParser(
963
+ description="MaskablePPO curriculum trainer for WeatherForecastEnv"
964
+ )
965
+ p.add_argument(
966
+ "--phase",
967
+ choices=list(WeatherCurriculum.PHASES) + ["all"],
968
+ default="normal",
969
+ help="Curriculum phase to run, or 'all' to run full curriculum.",
970
+ )
971
+ p.add_argument("--output-dir", default="./run", help="Root output directory")
972
+ p.add_argument("--resume-from", default=None, help="Path to checkpoint .zip")
973
+ p.add_argument("--steps", type=int, default=None, help="Override total_steps")
974
+ p.add_argument("--hidden-size", type=int, default=64)
975
+ p.add_argument("--device", default="auto", help="'cpu', 'cuda', or 'auto'")
976
+ p.add_argument("--seed", type=int, default=42)
977
+ p.add_argument(
978
+ "--dynamics-model",
979
+ default=None,
980
+ help="Path to pre-trained TemporalDynamicsModel .pt file. Enables Dyna augmentation.",
981
+ )
982
+ p.add_argument(
983
+ "--dynamics-weight",
984
+ type=float,
985
+ default=0.05,
986
+ help="Surprise bonus weight per step (only used with --dynamics-model). Default 0.05.",
987
+ )
988
+ p.add_argument(
989
+ "--dynamics-finetune-every",
990
+ type=int,
991
+ default=0,
992
+ help="Fine-tune dynamics model every N steps. 0=frozen (default).",
993
+ )
994
+ p.add_argument(
995
+ "--precip-scale",
996
+ type=float,
997
+ default=40.0,
998
+ help="Fixed (non-learned) divisor applied to forecast_precip before "
999
+ "the GRU extractor. See train_kaggle.py's INPUT NORMALIZATION "
1000
+ "docstring section for the full rationale. 40.0 matches the "
1001
+ "value that produced the validated single-dirty "
1002
+ "selection-accuracy result (see model card). Must stay "
1003
+ "consistent across a resumed checkpoint's phases, the same "
1004
+ "way --hidden-size must.",
1005
+ )
1006
+ args = p.parse_args()
1007
+
1008
+ output_dir = Path(args.output_dir)
1009
+
1010
+ dynamics_cfg: Optional[DynamicsConfig] = None
1011
+ if args.dynamics_model is not None:
1012
+ dynamics_cfg = DynamicsConfig(
1013
+ dynamics_model_path=args.dynamics_model,
1014
+ surprise_weight=args.dynamics_weight,
1015
+ update_dynamics_every_n_steps=args.dynamics_finetune_every,
1016
+ )
1017
+ logger.info(
1018
+ "Dyna config: model=%s weight=%.3f finetune_every=%d",
1019
+ args.dynamics_model, args.dynamics_weight, args.dynamics_finetune_every,
1020
+ )
1021
+
1022
+ if args.phase == "all":
1023
+ train_full_curriculum(
1024
+ output_dir=output_dir,
1025
+ device=args.device,
1026
+ seed=args.seed,
1027
+ dynamics_cfg=dynamics_cfg,
1028
+ precip_scale=args.precip_scale,
1029
+ )
1030
+ else:
1031
+ train_phase(
1032
+ phase_name=args.phase,
1033
+ output_dir=output_dir,
1034
+ resume_from=args.resume_from,
1035
+ override_steps=args.steps,
1036
+ hidden_size=args.hidden_size,
1037
+ device=args.device,
1038
+ seed=args.seed,
1039
+ dynamics_cfg=dynamics_cfg,
1040
+ precip_scale=args.precip_scale,
1041
+ )
1042
+
1043
+
1044
+ if __name__ == "__main__":
1045
+ main()