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 build_continuous_historical.py
Browse files- build_continuous_historical.py +312 -79
build_continuous_historical.py
CHANGED
|
@@ -1,9 +1,20 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
==============================
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import argparse
|
|
@@ -11,18 +22,23 @@ import json
|
|
| 11 |
import logging
|
| 12 |
import pickle
|
| 13 |
import time
|
|
|
|
| 14 |
from datetime import datetime, timedelta, timezone
|
| 15 |
from pathlib import Path
|
| 16 |
-
from typing import Any, Dict, List, Optional
|
| 17 |
|
| 18 |
import zone_observation as _zo
|
| 19 |
-
assert _zo.SCHEMA_VERSION == 3
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from indonesia_zones import (
|
| 23 |
-
register_indonesia_zones,
|
| 24 |
INDONESIA_ZONES,
|
| 25 |
crop_stage_for_date,
|
|
|
|
| 26 |
)
|
| 27 |
from era5_data_pipeline import fetch_episode_context
|
| 28 |
|
|
@@ -32,50 +48,86 @@ logging.basicConfig(
|
|
| 32 |
)
|
| 33 |
logger = logging.getLogger("continuous_cache")
|
| 34 |
|
| 35 |
-
|
|
|
|
| 36 |
PRIORITY_ZONES = [
|
| 37 |
-
"karawang_rice",
|
| 38 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
]
|
| 40 |
|
| 41 |
-
#
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
("elnino_2015_16_vstrong", "2015-05-01", "2016-04-30", "el_nino_very_strong"),
|
|
|
|
|
|
|
| 44 |
("elnino_2018_19", "2018-06-01", "2019-05-31", "el_nino_moderate"),
|
| 45 |
-
("elnino_2023_24_strong", "2023-05-01", "2024-04-30", "el_nino_strong"),
|
| 46 |
("lanina_2020_21", "2020-09-01", "2021-05-31", "la_nina_moderate"),
|
| 47 |
("lanina_2021_22", "2021-09-01", "2022-05-31", "la_nina_moderate"),
|
| 48 |
("lanina_2022_23", "2022-09-01", "2023-04-30", "la_nina_weak_moderate"),
|
| 49 |
-
("
|
| 50 |
]
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
def _parse(s: str) -> datetime:
|
| 54 |
return datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
| 55 |
|
| 56 |
|
| 57 |
-
def _daterange(start: datetime, end: datetime, step_days: int =
|
|
|
|
|
|
|
| 58 |
cur = start
|
| 59 |
while cur <= end:
|
| 60 |
yield cur
|
| 61 |
cur += timedelta(days=step_days)
|
| 62 |
|
| 63 |
|
| 64 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
try:
|
| 66 |
stage, days_to_harvest, season_name = crop_stage_for_date(zone_id, valid_time)
|
| 67 |
obs_dict["crop_stage"] = stage.value if isinstance(stage, CropStage) else str(stage)
|
| 68 |
obs_dict["days_to_harvest"] = days_to_harvest
|
| 69 |
-
if
|
| 70 |
obs_dict["extras"] = {}
|
| 71 |
if season_name:
|
| 72 |
obs_dict["extras"]["season_name"] = season_name
|
| 73 |
-
except Exception:
|
| 74 |
-
|
| 75 |
return obs_dict
|
| 76 |
|
| 77 |
|
| 78 |
-
def _safe_to_dict(obj) -> Optional[Dict]:
|
| 79 |
if obj is None:
|
| 80 |
return None
|
| 81 |
if hasattr(obj, "to_dict"):
|
|
@@ -86,22 +138,162 @@ def _safe_to_dict(obj) -> Optional[Dict]:
|
|
| 86 |
return None
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def build_continuous_cache(
|
| 90 |
-
output_path: str = "
|
| 91 |
-
step_days: int =
|
| 92 |
sleep_s: float = 0.7,
|
| 93 |
-
max_days_per_zone_season: int =
|
| 94 |
resume: bool = True,
|
|
|
|
|
|
|
| 95 |
) -> None:
|
| 96 |
register_indonesia_zones()
|
| 97 |
available = {z.zone_id for z in INDONESIA_ZONES}
|
| 98 |
zones = [z for z in PRIORITY_ZONES if z in available]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
logger.info("Priority zones (%d): %s", len(zones), zones)
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
cfg = ForecastConfig(
|
| 102 |
forecast_backend="baseline",
|
| 103 |
use_climatology_anomalies=True,
|
| 104 |
include_basin_context=True,
|
|
|
|
| 105 |
force_data_source=DataSource.OPENMETEO_LIVE,
|
| 106 |
real_data_ratio=1.0,
|
| 107 |
climatology_years=10,
|
|
@@ -111,19 +303,27 @@ def build_continuous_cache(
|
|
| 111 |
failures = 0
|
| 112 |
t0 = time.time()
|
| 113 |
out = Path(output_path)
|
| 114 |
-
dmi_warned = False
|
| 115 |
|
| 116 |
-
# Resume
|
| 117 |
if resume and out.exists():
|
| 118 |
try:
|
| 119 |
with open(out, "rb") as f:
|
| 120 |
existing = pickle.load(f)
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
except Exception as e:
|
| 124 |
logger.warning("Resume failed (%s) — starting fresh", e)
|
|
|
|
| 125 |
|
| 126 |
-
already_done = {
|
|
|
|
|
|
|
| 127 |
|
| 128 |
for label, start_s, end_s, regime in PARADIGMATIC_SEASONS:
|
| 129 |
start = _parse(start_s)
|
|
@@ -141,11 +341,16 @@ def build_continuous_cache(
|
|
| 141 |
|
| 142 |
for day in _daterange(start, end, step_days=step_days):
|
| 143 |
if days_fetched >= max_days_per_zone_season:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
break
|
| 145 |
|
| 146 |
window_end = day + timedelta(days=30)
|
| 147 |
try:
|
| 148 |
ctx = fetch_episode_context(zone_id, (day, window_end), cfg)
|
|
|
|
| 149 |
|
| 150 |
obs_dict = _safe_to_dict(ctx.obs) or {}
|
| 151 |
obs_dict = _enrich_with_crop_stage(obs_dict, zone_id, day)
|
|
@@ -155,8 +360,13 @@ def build_continuous_cache(
|
|
| 155 |
"zone_id": zone_id,
|
| 156 |
"obs": obs_dict,
|
| 157 |
"forecast": _safe_to_dict(ctx.forecast),
|
| 158 |
-
"basin_context": _safe_to_dict(
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
traj_points.append(point)
|
| 162 |
days_fetched += 1
|
|
@@ -164,15 +374,7 @@ def build_continuous_cache(
|
|
| 164 |
|
| 165 |
except Exception as e:
|
| 166 |
msg = str(e)
|
| 167 |
-
|
| 168 |
-
if not dmi_warned:
|
| 169 |
-
logger.warning(
|
| 170 |
-
"DMI/IOD source unavailable (404) — using synthetic IOD. "
|
| 171 |
-
"All other real data (Open-Meteo weather, climatology anomalies, ENSO, crop stage) remains intact."
|
| 172 |
-
)
|
| 173 |
-
dmi_warned = True
|
| 174 |
-
else:
|
| 175 |
-
logger.warning(" Fail %s @ %s: %s", zone_id, day.date(), msg[:120])
|
| 176 |
failures += 1
|
| 177 |
time.sleep(sleep_s * 1.3)
|
| 178 |
continue
|
|
@@ -187,66 +389,96 @@ def build_continuous_cache(
|
|
| 187 |
"end": end_s,
|
| 188 |
"n_points": len(traj_points),
|
| 189 |
"step_days": step_days,
|
|
|
|
|
|
|
|
|
|
| 190 |
},
|
| 191 |
"trajectory": traj_points,
|
| 192 |
})
|
| 193 |
-
logger.info(" %s: %d
|
| 194 |
-
|
| 195 |
if len(trajectories) % 3 == 0:
|
| 196 |
-
_save(
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
-
_save(trajectories, out, failures, zones, cfg)
|
| 199 |
|
| 200 |
elapsed = (time.time() - t0) / 60
|
| 201 |
-
|
| 202 |
logger.info("=" * 70)
|
| 203 |
-
logger.info("
|
| 204 |
-
logger.info(" Trajectories
|
| 205 |
-
logger.info(" Total points
|
| 206 |
-
logger.info("
|
| 207 |
-
logger.info("
|
| 208 |
-
logger.info("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
logger.info("=" * 70)
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
payload = {
|
| 214 |
-
"version":
|
| 215 |
"created_utc": datetime.now(timezone.utc).isoformat(),
|
| 216 |
-
"design": "
|
| 217 |
-
"n_trajectories":
|
| 218 |
-
"total_points":
|
| 219 |
"priority_zones": zones,
|
| 220 |
-
"
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
"include_basin_context": cfg.include_basin_context,
|
| 224 |
-
},
|
| 225 |
"trajectories": trajectories,
|
| 226 |
}
|
| 227 |
-
|
|
|
|
| 228 |
pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
|
|
|
|
| 229 |
|
| 230 |
-
summary = {
|
| 231 |
-
"version": payload["version"],
|
| 232 |
-
"n_trajectories": payload["n_trajectories"],
|
| 233 |
-
"total_points": payload["total_points"],
|
| 234 |
-
"failures": failures,
|
| 235 |
-
"zones": zones,
|
| 236 |
-
"seasons": [s[0] for s in PARADIGMATIC_SEASONS],
|
| 237 |
-
"output": str(out),
|
| 238 |
-
}
|
| 239 |
with open(out.with_suffix(".summary.json"), "w") as f:
|
| 240 |
-
json.dump(
|
| 241 |
|
| 242 |
|
| 243 |
-
def main():
|
| 244 |
-
p = argparse.ArgumentParser(
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
p.add_argument("--sleep", type=float, default=0.7)
|
| 248 |
-
p.add_argument("--max-days", type=int, default=
|
| 249 |
p.add_argument("--no-resume", action="store_true")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
args = p.parse_args()
|
| 251 |
|
| 252 |
build_continuous_cache(
|
|
@@ -255,6 +487,7 @@ def main():
|
|
| 255 |
sleep_s=args.sleep,
|
| 256 |
max_days_per_zone_season=args.max_days,
|
| 257 |
resume=not args.no_resume,
|
|
|
|
| 258 |
)
|
| 259 |
|
| 260 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
build_continuous_historical.py
|
| 3 |
+
==============================
|
| 4 |
+
Causal historical cache for Indonesian rice zones.
|
| 5 |
+
|
| 6 |
+
Pairs with:
|
| 7 |
+
- era5_data_pipeline.fetch_episode_context (valid_time-anchored, backward
|
| 8 |
+
30-day obs windows, archive URL for dates older than today-5)
|
| 9 |
+
- zone_observation.DataSource.OPENMETEO_ARCHIVE
|
| 10 |
+
- real_episode_sampler.DEFAULT_HOLDOUT_RANGES
|
| 11 |
|
| 12 |
+
This builder does NOT issue NWP. forecast_backend="baseline" is persistence
|
| 13 |
+
of causal precip_30d. Do not report forecast skill against this file.
|
| 14 |
+
|
| 15 |
+
Holdout seasons are stored in the pickle so evaluate_checkpoint_real.py can
|
| 16 |
+
see them. RealEpisodeIndex excludes DEFAULT_HOLDOUT_RANGES at train time.
|
| 17 |
+
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
import argparse
|
|
|
|
| 22 |
import logging
|
| 23 |
import pickle
|
| 24 |
import time
|
| 25 |
+
from collections import Counter
|
| 26 |
from datetime import datetime, timedelta, timezone
|
| 27 |
from pathlib import Path
|
| 28 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 29 |
|
| 30 |
import zone_observation as _zo
|
|
|
|
| 31 |
|
| 32 |
+
assert _zo.SCHEMA_VERSION == 3, (
|
| 33 |
+
f"build_continuous_historical: schema mismatch "
|
| 34 |
+
f"(expected 3, got {_zo.SCHEMA_VERSION})"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
from zone_observation import CropStage, DataSource, ForecastConfig
|
| 38 |
from indonesia_zones import (
|
|
|
|
| 39 |
INDONESIA_ZONES,
|
| 40 |
crop_stage_for_date,
|
| 41 |
+
register_indonesia_zones,
|
| 42 |
)
|
| 43 |
from era5_data_pipeline import fetch_episode_context
|
| 44 |
|
|
|
|
| 48 |
)
|
| 49 |
logger = logging.getLogger("continuous_cache")
|
| 50 |
|
| 51 |
+
CACHE_VERSION = "indonesia_continuous_v3_causal_obs"
|
| 52 |
+
|
| 53 |
PRIORITY_ZONES = [
|
| 54 |
+
"karawang_rice",
|
| 55 |
+
"indramayu_rice",
|
| 56 |
+
"central_java_rice",
|
| 57 |
+
"east_java_rice",
|
| 58 |
+
"lampung_rice",
|
| 59 |
+
"south_sumatra_rice",
|
| 60 |
+
"banten_rice",
|
| 61 |
+
"south_sulawesi_rice",
|
| 62 |
]
|
| 63 |
|
| 64 |
+
# Seasons stored in the pickle. Train vs eval is decided by
|
| 65 |
+
# real_episode_sampler.DEFAULT_HOLDOUT_RANGES, not by omitting rows here.
|
| 66 |
+
#
|
| 67 |
+
# DEFAULT_HOLDOUT_RANGES currently:
|
| 68 |
+
# 2023-05-01 -> 2024-04-30 el_nino_2023_24_strong
|
| 69 |
+
# 2017-05-01 -> 2018-04-30 neutral_2017_18
|
| 70 |
+
#
|
| 71 |
+
# neutral_2016_17 is the Neutral year that remains eligible for training.
|
| 72 |
+
PARADIGMATIC_SEASONS: List[Tuple[str, str, str, str]] = [
|
| 73 |
("elnino_2015_16_vstrong", "2015-05-01", "2016-04-30", "el_nino_very_strong"),
|
| 74 |
+
("neutral_2016_17", "2016-05-01", "2017-04-30", "neutral"),
|
| 75 |
+
("neutral_2017_18", "2017-05-01", "2018-04-30", "neutral"),
|
| 76 |
("elnino_2018_19", "2018-06-01", "2019-05-31", "el_nino_moderate"),
|
|
|
|
| 77 |
("lanina_2020_21", "2020-09-01", "2021-05-31", "la_nina_moderate"),
|
| 78 |
("lanina_2021_22", "2021-09-01", "2022-05-31", "la_nina_moderate"),
|
| 79 |
("lanina_2022_23", "2022-09-01", "2023-04-30", "la_nina_weak_moderate"),
|
| 80 |
+
("elnino_2023_24_strong", "2023-05-01", "2024-04-30", "el_nino_strong"),
|
| 81 |
]
|
| 82 |
|
| 83 |
+
# Mirrors real_episode_sampler.DEFAULT_HOLDOUT_RANGES for the QA sidecar.
|
| 84 |
+
# If you change the sampler holdout, change this list too (or pass --holdout).
|
| 85 |
+
DEFAULT_HOLDOUT_RANGES: Tuple[Tuple[str, str], ...] = (
|
| 86 |
+
("2023-05-01", "2024-04-30"),
|
| 87 |
+
("2017-05-01", "2018-04-30"),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
_ARCHIVE_LAG_DAYS = 5
|
| 91 |
+
|
| 92 |
|
| 93 |
def _parse(s: str) -> datetime:
|
| 94 |
return datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
| 95 |
|
| 96 |
|
| 97 |
+
def _daterange(start: datetime, end: datetime, step_days: int = 3):
|
| 98 |
+
if step_days < 1:
|
| 99 |
+
raise ValueError(f"step_days must be >= 1, got {step_days}")
|
| 100 |
cur = start
|
| 101 |
while cur <= end:
|
| 102 |
yield cur
|
| 103 |
cur += timedelta(days=step_days)
|
| 104 |
|
| 105 |
|
| 106 |
+
def _in_holdout(day: datetime, holdout: Tuple[Tuple[str, str], ...]) -> bool:
|
| 107 |
+
d = day.date().isoformat()
|
| 108 |
+
for start_s, end_s in holdout:
|
| 109 |
+
if start_s <= d <= end_s:
|
| 110 |
+
return True
|
| 111 |
+
return False
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _enrich_with_crop_stage(
|
| 115 |
+
obs_dict: Dict[str, Any], zone_id: str, valid_time: datetime
|
| 116 |
+
) -> Dict[str, Any]:
|
| 117 |
try:
|
| 118 |
stage, days_to_harvest, season_name = crop_stage_for_date(zone_id, valid_time)
|
| 119 |
obs_dict["crop_stage"] = stage.value if isinstance(stage, CropStage) else str(stage)
|
| 120 |
obs_dict["days_to_harvest"] = days_to_harvest
|
| 121 |
+
if obs_dict.get("extras") is None:
|
| 122 |
obs_dict["extras"] = {}
|
| 123 |
if season_name:
|
| 124 |
obs_dict["extras"]["season_name"] = season_name
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.debug("crop_stage_for_date failed for %s @ %s: %s", zone_id, valid_time.date(), e)
|
| 127 |
return obs_dict
|
| 128 |
|
| 129 |
|
| 130 |
+
def _safe_to_dict(obj) -> Optional[Dict[str, Any]]:
|
| 131 |
if obj is None:
|
| 132 |
return None
|
| 133 |
if hasattr(obj, "to_dict"):
|
|
|
|
| 138 |
return None
|
| 139 |
|
| 140 |
|
| 141 |
+
def _source_value(obs) -> str:
|
| 142 |
+
src = getattr(obs, "source", None)
|
| 143 |
+
if src is None:
|
| 144 |
+
return "unknown"
|
| 145 |
+
return src.value if hasattr(src, "value") else str(src)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _assert_causal_obs(ctx: Any, day: datetime) -> None:
|
| 149 |
+
"""Fail the point rather than write a leaked or synthetic row."""
|
| 150 |
+
obs = ctx.obs
|
| 151 |
+
if obs is None:
|
| 152 |
+
raise RuntimeError("fetch_episode_context returned ctx.obs=None")
|
| 153 |
+
|
| 154 |
+
obs_day = obs.valid_time
|
| 155 |
+
if getattr(obs_day, "tzinfo", None) is None:
|
| 156 |
+
obs_day = obs_day.replace(tzinfo=timezone.utc)
|
| 157 |
+
if obs_day.date() != day.date():
|
| 158 |
+
raise RuntimeError(
|
| 159 |
+
f"obs.valid_time {obs_day.date()} != anchor {day.date()}"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
src = getattr(obs, "source", None)
|
| 163 |
+
if src == DataSource.SYNTHETIC:
|
| 164 |
+
raise RuntimeError(
|
| 165 |
+
f"synthetic obs refused at {day.date()} zone={obs.zone_id}"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
forecast = getattr(ctx, "forecast", None)
|
| 169 |
+
if forecast is not None:
|
| 170 |
+
ft = getattr(forecast, "forecast_time", None)
|
| 171 |
+
if ft is not None:
|
| 172 |
+
if getattr(ft, "tzinfo", None) is None:
|
| 173 |
+
ft = ft.replace(tzinfo=timezone.utc)
|
| 174 |
+
if ft.date() > day.date():
|
| 175 |
+
raise RuntimeError(
|
| 176 |
+
f"forecast_time {ft.date()} is after anchor {day.date()}"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
today = datetime.now(timezone.utc).date()
|
| 180 |
+
if day.date() < today - timedelta(days=_ARCHIVE_LAG_DAYS):
|
| 181 |
+
archive = getattr(DataSource, "OPENMETEO_ARCHIVE", None)
|
| 182 |
+
if archive is not None and src not in (
|
| 183 |
+
archive,
|
| 184 |
+
DataSource.ERA5_REANALYSIS,
|
| 185 |
+
DataSource.SATELLITE_PRECIP,
|
| 186 |
+
DataSource.SATELLITE_SOIL,
|
| 187 |
+
):
|
| 188 |
+
raise RuntimeError(
|
| 189 |
+
f"historical day {day.date()} stamped {src} "
|
| 190 |
+
f"(expected OPENMETEO_ARCHIVE / ERA5 / satellite). "
|
| 191 |
+
f"zone_observation.DataSource.OPENMETEO_ARCHIVE is missing "
|
| 192 |
+
f"or the pipeline on this machine is stale."
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _qa_payload(
|
| 197 |
+
trajectories: List[Dict[str, Any]],
|
| 198 |
+
failures: int,
|
| 199 |
+
zones: List[str],
|
| 200 |
+
cfg: ForecastConfig,
|
| 201 |
+
step_days: int,
|
| 202 |
+
holdout: Tuple[Tuple[str, str], ...],
|
| 203 |
+
) -> Dict[str, Any]:
|
| 204 |
+
source_counts: Counter = Counter()
|
| 205 |
+
model_counts: Counter = Counter()
|
| 206 |
+
n_train = 0
|
| 207 |
+
n_holdout = 0
|
| 208 |
+
n_missing_basin = 0
|
| 209 |
+
n_synthetic = 0
|
| 210 |
+
n_points = 0
|
| 211 |
+
|
| 212 |
+
for traj in trajectories:
|
| 213 |
+
for pt in traj.get("trajectory", []):
|
| 214 |
+
n_points += 1
|
| 215 |
+
obs = pt.get("obs") or {}
|
| 216 |
+
src = str(obs.get("source") or pt.get("data_source") or "unknown")
|
| 217 |
+
source_counts[src] += 1
|
| 218 |
+
if src.endswith("synthetic") or src == "DataSource.SYNTHETIC":
|
| 219 |
+
n_synthetic += 1
|
| 220 |
+
fcast = pt.get("forecast") or {}
|
| 221 |
+
model_counts[str(fcast.get("model_id") or "missing")] += 1
|
| 222 |
+
if pt.get("basin_context") is None:
|
| 223 |
+
n_missing_basin += 1
|
| 224 |
+
vt = pt.get("valid_time") or ""
|
| 225 |
+
day = _parse(vt[:10]) if vt else None
|
| 226 |
+
if day is None:
|
| 227 |
+
continue
|
| 228 |
+
if _in_holdout(day, holdout):
|
| 229 |
+
n_holdout += 1
|
| 230 |
+
else:
|
| 231 |
+
n_train += 1
|
| 232 |
+
|
| 233 |
+
return {
|
| 234 |
+
"version": CACHE_VERSION,
|
| 235 |
+
"n_trajectories": len(trajectories),
|
| 236 |
+
"total_points": n_points,
|
| 237 |
+
"failures_dropped": failures,
|
| 238 |
+
"zones": zones,
|
| 239 |
+
"seasons": [s[0] for s in PARADIGMATIC_SEASONS],
|
| 240 |
+
"step_days": step_days,
|
| 241 |
+
"holdout_ranges": [list(h) for h in holdout],
|
| 242 |
+
"n_train_eligible": n_train,
|
| 243 |
+
"n_holdout_stored": n_holdout,
|
| 244 |
+
"n_missing_basin": n_missing_basin,
|
| 245 |
+
"n_synthetic_obs": n_synthetic,
|
| 246 |
+
"source_histogram": dict(source_counts),
|
| 247 |
+
"forecast_model_histogram": dict(model_counts),
|
| 248 |
+
"config_snapshot": {
|
| 249 |
+
"forecast_backend": cfg.forecast_backend,
|
| 250 |
+
"use_climatology_anomalies": cfg.use_climatology_anomalies,
|
| 251 |
+
"include_basin_context": cfg.include_basin_context,
|
| 252 |
+
"require_real_basin_context": cfg.require_real_basin_context,
|
| 253 |
+
"force_data_source": (
|
| 254 |
+
cfg.force_data_source.value if cfg.force_data_source else None
|
| 255 |
+
),
|
| 256 |
+
"climatology_years": cfg.climatology_years,
|
| 257 |
+
},
|
| 258 |
+
"notes": [
|
| 259 |
+
"forecast_backend=baseline is persistence of causal precip_30d, not NWP",
|
| 260 |
+
"holdout rows are stored for eval; RealEpisodeIndex excludes them at train",
|
| 261 |
+
"n_synthetic_obs must be 0 before this pickle is used for training",
|
| 262 |
+
],
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
def build_continuous_cache(
|
| 267 |
+
output_path: str = "historical_continuous_indonesia_v3_causal_obs.pkl",
|
| 268 |
+
step_days: int = 3,
|
| 269 |
sleep_s: float = 0.7,
|
| 270 |
+
max_days_per_zone_season: int = 400,
|
| 271 |
resume: bool = True,
|
| 272 |
+
require_real_basin_context: bool = False,
|
| 273 |
+
holdout_ranges: Tuple[Tuple[str, str], ...] = DEFAULT_HOLDOUT_RANGES,
|
| 274 |
) -> None:
|
| 275 |
register_indonesia_zones()
|
| 276 |
available = {z.zone_id for z in INDONESIA_ZONES}
|
| 277 |
zones = [z for z in PRIORITY_ZONES if z in available]
|
| 278 |
+
missing = [z for z in PRIORITY_ZONES if z not in available]
|
| 279 |
+
if missing:
|
| 280 |
+
logger.warning("Priority zones not in registry (skipped): %s", missing)
|
| 281 |
+
if not zones:
|
| 282 |
+
raise RuntimeError("No priority zones registered — aborting")
|
| 283 |
logger.info("Priority zones (%d): %s", len(zones), zones)
|
| 284 |
|
| 285 |
+
if not hasattr(DataSource, "OPENMETEO_ARCHIVE"):
|
| 286 |
+
logger.warning(
|
| 287 |
+
"DataSource.OPENMETEO_ARCHIVE is missing. Historical points "
|
| 288 |
+
"will not pass the archive-stamp assertion. Upload the updated "
|
| 289 |
+
"zone_observation.py before rebuilding."
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
cfg = ForecastConfig(
|
| 293 |
forecast_backend="baseline",
|
| 294 |
use_climatology_anomalies=True,
|
| 295 |
include_basin_context=True,
|
| 296 |
+
require_real_basin_context=require_real_basin_context,
|
| 297 |
force_data_source=DataSource.OPENMETEO_LIVE,
|
| 298 |
real_data_ratio=1.0,
|
| 299 |
climatology_years=10,
|
|
|
|
| 303 |
failures = 0
|
| 304 |
t0 = time.time()
|
| 305 |
out = Path(output_path)
|
|
|
|
| 306 |
|
|
|
|
| 307 |
if resume and out.exists():
|
| 308 |
try:
|
| 309 |
with open(out, "rb") as f:
|
| 310 |
existing = pickle.load(f)
|
| 311 |
+
if existing.get("version") != CACHE_VERSION:
|
| 312 |
+
logger.warning(
|
| 313 |
+
"Resume file version=%s != %s — starting fresh so a "
|
| 314 |
+
"leaky v1/v2 cache cannot be extended.",
|
| 315 |
+
existing.get("version"), CACHE_VERSION,
|
| 316 |
+
)
|
| 317 |
+
else:
|
| 318 |
+
trajectories = existing.get("trajectories", [])
|
| 319 |
+
logger.info("Resuming from %d existing trajectories", len(trajectories))
|
| 320 |
except Exception as e:
|
| 321 |
logger.warning("Resume failed (%s) — starting fresh", e)
|
| 322 |
+
trajectories = []
|
| 323 |
|
| 324 |
+
already_done = {
|
| 325 |
+
(t["meta"]["label"], t["meta"]["zone_id"]) for t in trajectories
|
| 326 |
+
}
|
| 327 |
|
| 328 |
for label, start_s, end_s, regime in PARADIGMATIC_SEASONS:
|
| 329 |
start = _parse(start_s)
|
|
|
|
| 341 |
|
| 342 |
for day in _daterange(start, end, step_days=step_days):
|
| 343 |
if days_fetched >= max_days_per_zone_season:
|
| 344 |
+
logger.warning(
|
| 345 |
+
" %s hit max_days=%d before %s — raise --max-days",
|
| 346 |
+
zone_id, max_days_per_zone_season, end_s,
|
| 347 |
+
)
|
| 348 |
break
|
| 349 |
|
| 350 |
window_end = day + timedelta(days=30)
|
| 351 |
try:
|
| 352 |
ctx = fetch_episode_context(zone_id, (day, window_end), cfg)
|
| 353 |
+
_assert_causal_obs(ctx, day)
|
| 354 |
|
| 355 |
obs_dict = _safe_to_dict(ctx.obs) or {}
|
| 356 |
obs_dict = _enrich_with_crop_stage(obs_dict, zone_id, day)
|
|
|
|
| 360 |
"zone_id": zone_id,
|
| 361 |
"obs": obs_dict,
|
| 362 |
"forecast": _safe_to_dict(ctx.forecast),
|
| 363 |
+
"basin_context": _safe_to_dict(
|
| 364 |
+
getattr(ctx, "basin_context", None)
|
| 365 |
+
),
|
| 366 |
+
"data_source": _source_value(ctx.obs),
|
| 367 |
+
"split": (
|
| 368 |
+
"holdout" if _in_holdout(day, holdout_ranges) else "train"
|
| 369 |
+
),
|
| 370 |
}
|
| 371 |
traj_points.append(point)
|
| 372 |
days_fetched += 1
|
|
|
|
| 374 |
|
| 375 |
except Exception as e:
|
| 376 |
msg = str(e)
|
| 377 |
+
logger.warning(" Drop %s @ %s: %s", zone_id, day.date(), msg[:200])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
failures += 1
|
| 379 |
time.sleep(sleep_s * 1.3)
|
| 380 |
continue
|
|
|
|
| 389 |
"end": end_s,
|
| 390 |
"n_points": len(traj_points),
|
| 391 |
"step_days": step_days,
|
| 392 |
+
"n_holdout": sum(
|
| 393 |
+
1 for p in traj_points if p.get("split") == "holdout"
|
| 394 |
+
),
|
| 395 |
},
|
| 396 |
"trajectory": traj_points,
|
| 397 |
})
|
| 398 |
+
logger.info(" %s: %d causal points saved", zone_id, len(traj_points))
|
|
|
|
| 399 |
if len(trajectories) % 3 == 0:
|
| 400 |
+
_save(
|
| 401 |
+
trajectories, out, failures, zones, cfg,
|
| 402 |
+
step_days, holdout_ranges,
|
| 403 |
+
)
|
| 404 |
|
| 405 |
+
_save(trajectories, out, failures, zones, cfg, step_days, holdout_ranges)
|
| 406 |
|
| 407 |
elapsed = (time.time() - t0) / 60
|
| 408 |
+
qa = _qa_payload(trajectories, failures, zones, cfg, step_days, holdout_ranges)
|
| 409 |
logger.info("=" * 70)
|
| 410 |
+
logger.info("CAUSAL HISTORICAL CACHE COMPLETE")
|
| 411 |
+
logger.info(" Trajectories : %d", qa["n_trajectories"])
|
| 412 |
+
logger.info(" Total points : %d", qa["total_points"])
|
| 413 |
+
logger.info(" Train-eligible : %d", qa["n_train_eligible"])
|
| 414 |
+
logger.info(" Holdout stored : %d", qa["n_holdout_stored"])
|
| 415 |
+
logger.info(" Synthetic obs : %d", qa["n_synthetic_obs"])
|
| 416 |
+
logger.info(" Missing basin : %d", qa["n_missing_basin"])
|
| 417 |
+
logger.info(" Dropped : %d", failures)
|
| 418 |
+
logger.info(" Sources : %s", qa["source_histogram"])
|
| 419 |
+
logger.info(" Forecast models : %s", qa["forecast_model_histogram"])
|
| 420 |
+
logger.info(" Elapsed : %.1f min", elapsed)
|
| 421 |
+
logger.info(" Output : %s", out)
|
| 422 |
logger.info("=" * 70)
|
| 423 |
+
if qa["n_synthetic_obs"] != 0:
|
| 424 |
+
raise RuntimeError(
|
| 425 |
+
f"cache contains {qa['n_synthetic_obs']} synthetic obs rows — "
|
| 426 |
+
"do not train on this file"
|
| 427 |
+
)
|
| 428 |
+
if qa["n_train_eligible"] == 0:
|
| 429 |
+
raise RuntimeError("cache has 0 train-eligible points after holdout")
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _save(
|
| 433 |
+
trajectories,
|
| 434 |
+
out: Path,
|
| 435 |
+
failures: int,
|
| 436 |
+
zones,
|
| 437 |
+
cfg: ForecastConfig,
|
| 438 |
+
step_days: int,
|
| 439 |
+
holdout: Tuple[Tuple[str, str], ...],
|
| 440 |
+
) -> None:
|
| 441 |
+
qa = _qa_payload(trajectories, failures, zones, cfg, step_days, holdout)
|
| 442 |
payload = {
|
| 443 |
+
"version": CACHE_VERSION,
|
| 444 |
"created_utc": datetime.now(timezone.utc).isoformat(),
|
| 445 |
+
"design": "continuous_paradigmatic_seasons_causal_obs",
|
| 446 |
+
"n_trajectories": qa["n_trajectories"],
|
| 447 |
+
"total_points": qa["total_points"],
|
| 448 |
"priority_zones": zones,
|
| 449 |
+
"holdout_ranges": [list(h) for h in holdout],
|
| 450 |
+
"config_snapshot": qa["config_snapshot"],
|
| 451 |
+
"qa": qa,
|
|
|
|
|
|
|
| 452 |
"trajectories": trajectories,
|
| 453 |
}
|
| 454 |
+
tmp = out.with_suffix(out.suffix + ".tmp")
|
| 455 |
+
with open(tmp, "wb") as f:
|
| 456 |
pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 457 |
+
tmp.replace(out)
|
| 458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
with open(out.with_suffix(".summary.json"), "w") as f:
|
| 460 |
+
json.dump(qa, f, indent=2)
|
| 461 |
|
| 462 |
|
| 463 |
+
def main() -> None:
|
| 464 |
+
p = argparse.ArgumentParser(
|
| 465 |
+
description="Build a causal-obs Indonesia historical cache (v3)."
|
| 466 |
+
)
|
| 467 |
+
p.add_argument(
|
| 468 |
+
"--output",
|
| 469 |
+
default="historical_continuous_indonesia_v3_causal_obs.pkl",
|
| 470 |
+
)
|
| 471 |
+
p.add_argument("--step-days", type=int, default=3)
|
| 472 |
p.add_argument("--sleep", type=float, default=0.7)
|
| 473 |
+
p.add_argument("--max-days", type=int, default=400)
|
| 474 |
p.add_argument("--no-resume", action="store_true")
|
| 475 |
+
p.add_argument(
|
| 476 |
+
"--require-real-basin",
|
| 477 |
+
action="store_true",
|
| 478 |
+
help="Drop the point if RONI/DMI/SWPC cannot be fetched. "
|
| 479 |
+
"Off by default so a DMI 404 does not empty the cache; "
|
| 480 |
+
"ENSO/obs still have to be real.",
|
| 481 |
+
)
|
| 482 |
args = p.parse_args()
|
| 483 |
|
| 484 |
build_continuous_cache(
|
|
|
|
| 487 |
sleep_s=args.sleep,
|
| 488 |
max_days_per_zone_season=args.max_days,
|
| 489 |
resume=not args.no_resume,
|
| 490 |
+
require_real_basin_context=args.require_real_basin,
|
| 491 |
)
|
| 492 |
|
| 493 |
|