Pipeline hardening: Haiku, dead code cleanup, consec fix, ERA5-lag init
Browse filesBundles 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>
- config.py +4 -4
- src/healing/healer.py +1 -1
- src/pipeline.py +20 -67
|
@@ -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 |
-
"
|
| 250 |
-
"
|
| 251 |
"explain",
|
| 252 |
-
"
|
| 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 |
]
|
|
@@ -485,7 +485,7 @@ class HealingAgent:
|
|
| 485 |
|
| 486 |
MAX_TOOL_ROUNDS = 4
|
| 487 |
|
| 488 |
-
def __init__(self, api_key: str, model: str = "claude-
|
| 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
|
|
@@ -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 |
-
|
|
|
|
| 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 |
-
|
| 547 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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 |
-
|
|
|
|
| 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)
|