DHDRL commited on
Commit
9927b6d
·
verified ·
1 Parent(s): 0184a3f

Update climatology.py

Browse files
Files changed (1) hide show
  1. climatology.py +7 -148
climatology.py CHANGED
@@ -3,25 +3,8 @@ 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
  ----------------------------------------------------------------------------
@@ -39,14 +22,6 @@ DATA SOURCES (two tiers, matching the codebase's degrade-safely philosophy)
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
@@ -83,15 +58,12 @@ except ImportError:
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)
@@ -100,7 +72,9 @@ _TABLE_LEN = 366 # DOY table indexed doy-1; DOY 60 = Feb 29 (leap mapping below
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.
@@ -116,12 +90,6 @@ def _is_leap(year: int) -> bool:
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
@@ -129,7 +97,6 @@ def doy_index(dt: datetime) -> int:
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):
@@ -148,12 +115,6 @@ def _circular_smooth(values: List[float], half_window: int = 7) -> List[float]:
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
@@ -165,9 +126,6 @@ class ZoneClimatology:
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
 
@@ -184,15 +142,6 @@ class ZoneClimatology:
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
@@ -234,10 +183,6 @@ class ZoneClimatology:
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:
@@ -266,22 +211,6 @@ class ZoneClimatology:
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)
@@ -316,10 +245,6 @@ def _synthetic_climatology(zone_id: str, lat: float, n_years: int = 0) -> ZoneCl
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))
@@ -359,29 +284,6 @@ def _fetch_openmeteo_climatology(
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
 
@@ -567,20 +469,6 @@ def get_zone_climatology(
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 = (
@@ -623,29 +511,6 @@ def get_zone_climatology(
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",
@@ -690,12 +555,6 @@ def apply_anomalies_by_zone_id(
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)
@@ -831,4 +690,4 @@ if __name__ == "__main__":
831
  print(f" - {f}")
832
  sys.exit(1)
833
  else:
834
- print("All 9 test groups passed.")
 
3
  ==============
4
  Per-zone day-of-year climatology and anomaly (z-score) computation.
5
 
6
+ This module turns absolute real-world readings into the z-score
7
+ anomalies the rest of the pipeline was designed around.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  DATA SOURCES (two tiers, matching the codebase's degrade-safely philosophy)
10
  ----------------------------------------------------------------------------
 
22
  for northern Sumatra). It exists so the pipeline keeps producing
23
  sensible anomalies offline and in tests. It is NOT a retrieval --
24
  treat its absolute values as plausible shapes, not measurements.
 
 
 
 
 
 
 
 
25
  """
26
 
27
  from __future__ import annotations
 
58
  # Constants
59
  # ---------------------------------------------------------------------------
60
 
 
 
 
61
  _OPENMETEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
62
  _TIMEOUT_S = int(os.environ.get("WEATHER_HTTP_TIMEOUT", "60"))
63
 
64
  _CACHE_DIR = Path(os.environ.get("WEATHER_CACHE_DIR", ".cache/era5")) / "climatology"
65
  _CACHE_DIR.mkdir(parents=True, exist_ok=True)
66
+ _CACHE_TTL_DAYS = 90 # climatology drifts slowly; quarterly refresh is ample
67
 
68
  _DAYS_PER_YEAR = 365.25
69
  _TABLE_LEN = 366 # DOY table indexed doy-1; DOY 60 = Feb 29 (leap mapping below)
 
72
  _PRECIP_STD_FLOOR = 1.5 # mm/day
73
  _TEMP_STD_FLOOR = 0.4 # deg C
74
  _SOIL_STD_FLOOR = 2.0 # percent
75
+ _RH_STD_FLOOR = 3.0 # percent -- RH is bounded [0,100] and often near-
76
+ # saturated in the tropics, so day-to-day variance
77
+ # is naturally small; floor prevents z-score blowup
78
 
79
  # Trailing window for the precipitation anomaly. Matches ZoneObs.precip_30d_mm,
80
  # the longest aggregate every real fetcher populates.
 
90
 
91
 
92
  def doy_index(dt: datetime) -> int:
 
 
 
 
 
 
93
  doy = dt.timetuple().tm_yday
94
  if not _is_leap(dt.year) and doy >= 60:
95
  doy += 1
 
97
 
98
 
99
  def _circular_smooth(values: List[float], half_window: int = 7) -> List[float]:
 
100
  n = len(values)
101
  out = []
102
  for i in range(n):
 
115
 
116
  @dataclass
117
  class ZoneClimatology:
 
 
 
 
 
 
118
  zone_id: str
119
  source: str # 'openmeteo_archive' | 'synthetic_model' | 'mixed'
120
  n_years: int
 
126
  temp_std_c: List[float] = field(default_factory=list)
127
  soil_mean_pct: List[float] = field(default_factory=list)
128
  soil_std_pct: List[float] = field(default_factory=list)
 
 
 
129
  rh_mean_pct: List[float] = field(default_factory=list)
130
  rh_std_pct: List[float] = field(default_factory=list)
131
 
 
142
 
143
  # --- Window statistics ------------------------------------------------
144
  def window_precip_stats(self, dt: datetime, window_days: int) -> Tuple[float, float]:
 
 
 
 
 
 
 
 
 
145
  idx0 = doy_index(dt) - 1
146
  mean = 0.0
147
  var = 0.0
 
183
 
184
  @classmethod
185
  def from_dict(cls, d: Dict[str, Any]) -> "ZoneClimatology":
 
 
 
 
186
  rh_mean = d.get("rh_mean_pct")
187
  rh_std = d.get("rh_std_pct")
188
  if rh_mean is None or len(rh_mean) != _TABLE_LEN:
 
211
  # ---------------------------------------------------------------------------
212
 
213
  def _synthetic_climatology(zone_id: str, lat: float, n_years: int = 0) -> ZoneClimatology:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  seed = _stable_seed(f"clim_{zone_id}")
215
  # Deterministic jitter in [0.9, 1.1] from the seed's low bits.
216
  jitter = 0.9 + 0.2 * ((seed % 1000) / 1000.0)
 
245
  temp_mean.append(t)
246
  temp_std.append(0.7)
247
 
 
 
 
 
248
  r = 86.0 + amp_scale * 6.0 * math.cos(phase)
249
  rh_mean.append(_clip(r, 78.0, 94.0))
250
  rh_std.append(max(_RH_STD_FLOOR, 3.5))
 
284
  years: int,
285
  end_year: Optional[int] = None,
286
  ) -> ZoneClimatology:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  if not _REQUESTS_AVAILABLE:
288
  raise RuntimeError("requests not installed")
289
 
 
469
  prefer_real: bool = True,
470
  use_cache: bool = True,
471
  ) -> ZoneClimatology:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  path = _cache_path(zone_id, years, end_year)
473
  if use_cache and path.exists():
474
  age_days = (
 
511
 
512
 
513
  def apply_climatology_anomalies(obs: ZoneObs, clim: ZoneClimatology) -> ZoneObs:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  if obs.source == DataSource.SYNTHETIC:
515
  logger.debug(
516
  "climatology: %s source is SYNTHETIC -- anomalies left as injected",
 
555
  years: int = 10,
556
  prefer_real: bool = True,
557
  ) -> ZoneObs:
 
 
 
 
 
 
558
  clim = get_zone_climatology(obs.zone_id, lat, lon, years=years,
559
  prefer_real=prefer_real)
560
  return apply_climatology_anomalies(obs, clim)
 
690
  print(f" - {f}")
691
  sys.exit(1)
692
  else:
693
+ print("All 9 test groups passed.")