DHDRL commited on
Commit
aacbbba
·
verified ·
1 Parent(s): c895e68

Update build_continuous_historical.py

Browse files
Files changed (1) hide show
  1. build_continuous_historical.py +312 -79
build_continuous_historical.py CHANGED
@@ -1,9 +1,20 @@
1
  """
2
- build_continuous_historical_cache.py
3
- ====================================
4
- High-fidelity continuous historical cache for Indonesian rice zones.
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
- from zone_observation import ForecastConfig, DataSource, CropStage
 
 
 
 
 
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
- # Priority rice zones
 
36
  PRIORITY_ZONES = [
37
- "karawang_rice", "indramayu_rice", "central_java_rice", "east_java_rice",
38
- "lampung_rice", "south_sumatra_rice", "banten_rice", "south_sulawesi_rice",
 
 
 
 
 
 
39
  ]
40
 
41
- # Continuous paradigmatic seasons
42
- PARADIGMATIC_SEASONS = [
 
 
 
 
 
 
 
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
- ("neutral_2017_18", "2017-05-01", "2018-04-30", "neutral"),
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 = 5):
 
 
58
  cur = start
59
  while cur <= end:
60
  yield cur
61
  cur += timedelta(days=step_days)
62
 
63
 
64
- def _enrich_with_crop_stage(obs_dict: Dict[str, Any], zone_id: str, valid_time: datetime) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
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 "extras" not in obs_dict or obs_dict["extras"] is None:
70
  obs_dict["extras"] = {}
71
  if season_name:
72
  obs_dict["extras"]["season_name"] = season_name
73
- except Exception:
74
- pass
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 = "historical_continuous_indonesia_v1.pkl",
91
- step_days: int = 5,
92
  sleep_s: float = 0.7,
93
- max_days_per_zone_season: int = 75,
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
- trajectories = existing.get("trajectories", [])
122
- logger.info("Resuming from %d existing trajectories", len(trajectories))
 
 
 
 
 
 
 
123
  except Exception as e:
124
  logger.warning("Resume failed (%s) — starting fresh", e)
 
125
 
126
- already_done = {(t["meta"]["label"], t["meta"]["zone_id"]) for t in trajectories}
 
 
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(getattr(ctx, "basin_context", None)),
159
- "data_source": str(ctx.data_source),
 
 
 
 
 
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
- if "dmi.data" in msg.lower() or "DMI" in msg or "404" in msg:
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 ordered points saved", zone_id, len(traj_points))
194
-
195
  if len(trajectories) % 3 == 0:
196
- _save(trajectories, out, failures, zones, cfg)
 
 
 
197
 
198
- _save(trajectories, out, failures, zones, cfg)
199
 
200
  elapsed = (time.time() - t0) / 60
201
- total_points = sum(t["meta"]["n_points"] for t in trajectories)
202
  logger.info("=" * 70)
203
- logger.info("CONTINUOUS HISTORICAL CACHE COMPLETE")
204
- logger.info(" Trajectories : %d", len(trajectories))
205
- logger.info(" Total points : %d", total_points)
206
- logger.info(" Failures : %d", failures)
207
- logger.info(" Elapsed : %.1f min", elapsed)
208
- logger.info(" Output : %s", out)
 
 
 
 
 
 
209
  logger.info("=" * 70)
210
-
211
-
212
- def _save(trajectories, out: Path, failures: int, zones, cfg):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  payload = {
214
- "version": "indonesia_continuous_v2",
215
  "created_utc": datetime.now(timezone.utc).isoformat(),
216
- "design": "continuous_paradigmatic_seasons",
217
- "n_trajectories": len(trajectories),
218
- "total_points": sum(t["meta"]["n_points"] for t in trajectories),
219
  "priority_zones": zones,
220
- "config_snapshot": {
221
- "forecast_backend": cfg.forecast_backend,
222
- "use_climatology_anomalies": cfg.use_climatology_anomalies,
223
- "include_basin_context": cfg.include_basin_context,
224
- },
225
  "trajectories": trajectories,
226
  }
227
- with open(out, "wb") as f:
 
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(summary, f, indent=2)
241
 
242
 
243
- def main():
244
- p = argparse.ArgumentParser()
245
- p.add_argument("--output", default="historical_continuous_indonesia_v1.pkl")
246
- p.add_argument("--step-days", type=int, default=5)
 
 
 
 
 
247
  p.add_argument("--sleep", type=float, default=0.7)
248
- p.add_argument("--max-days", type=int, default=75)
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