jtlevine Claude Opus 4.7 (1M context) commited on
Commit
2bcf53c
Β·
1 Parent(s): 5f87e82

Pipeline hardening: Haiku, dead code cleanup, consec fix, ERA5-lag init

Browse files

Bundles the remaining CRE diagnostic fixes that all touch pipeline.py.

(a) Claude cost: run 67b30124 cost \$4.99 -- 50x the CLAUDE.md target of
~\$0.10/wk. HealingAgent used claude-sonnet-4-6 with a 4-round tool-
use loop at 8192 max_tokens, and the weekly reviewer also used
Sonnet. Switched both to Haiku 4.5 (claude-haiku-4-5-20251001).
Updated blended-cost estimates to \$2/M tokens.

(b) Dead notify step: _step_notify was never called from run() -- the
pipeline dispatches to _step_review at step 6 -- but its method,
its create_sender import, and the insert_notification /
insert_explanation CRUD imports were still wired in. 0 rows in
explanations and notifications tables confirmed. Removed the dead
method + imports. delivery_channel kwarg stays for API compat.

(c) consecutive_days bug: pipeline.py stored
sum(1 for w in reversed(zone_wbgt) if w > THRESH), which is total
days above, not the max consecutive run. Historical trigger_events
rows all showed consecutive_days=7 (= full forecast window) as a
result. Replaced with a max-run-length accumulator.

(d) ARCO ERA5 init date: forecast_for_dar was called with today's
date. ARCO ERA5 has a publication lag so xarray's method="nearest"
silently snaps to the most recent available timestamp, breaking
the assumption that the 5-day forecast covers T+1..T+5. Pinned
init_date = today - 5d.

(e) config.PIPELINE_STEPS listed ingest/heal/index/calibrate/explain/
notify -- completely out of sync with the actual
ingest/heal/downscale/predict/explain/review. Aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (3) hide show
  1. config.py +4 -4
  2. src/healing/healer.py +1 -1
  3. src/pipeline.py +20 -67
config.py CHANGED
@@ -242,12 +242,12 @@ NASA_POWER_PARAMS = [
242
  "PRECTOTCORR", # Corrected precipitation (mm/day)
243
  ]
244
 
245
- # Pipeline step names
246
  PIPELINE_STEPS = [
247
  "ingest",
248
  "heal",
249
- "index",
250
- "calibrate",
251
  "explain",
252
- "notify",
253
  ]
 
242
  "PRECTOTCORR", # Corrected precipitation (mm/day)
243
  ]
244
 
245
+ # Pipeline step names β€” must match HeatRiskPipeline.run() in src/pipeline.py
246
  PIPELINE_STEPS = [
247
  "ingest",
248
  "heal",
249
+ "downscale",
250
+ "predict",
251
  "explain",
252
+ "review",
253
  ]
src/healing/healer.py CHANGED
@@ -485,7 +485,7 @@ class HealingAgent:
485
 
486
  MAX_TOOL_ROUNDS = 4
487
 
488
- def __init__(self, api_key: str, model: str = "claude-sonnet-4-6"):
489
  self.api_key = api_key
490
  self.model = model
491
  self._client: anthropic.Anthropic | None = None
 
485
 
486
  MAX_TOOL_ROUNDS = 4
487
 
488
+ def __init__(self, api_key: str, model: str = "claude-haiku-4-5-20251001"):
489
  self.api_key = api_key
490
  self.model = model
491
  self._client: anthropic.Anthropic | None = None
src/pipeline.py CHANGED
@@ -30,7 +30,6 @@ from src.downscaling.uhi_model import UHICorrector
30
  from src.prediction.heat_forecast import HeatWavePredictor
31
  from src.calibration.basis_risk import assess_all_zones
32
  from src.explanation.explainer import TriggerExplainer, TemplateExplainer
33
- from src.notification.sender import create_sender
34
 
35
  # Database CRUD (imported lazily to keep pipeline usable without DB)
36
  from src.database.crud import (
@@ -41,8 +40,6 @@ from src.database.crud import (
41
  insert_heat_index,
42
  insert_prediction,
43
  insert_trigger_event,
44
- insert_explanation,
45
- insert_notification,
46
  start_pipeline_run,
47
  finish_pipeline_run,
48
  upsert_zone,
@@ -374,7 +371,8 @@ class HeatRiskPipeline:
374
  elif flagged_readings:
375
  logger.info("Rule-based flagged %d anomalies (no Claude key)", len(flagged_readings))
376
 
377
- est_cost = total_tokens * 0.005 / 1000
 
378
 
379
  # DB: write healed readings and healing log
380
  for zone_id, healed in self._healed.items():
@@ -543,8 +541,12 @@ class HeatRiskPipeline:
543
 
544
  try:
545
  from src.prediction.graphcast_inference import forecast_for_dar
546
- today_str = datetime.utcnow().strftime("%Y-%m-%d")
547
- _, gc_wbgt, gc_timing = forecast_for_dar(today_str, apply_mos=True)
 
 
 
 
548
  forecast_source = "graphcast_mos"
549
  logger.info(
550
  "GraphCast forecast: 5d WBGT=%s (fetch=%.0fs, inference=%.0fs)",
@@ -611,7 +613,15 @@ class HeatRiskPipeline:
611
  payout_severity_c=30.7,
612
  )
613
  max_wbgt = max(zone_wbgt) if zone_wbgt else 0
614
- consec = sum(1 for w in reversed(zone_wbgt) if w > WBGT_THRESHOLD_C)
 
 
 
 
 
 
 
 
615
  total_above = sum(1 for w in zone_wbgt if w > WBGT_THRESHOLD_C)
616
 
617
  if action == "alert_cash":
@@ -770,64 +780,6 @@ class HeatRiskPipeline:
770
  errors=[str(e)],
771
  )
772
 
773
- # ── Step 6: NOTIFY ──────────────────────────────────────────────────────
774
-
775
- def _step_notify(self, run_id: str) -> StepResult:
776
- t0 = time.time()
777
-
778
- if not self._explanations:
779
- return StepResult(
780
- step="notify", status="skipped", duration_s=time.time() - t0,
781
- details={"reason": "no explanations to deliver"},
782
- )
783
-
784
- try:
785
- sender = create_sender(self.delivery_channel)
786
- sent_count = 0
787
- failed_count = 0
788
-
789
- for explanation in self._explanations:
790
- message = (
791
- f"[{explanation.trigger_level.upper()}] "
792
- f"{explanation.zone_name}, {explanation.city}\n"
793
- f"{explanation.english_text}\n"
794
- f"---\n"
795
- f"{explanation.swahili_text}\n"
796
- f"Estimated payout: {explanation.payout_estimate}"
797
- )
798
-
799
- result = sender.send(
800
- recipient=f"zone-{explanation.zone_id}",
801
- message=message,
802
- channel=self.delivery_channel,
803
- )
804
- self._notifications.append(result)
805
-
806
- if result.status in ("sent", "dry_run"):
807
- sent_count += 1
808
- else:
809
- failed_count += 1
810
-
811
- est_cost = sent_count * 0.0075 if self.delivery_channel != "console" else 0
812
-
813
- return StepResult(
814
- step="notify", status="ok" if failed_count == 0 else "partial",
815
- duration_s=time.time() - t0,
816
- records_processed=sent_count + failed_count,
817
- details={
818
- "sent": sent_count,
819
- "failed": failed_count,
820
- "channel": self.delivery_channel,
821
- "cost_usd": est_cost,
822
- },
823
- )
824
- except Exception as e:
825
- logger.exception(f"Notification step failed: {e}")
826
- return StepResult(
827
- step="notify", status="failed", duration_s=time.time() - t0,
828
- errors=[str(e)],
829
- )
830
-
831
  # ── Step 6: REVIEW (one Claude call) ──────────────────────────────────
832
 
833
  def _step_review(self, run_id: str) -> StepResult:
@@ -890,14 +842,15 @@ class HeatRiskPipeline:
890
  )
891
 
892
  response = client.messages.create(
893
- model="claude-sonnet-4-6",
894
  max_tokens=500,
895
  messages=[{"role": "user", "content": prompt}],
896
  )
897
 
898
  review_text = response.content[0].text
899
  tokens = response.usage.input_tokens + response.usage.output_tokens
900
- est_cost = tokens * 0.005 / 1000
 
901
 
902
  self._review = review_text
903
  logger.info("Claude review complete (%d tokens, $%.4f)", tokens, est_cost)
 
30
  from src.prediction.heat_forecast import HeatWavePredictor
31
  from src.calibration.basis_risk import assess_all_zones
32
  from src.explanation.explainer import TriggerExplainer, TemplateExplainer
 
33
 
34
  # Database CRUD (imported lazily to keep pipeline usable without DB)
35
  from src.database.crud import (
 
40
  insert_heat_index,
41
  insert_prediction,
42
  insert_trigger_event,
 
 
43
  start_pipeline_run,
44
  finish_pipeline_run,
45
  upsert_zone,
 
371
  elif flagged_readings:
372
  logger.info("Rule-based flagged %d anomalies (no Claude key)", len(flagged_readings))
373
 
374
+ # Haiku 4.5: $1/M input + $5/M output β‰ˆ $2/M blended
375
+ est_cost = total_tokens * 2.0 / 1_000_000
376
 
377
  # DB: write healed readings and healing log
378
  for zone_id, healed in self._healed.items():
 
541
 
542
  try:
543
  from src.prediction.graphcast_inference import forecast_for_dar
544
+ # ARCO ERA5 has a multi-day publication lag; initialising from
545
+ # "today" makes xarray's nearest-match silently fall back to
546
+ # whatever's available, so the 5-day forecast window doesn't
547
+ # align with T+1..T+5 as the pipeline assumes. Anchor to T-5.
548
+ init_date = (datetime.utcnow() - timedelta(days=5)).strftime("%Y-%m-%d")
549
+ _, gc_wbgt, gc_timing = forecast_for_dar(init_date, apply_mos=True)
550
  forecast_source = "graphcast_mos"
551
  logger.info(
552
  "GraphCast forecast: 5d WBGT=%s (fetch=%.0fs, inference=%.0fs)",
 
613
  payout_severity_c=30.7,
614
  )
615
  max_wbgt = max(zone_wbgt) if zone_wbgt else 0
616
+ # Max consecutive-run length above threshold in the forecast
617
+ consec = 0
618
+ run_length = 0
619
+ for w in zone_wbgt:
620
+ if w > WBGT_THRESHOLD_C:
621
+ run_length += 1
622
+ consec = max(consec, run_length)
623
+ else:
624
+ run_length = 0
625
  total_above = sum(1 for w in zone_wbgt if w > WBGT_THRESHOLD_C)
626
 
627
  if action == "alert_cash":
 
780
  errors=[str(e)],
781
  )
782
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
  # ── Step 6: REVIEW (one Claude call) ──────────────────────────────────
784
 
785
  def _step_review(self, run_id: str) -> StepResult:
 
842
  )
843
 
844
  response = client.messages.create(
845
+ model="claude-haiku-4-5-20251001",
846
  max_tokens=500,
847
  messages=[{"role": "user", "content": prompt}],
848
  )
849
 
850
  review_text = response.content[0].text
851
  tokens = response.usage.input_tokens + response.usage.output_tokens
852
+ # Haiku 4.5: $1/M input + $5/M output β‰ˆ $2/M blended
853
+ est_cost = tokens * 2.0 / 1_000_000
854
 
855
  self._review = review_text
856
  logger.info("Claude review complete (%d tokens, $%.4f)", tokens, est_cost)