DHDRL commited on
Commit
91aa374
·
verified ·
1 Parent(s): 6284db6

Update era5_data_pipeline.py

Browse files
Files changed (1) hide show
  1. era5_data_pipeline.py +236 -67
era5_data_pipeline.py CHANGED
@@ -1,6 +1,23 @@
1
  """
2
  era5_data_pipeline.py
3
  =====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
  from __future__ import annotations
@@ -37,6 +54,25 @@ from zone_observation import (
37
 
38
  logger = logging.getLogger(__name__)
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  # ---------------------------------------------------------------------------
41
  # Optional dependencies
42
  # ---------------------------------------------------------------------------
@@ -337,55 +373,61 @@ def _build_era5_obs_xarray(
337
  start: datetime,
338
  nc_path: Path,
339
  ) -> ZoneObs:
340
- """xarray fallback for _build_era5_obs. Same backward-window,
341
- end-indexed convention -- see _build_era5_obs's docstring."""
 
 
 
 
 
 
 
 
 
 
342
  import xarray as xr
343
  import numpy as np
344
 
345
  ds = xr.open_dataset(str(nc_path))
346
  try:
347
- def _mean(var: str) -> float:
348
  if var not in ds:
349
- return 0.0
350
- return float(ds[var].values.flatten()[np.isfinite(
351
- ds[var].values.flatten()
352
- )].mean()) if len(ds[var].values.flatten()) > 0 else 0.0
353
 
354
- def _max(var: str) -> float:
355
- if var not in ds:
356
- return 0.0
357
- vals = ds[var].values.flatten()
358
- valid = vals[np.isfinite(vals)]
 
359
  return float(valid.max()) if len(valid) > 0 else 0.0
360
 
361
- def _sum(var: str, scale: float = 1.0) -> float:
362
- if var not in ds:
363
- return 0.0
364
- vals = ds[var].values.flatten()
365
- valid = vals[np.isfinite(vals)]
 
366
  return float(valid.sum() * scale) if len(valid) > 0 else 0.0
367
 
368
- temp_mean_c = _mean("t2m") - 273.15
369
- temp_max_c = _max("t2m") - 273.15
370
- if "t2m" in ds:
371
- _t2m_flat = ds["t2m"].values.flatten()
372
- _t2m_valid = _t2m_flat[np.isfinite(_t2m_flat)]
373
- temp_min_c = float(np.min(_t2m_valid)) - 273.15 if len(_t2m_valid) > 0 else temp_mean_c
374
- else:
375
- temp_min_c = temp_mean_c
376
 
377
- dewpoint_mean_c = _mean("d2m") - 273.15
378
- dewpoint_max_c = _max("d2m") - 273.15
379
  rh_mean = _dewpoint_to_rh(temp_mean_c, dewpoint_mean_c)
380
  rh_max = _dewpoint_to_rh(temp_min_c, dewpoint_max_c)
381
 
382
- precip_24h = max(0.0, _sum("tp", scale=1000.0))
383
-
384
- if "tp" in ds:
385
- total_hours = len(ds["tp"].values.flatten())
386
- else:
387
- total_hours = 0
388
 
 
389
  MIN_HOURS_30D = 24 * 30
390
  if total_hours < MIN_HOURS_30D:
391
  raise RuntimeError(
@@ -396,21 +438,16 @@ def _build_era5_obs_xarray(
396
  f"widen the ERA5 request window."
397
  )
398
 
399
- def _window_sum_xr(var: str, hours: int, scale: float = 1.0) -> float:
400
- if var not in ds:
401
- return 0.0
402
- vals = ds[var].values.flatten()[-hours:]
403
- valid = vals[np.isfinite(vals)]
404
- return float(valid.sum() * scale) if len(valid) > 0 else 0.0
405
  precip_7d = max(0.0, _window_sum_xr("tp", 24 * 7, scale=1000.0))
406
  precip_14d = max(0.0, _window_sum_xr("tp", 24 * 14, scale=1000.0))
407
  precip_30d = max(0.0, _window_sum_xr("tp", 24 * 30, scale=1000.0))
408
- u = _mean("u10")
409
- v = _mean("v10")
 
410
  wind_mean = math.sqrt(u**2 + v**2)
411
- wind_max = math.sqrt(_max("u10")**2 + _max("v10")**2)
412
- soil_pct = _mean("swvl1") * 100.0
413
- et0_mm = abs(_sum("pev")) * 1000.0
414
 
415
  return ZoneObs(
416
  zone_id=zone_id,
@@ -493,8 +530,33 @@ def _fetch_openmeteo(zone_id: str, valid_time: datetime) -> ZoneObs:
493
  data = _cached_get(url, params)
494
  daily = data.get("daily", {})
495
 
496
- # Chronological, ending at `anchor` -- index from the END, not the
497
- # start, so "last N days" genuinely means the N most recent days
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  # relative to anchor, never days after it.
499
  precip_series = daily.get("precipitation_sum", [])
500
  n_valid_precip = sum(1 for v in precip_series if v is not None)
@@ -534,7 +596,7 @@ def _fetch_openmeteo(zone_id: str, valid_time: datetime) -> ZoneObs:
534
  return ZoneObs(
535
  zone_id=zone_id,
536
  valid_time=anchor,
537
- source=DataSource.OPENMETEO_LIVE,
538
  precip_24h_mm=_safe_last("precipitation_sum"),
539
  precip_7d_mm=_safe_sum_last("precipitation_sum", 7),
540
  precip_14d_mm=_safe_sum_last("precipitation_sum", 14),
@@ -558,8 +620,12 @@ def _fetch_era5(zone_id: str, valid_time: datetime) -> ZoneObs:
558
  constructed from it internally. See _fetch_openmeteo's docstring for
559
  why this takes valid_time rather than a date_range tuple."""
560
  if not CDSAPI_AVAILABLE:
561
- logger.debug("cdsapi unavailable — falling back to synthetic for %s", zone_id)
562
- return _fetch_synthetic(zone_id, valid_time)
 
 
 
 
563
 
564
  lat, lon = _resolve_latlon(zone_id)
565
  anchor = _ensure_utc(valid_time)
@@ -622,41 +688,47 @@ def _fetch_era5(zone_id: str, valid_time: datetime) -> ZoneObs:
622
  )
623
  logger.info("ERA5 download complete: %s", nc_path)
624
  except Exception as e:
625
- logger.warning(
626
- "ERA5 CDS request failed for %s: %s — falling back to synthetic",
627
- zone_id, e
628
- )
629
  if nc_path.exists():
630
  nc_path.unlink()
631
- return _fetch_synthetic(zone_id, anchor)
 
 
632
 
633
  try:
634
  return _build_era5_obs(zone_id, anchor, nc_path)
635
  except Exception as e:
636
- logger.warning(
637
- "ERA5 NetCDF parse failed for %s: %s — falling back to synthetic",
638
- zone_id, e
639
- )
640
- return _fetch_synthetic(zone_id, anchor)
641
 
642
 
643
  def _fetch_imerg(zone_id: str, valid_time: datetime) -> ZoneObs:
644
  """Explicit valid_time. Also fixes the same forward-window bug found
645
  in _fetch_openmeteo: the previous version queried
646
  [start, start+n_days) for "last n_days" precip -- forward, not
647
- backward. Now queries [start-n_days, start]."""
 
 
 
 
 
 
 
 
 
648
  _ensure_ee_initialized()
649
 
650
  lat, lon = _resolve_latlon(zone_id)
651
  start = _ensure_utc(valid_time)
 
652
 
653
  region = ee.Geometry.Point([lon, lat]).buffer(_ERA5_BOX_PAD * 111_000)
654
 
655
  def _window_sum_mm(n_days: int) -> float:
656
- window_start = start - timedelta(days=n_days)
657
  coll = (
658
  ee.ImageCollection("NASA/GPM_L3/IMERG_V07")
659
- .filterDate(window_start.isoformat(), start.isoformat())
660
  .filterBounds(region)
661
  .select("precipitation")
662
  )
@@ -1102,10 +1174,29 @@ def _select_source(cfg: ForecastConfig, rng: random.Random) -> DataSource:
1102
  )
1103
 
1104
 
1105
- def _fallback_chain(primary: DataSource) -> List[DataSource]:
 
 
 
 
 
 
 
 
 
 
 
1106
  if primary in (DataSource.SATELLITE_PRECIP, DataSource.SATELLITE_SOIL):
1107
- return [primary, DataSource.ERA5_REANALYSIS, DataSource.SYNTHETIC]
1108
- return [primary, DataSource.SYNTHETIC]
 
 
 
 
 
 
 
 
1109
 
1110
 
1111
  def fetch_zone_obs(
@@ -1133,6 +1224,15 @@ def fetch_zone_obs(
1133
  else:
1134
  primary = _select_source(cfg, rng)
1135
 
 
 
 
 
 
 
 
 
 
1136
  fetchers = {
1137
  DataSource.OPENMETEO_LIVE: _fetch_openmeteo,
1138
  DataSource.ERA5_REANALYSIS: _fetch_era5,
@@ -1140,9 +1240,12 @@ def fetch_zone_obs(
1140
  DataSource.SATELLITE_SOIL: _fetch_smap,
1141
  DataSource.SYNTHETIC: _fetch_synthetic,
1142
  }
 
 
 
1143
 
1144
  obs: Optional[ZoneObs] = None
1145
- for source in _fallback_chain(primary):
1146
  try:
1147
  obs = fetchers[source](zone_id, valid_time)
1148
  ZoneObs.validate(obs, strict=True)
@@ -1764,6 +1867,72 @@ def _self_test() -> int:
1764
  print(" fetch_episode_context tuple-compat OK "
1765
  "(date_range[1] cannot reintroduce the leak, even here)")
1766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1767
  print()
1768
  if failures:
1769
  print(f"FAILED {len(failures)} test(s):")
@@ -1771,7 +1940,7 @@ def _self_test() -> int:
1771
  print(f" - {f}")
1772
  sys.exit(1)
1773
  else:
1774
- print("All 5 test groups passed.")
1775
 
1776
 
1777
  if __name__ == "__main__":
 
1
  """
2
  era5_data_pipeline.py
3
  =====================
4
+ Causal observation fetchers for WeatherForecastEnv / historical caches.
5
+
6
+ Contract
7
+ --------
8
+ - Callers pass an explicit valid_time (the "as of" date). Fetchers build
9
+ their own BACKWARD 30-day window ending on that date. There is no
10
+ date_range tuple for observations.
11
+ - precip_24h / 7d / 14d / 30d are last-N-days totals through valid_time,
12
+ never a forward sum from valid_time.
13
+ - Historical Open-Meteo dates use archive-api.open-meteo.com and are
14
+ stamped DataSource.OPENMETEO_ARCHIVE when that enum member exists.
15
+ - Insufficient history raises; it does not zero-pad (zero-pad looks like
16
+ drought).
17
+ - A force_data_source other than SYNTHETIC does not fall back to
18
+ make_synthetic_zone_obs unless allow_synthetic_obs_fallback=True.
19
+ - This module does not issue real NWP forecasts. forecast_backend="baseline"
20
+ is persistence of causal precip_30d and must not be reported as skill.
21
  """
22
 
23
  from __future__ import annotations
 
54
 
55
  logger = logging.getLogger(__name__)
56
 
57
+
58
+ def _openmeteo_archive_source():
59
+ """Prefer DataSource.OPENMETEO_ARCHIVE when zone_observation has it.
60
+
61
+ Historical dates must not be stamped OPENMETEO_LIVE. If the enum member
62
+ has not been added yet, fall back to LIVE and warn -- the fetch URL is
63
+ still the archive endpoint; only the provenance tag is degraded.
64
+ """
65
+ src = getattr(DataSource, "OPENMETEO_ARCHIVE", None)
66
+ if src is not None:
67
+ return src
68
+ logger.warning(
69
+ "DataSource.OPENMETEO_ARCHIVE is missing from zone_observation.py. "
70
+ "Add OPENMETEO_ARCHIVE = 'openmeteo_archive' next to OPENMETEO_LIVE. "
71
+ "Stamping OPENMETEO_LIVE for archive fetches until that lands."
72
+ )
73
+ return DataSource.OPENMETEO_LIVE
74
+
75
+
76
  # ---------------------------------------------------------------------------
77
  # Optional dependencies
78
  # ---------------------------------------------------------------------------
 
373
  start: datetime,
374
  nc_path: Path,
375
  ) -> ZoneObs:
376
+ """xarray fallback for _build_era5_obs.
377
+
378
+ This function's docstring previously claimed to mirror
379
+ _build_era5_obs's backward-window, end-indexed convention, but only
380
+ the precip window-sums (_window_sum_xr) actually did -- _mean/_max/
381
+ _sum had no windowing at all and aggregated the ENTIRE downloaded
382
+ cube (all 30 days) for every "daily" stat: temp_mean/max/min, RH,
383
+ wind, soil moisture, ET0, and precip_24h. Fixed by giving every
384
+ helper the same [-hours:] slicing _build_era5_obs uses, so this
385
+ fallback path (taken whenever netCDF4 isn't installed but xarray is)
386
+ can no longer silently diverge from the primary path's correctness.
387
+ """
388
  import xarray as xr
389
  import numpy as np
390
 
391
  ds = xr.open_dataset(str(nc_path))
392
  try:
393
+ def _windowed(var: str, hours: int) -> "np.ndarray":
394
  if var not in ds:
395
+ return np.array([])
396
+ vals = ds[var].values.flatten()[-hours:]
397
+ return vals[np.isfinite(vals)]
 
398
 
399
+ def _daily_mean(var: str) -> float:
400
+ valid = _windowed(var, 24)
401
+ return float(valid.mean()) if len(valid) > 0 else 0.0
402
+
403
+ def _daily_max(var: str) -> float:
404
+ valid = _windowed(var, 24)
405
  return float(valid.max()) if len(valid) > 0 else 0.0
406
 
407
+ def _daily_min(var: str, fallback: float) -> float:
408
+ valid = _windowed(var, 24)
409
+ return float(valid.min()) if len(valid) > 0 else fallback
410
+
411
+ def _daily_sum(var: str, scale: float = 1.0) -> float:
412
+ valid = _windowed(var, 24)
413
  return float(valid.sum() * scale) if len(valid) > 0 else 0.0
414
 
415
+ def _window_sum_xr(var: str, hours: int, scale: float = 1.0) -> float:
416
+ valid = _windowed(var, hours)
417
+ return float(valid.sum() * scale) if len(valid) > 0 else 0.0
418
+
419
+ temp_mean_c = _daily_mean("t2m") - 273.15
420
+ temp_max_c = _daily_max("t2m") - 273.15
421
+ temp_min_c = _daily_min("t2m", fallback=temp_mean_c + 273.15) - 273.15
 
422
 
423
+ dewpoint_mean_c = _daily_mean("d2m") - 273.15
424
+ dewpoint_max_c = _daily_max("d2m") - 273.15
425
  rh_mean = _dewpoint_to_rh(temp_mean_c, dewpoint_mean_c)
426
  rh_max = _dewpoint_to_rh(temp_min_c, dewpoint_max_c)
427
 
428
+ precip_24h = max(0.0, _daily_sum("tp", scale=1000.0))
 
 
 
 
 
429
 
430
+ total_hours = len(ds["tp"].values.flatten()) if "tp" in ds else 0
431
  MIN_HOURS_30D = 24 * 30
432
  if total_hours < MIN_HOURS_30D:
433
  raise RuntimeError(
 
438
  f"widen the ERA5 request window."
439
  )
440
 
 
 
 
 
 
 
441
  precip_7d = max(0.0, _window_sum_xr("tp", 24 * 7, scale=1000.0))
442
  precip_14d = max(0.0, _window_sum_xr("tp", 24 * 14, scale=1000.0))
443
  precip_30d = max(0.0, _window_sum_xr("tp", 24 * 30, scale=1000.0))
444
+
445
+ u = _daily_mean("u10")
446
+ v = _daily_mean("v10")
447
  wind_mean = math.sqrt(u**2 + v**2)
448
+ wind_max = math.sqrt(_daily_max("u10")**2 + _daily_max("v10")**2)
449
+ soil_pct = _daily_mean("swvl1") * 100.0
450
+ et0_mm = abs(_daily_sum("pev")) * 1000.0
451
 
452
  return ZoneObs(
453
  zone_id=zone_id,
 
530
  data = _cached_get(url, params)
531
  daily = data.get("daily", {})
532
 
533
+ # Pin the series to the anchor calendar date. Open-Meteo usually
534
+ # honors end_date, but the live forecast endpoint can still return
535
+ # days after `anchor`. Indexing "from the end" of that payload would
536
+ # reintroduce a short forward leak. Slice through the last index
537
+ # whose `time` label equals the anchor (or the last element if no
538
+ # time axis is present).
539
+ times = daily.get("time") or []
540
+ anchor_iso = anchor.date().isoformat()
541
+ if times:
542
+ end_idx = None
543
+ for i in range(len(times) - 1, -1, -1):
544
+ if str(times[i])[:10] == anchor_iso:
545
+ end_idx = i
546
+ break
547
+ if end_idx is None:
548
+ raise RuntimeError(
549
+ f"_fetch_openmeteo: anchor {anchor_iso} not present on "
550
+ f"Open-Meteo time axis for {zone_id} "
551
+ f"({times[0]} .. {times[-1]})."
552
+ )
553
+ daily = {
554
+ k: (v[: end_idx + 1] if isinstance(v, list) else v)
555
+ for k, v in daily.items()
556
+ }
557
+
558
+ # Chronological, ending at `anchor` -- index from the END of the
559
+ # sliced series, so "last N days" means the N most recent days
560
  # relative to anchor, never days after it.
561
  precip_series = daily.get("precipitation_sum", [])
562
  n_valid_precip = sum(1 for v in precip_series if v is not None)
 
596
  return ZoneObs(
597
  zone_id=zone_id,
598
  valid_time=anchor,
599
+ source=_openmeteo_archive_source() if use_archive else DataSource.OPENMETEO_LIVE,
600
  precip_24h_mm=_safe_last("precipitation_sum"),
601
  precip_7d_mm=_safe_sum_last("precipitation_sum", 7),
602
  precip_14d_mm=_safe_sum_last("precipitation_sum", 14),
 
620
  constructed from it internally. See _fetch_openmeteo's docstring for
621
  why this takes valid_time rather than a date_range tuple."""
622
  if not CDSAPI_AVAILABLE:
623
+ raise RuntimeError(
624
+ f"_fetch_era5: cdsapi is not installed — cannot fetch ERA5 for "
625
+ f"{zone_id} at {valid_time.date().isoformat()}. fetch_zone_obs "
626
+ f"may fall back to another source; this function will not invent "
627
+ f"synthetic weather itself."
628
+ )
629
 
630
  lat, lon = _resolve_latlon(zone_id)
631
  anchor = _ensure_utc(valid_time)
 
688
  )
689
  logger.info("ERA5 download complete: %s", nc_path)
690
  except Exception as e:
 
 
 
 
691
  if nc_path.exists():
692
  nc_path.unlink()
693
+ raise RuntimeError(
694
+ f"_fetch_era5: CDS request failed for {zone_id}: {e}"
695
+ ) from e
696
 
697
  try:
698
  return _build_era5_obs(zone_id, anchor, nc_path)
699
  except Exception as e:
700
+ raise RuntimeError(
701
+ f"_fetch_era5: NetCDF parse failed for {zone_id}: {e}"
702
+ ) from e
 
 
703
 
704
 
705
  def _fetch_imerg(zone_id: str, valid_time: datetime) -> ZoneObs:
706
  """Explicit valid_time. Also fixes the same forward-window bug found
707
  in _fetch_openmeteo: the previous version queried
708
  [start, start+n_days) for "last n_days" precip -- forward, not
709
+ backward. Now queries [start-n_days, start+1day).
710
+
711
+ The "+1day" on the end bound matters: Earth Engine's
712
+ ImageCollection.filterDate(start, end) is half-open -- [start, end).
713
+ Passing end=start (valid_time) would silently exclude valid_time's
714
+ own day's image(s), making every "last N days" window actually mean
715
+ "last N days ending yesterday." Using end=start+1day makes it
716
+ genuinely inclusive of valid_time, matching precip_24h/7d/14d/30d's
717
+ documented meaning and _fetch_openmeteo's equivalent window.
718
+ """
719
  _ensure_ee_initialized()
720
 
721
  lat, lon = _resolve_latlon(zone_id)
722
  start = _ensure_utc(valid_time)
723
+ inclusive_end = start + timedelta(days=1)
724
 
725
  region = ee.Geometry.Point([lon, lat]).buffer(_ERA5_BOX_PAD * 111_000)
726
 
727
  def _window_sum_mm(n_days: int) -> float:
728
+ window_start = start - timedelta(days=n_days - 1)
729
  coll = (
730
  ee.ImageCollection("NASA/GPM_L3/IMERG_V07")
731
+ .filterDate(window_start.isoformat(), inclusive_end.isoformat())
732
  .filterBounds(region)
733
  .select("precipitation")
734
  )
 
1174
  )
1175
 
1176
 
1177
+ def _fallback_chain(
1178
+ primary: DataSource,
1179
+ allow_synthetic: bool = True,
1180
+ ) -> List[DataSource]:
1181
+ """Ordered sources to try for one fetch_zone_obs call.
1182
+
1183
+ When allow_synthetic is False (the cache-builder default once a real
1184
+ source is pinned via force_data_source), a failed Open-Meteo/ERA5
1185
+ fetch raises instead of quietly planting make_synthetic_zone_obs
1186
+ into a "historical" pickle.
1187
+ """
1188
+ archive = getattr(DataSource, "OPENMETEO_ARCHIVE", None)
1189
  if primary in (DataSource.SATELLITE_PRECIP, DataSource.SATELLITE_SOIL):
1190
+ chain: List[DataSource] = [primary, DataSource.ERA5_REANALYSIS]
1191
+ elif archive is not None and primary == archive:
1192
+ chain = [primary, DataSource.OPENMETEO_LIVE]
1193
+ elif primary == DataSource.OPENMETEO_LIVE:
1194
+ chain = [primary]
1195
+ else:
1196
+ chain = [primary]
1197
+ if allow_synthetic and DataSource.SYNTHETIC not in chain:
1198
+ chain.append(DataSource.SYNTHETIC)
1199
+ return chain
1200
 
1201
 
1202
  def fetch_zone_obs(
 
1224
  else:
1225
  primary = _select_source(cfg, rng)
1226
 
1227
+ # Pinned real sources must not silently become synthetic. Opt in with
1228
+ # ForecastConfig.allow_synthetic_obs_fallback=True if a demo path
1229
+ # needs the old behaviour.
1230
+ pinned = getattr(cfg, "force_data_source", None)
1231
+ if pinned is not None and pinned != DataSource.SYNTHETIC:
1232
+ allow_synthetic = bool(getattr(cfg, "allow_synthetic_obs_fallback", False))
1233
+ else:
1234
+ allow_synthetic = bool(getattr(cfg, "allow_synthetic_obs_fallback", True))
1235
+
1236
  fetchers = {
1237
  DataSource.OPENMETEO_LIVE: _fetch_openmeteo,
1238
  DataSource.ERA5_REANALYSIS: _fetch_era5,
 
1240
  DataSource.SATELLITE_SOIL: _fetch_smap,
1241
  DataSource.SYNTHETIC: _fetch_synthetic,
1242
  }
1243
+ archive = getattr(DataSource, "OPENMETEO_ARCHIVE", None)
1244
+ if archive is not None:
1245
+ fetchers[archive] = _fetch_openmeteo
1246
 
1247
  obs: Optional[ZoneObs] = None
1248
+ for source in _fallback_chain(primary, allow_synthetic=allow_synthetic):
1249
  try:
1250
  obs = fetchers[source](zone_id, valid_time)
1251
  ZoneObs.validate(obs, strict=True)
 
1867
  print(" fetch_episode_context tuple-compat OK "
1868
  "(date_range[1] cannot reintroduce the leak, even here)")
1869
 
1870
+ # 6. Archive vs live provenance: an anchor older than (today-5d)
1871
+ # must request the archive URL. The source tag is
1872
+ # OPENMETEO_ARCHIVE when the enum exists, else LIVE-with-warning.
1873
+ old_anchor = datetime(2020, 6, 15, tzinfo=timezone.utc)
1874
+ with mock.patch(
1875
+ __name__ + "._cached_get",
1876
+ return_value={"daily": {
1877
+ **fake_daily_backward,
1878
+ "time": [
1879
+ (old_anchor.date() - timedelta(days=29-i)).isoformat()
1880
+ for i in range(30)
1881
+ ],
1882
+ }},
1883
+ ) as mocked_arch:
1884
+ obs_arch = _fetch_openmeteo("test_zone", old_anchor)
1885
+ arch_url = mocked_arch.call_args[0][0]
1886
+ _assert("archive-api.open-meteo.com" in arch_url,
1887
+ f"old anchor should hit archive API, got {arch_url}")
1888
+ expected_src = getattr(DataSource, "OPENMETEO_ARCHIVE", DataSource.OPENMETEO_LIVE)
1889
+ _assert(obs_arch.source == expected_src,
1890
+ f"archive fetch source should be {expected_src}, got {obs_arch.source}")
1891
+ print(f" Archive provenance OK: url=archive source={obs_arch.source.value}")
1892
+
1893
+ # 7. Live payload that overshoots the anchor by 5 days must NOT
1894
+ # fold those future days into precip_7d (time-axis pin).
1895
+ overshoot_times = []
1896
+ overshoot_precip = []
1897
+ start_d = anchor.date() - timedelta(days=29)
1898
+ for i in range(35):
1899
+ d = start_d + timedelta(days=i)
1900
+ overshoot_times.append(d.isoformat())
1901
+ overshoot_precip.append(1.0 if d <= anchor.date() else 500.0)
1902
+ fake_overshoot = dict(fake_daily_backward)
1903
+ fake_overshoot["time"] = overshoot_times
1904
+ fake_overshoot["precipitation_sum"] = overshoot_precip
1905
+ for k, v in list(fake_overshoot.items()):
1906
+ if k not in ("time", "precipitation_sum") and isinstance(v, list):
1907
+ fake_overshoot[k] = (v + v[:5])[:35]
1908
+ with mock.patch(
1909
+ __name__ + "._cached_get",
1910
+ return_value={"daily": fake_overshoot},
1911
+ ):
1912
+ obs_pin = _fetch_openmeteo("test_zone", anchor)
1913
+ _assert(abs(obs_pin.precip_7d_mm - 7.0) < 1e-6,
1914
+ f"time-axis pin failed: precip_7d={obs_pin.precip_7d_mm} "
1915
+ f"(future 500mm days leaked)")
1916
+ _assert(abs(obs_pin.precip_30d_mm - 30.0) < 1e-6,
1917
+ f"time-axis pin failed: precip_30d={obs_pin.precip_30d_mm}")
1918
+ print(" Time-axis pin OK (payload days after anchor ignored)")
1919
+
1920
+ # 8. Pinned real source must not fall through to synthetic.
1921
+ with mock.patch(
1922
+ __name__ + "._fetch_openmeteo",
1923
+ side_effect=RuntimeError("simulated outage"),
1924
+ ):
1925
+ try:
1926
+ fetch_zone_obs(
1927
+ "test_zone",
1928
+ anchor,
1929
+ ForecastConfig(force_data_source=DataSource.OPENMETEO_LIVE),
1930
+ )
1931
+ _assert(False, "pinned OPENMETEO_LIVE should not succeed via synthetic")
1932
+ except RuntimeError:
1933
+ pass
1934
+ print(" Pinned-source no-synthetic-fallback OK")
1935
+
1936
  print()
1937
  if failures:
1938
  print(f"FAILED {len(failures)} test(s):")
 
1940
  print(f" - {f}")
1941
  sys.exit(1)
1942
  else:
1943
+ print("All 8 test groups passed.")
1944
 
1945
 
1946
  if __name__ == "__main__":