Reinforcement Learning
stable-baselines3
deep-reinforcement-learning
agricultural-ai
weather-modelling
curriculum-learning
edge-ai
Instructions to use DHDRL/monsoon-rl with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- stable-baselines3
How to use DHDRL/monsoon-rl with stable-baselines3:
from huggingface_sb3 import load_from_hub checkpoint = load_from_hub( repo_id="DHDRL/monsoon-rl", filename="{MODEL FILENAME}.zip", ) - Notebooks
- Google Colab
- Kaggle
Update era5_data_pipeline.py
Browse files- 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.
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
import xarray as xr
|
| 343 |
import numpy as np
|
| 344 |
|
| 345 |
ds = xr.open_dataset(str(nc_path))
|
| 346 |
try:
|
| 347 |
-
def
|
| 348 |
if var not in ds:
|
| 349 |
-
return
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
)].mean()) if len(ds[var].values.flatten()) > 0 else 0.0
|
| 353 |
|
| 354 |
-
def
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
|
|
|
| 359 |
return float(valid.max()) if len(valid) > 0 else 0.0
|
| 360 |
|
| 361 |
-
def
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
| 366 |
return float(valid.sum() * scale) if len(valid) > 0 else 0.0
|
| 367 |
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
temp_min_c = temp_mean_c
|
| 376 |
|
| 377 |
-
dewpoint_mean_c =
|
| 378 |
-
dewpoint_max_c =
|
| 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,
|
| 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 |
-
|
| 409 |
-
|
|
|
|
| 410 |
wind_mean = math.sqrt(u**2 + v**2)
|
| 411 |
-
wind_max = math.sqrt(
|
| 412 |
-
soil_pct =
|
| 413 |
-
et0_mm = abs(
|
| 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 |
-
#
|
| 497 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 632 |
|
| 633 |
try:
|
| 634 |
return _build_era5_obs(zone_id, anchor, nc_path)
|
| 635 |
except Exception as e:
|
| 636 |
-
|
| 637 |
-
"
|
| 638 |
-
|
| 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(),
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
if primary in (DataSource.SATELLITE_PRECIP, DataSource.SATELLITE_SOIL):
|
| 1107 |
-
|
| 1108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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__":
|