Add LSTM neural model, ERA5 data, FAISS+BM25 RAG, Neon DB, eval suite; de-jargon frontend
Browse filesCloses the AI/ML sophistication gap with Weather AI 2:
- ERA5 real climate data via Google ARCO archive (free, no API key)
- PyTorch LSTM heat predictor ensembled with XGBoost (4-tier degradation)
- FAISS+BM25 hybrid RAG for alert generation (44-doc index)
- Neon PostgreSQL schema (11 heat-specific tables) with asyncpg + InMemory fallback
- 19-test eval suite (UHI accuracy, predictor AUROC, RAG precision, healing recall)
- Fix broken flood-era pipeline/store/schema imports
- De-jargonify all frontend text for donor/program officer audience
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dockerfile +8 -0
- frontend/.gitignore +1 -0
- frontend/src/components/Sidebar.tsx +1 -1
- frontend/src/lib/tour.ts +42 -39
- frontend/src/pages/Dashboard.tsx +18 -18
- frontend/src/pages/HeatMonitor.tsx +12 -12
- frontend/src/pages/Pipeline.tsx +56 -56
- frontend/src/pages/ProgramDesigner.tsx +10 -10
- frontend/src/pages/Zones.tsx +9 -9
- models/faiss_index/bm25.pkl +3 -0
- models/faiss_index/corpus.pkl +3 -0
- models/faiss_index/index.faiss +3 -0
- models/heat_lstm.pt +3 -0
- models/lstm_norm.json +18 -0
- requirements.txt +10 -0
- scripts/build_rag_index.py +23 -0
- scripts/train_lstm.py +58 -0
- src/api.py +38 -14
- src/database/crud.py +127 -121
- src/database/schema.py +61 -84
- src/explanation/explainer.py +19 -1
- src/explanation/rag_index_builder.py +195 -0
- src/explanation/rag_provider.py +139 -0
- src/healing/healer.py +2 -2
- src/ingestion/era5_fetcher.py +403 -0
- src/ingestion/pipeline_ingest.py +9 -6
- src/pipeline.py +115 -112
- src/prediction/heat_forecast.py +98 -5
- src/prediction/lstm_model.py +535 -0
- src/store.py +58 -160
- tests/__init__.py +0 -0
- tests/conftest.py +9 -0
- tests/eval_healing.py +121 -0
- tests/eval_heat_predictor.py +157 -0
- tests/eval_pipeline.py +134 -0
- tests/eval_rag.py +124 -0
- tests/eval_results/healing_eval.json +32 -0
- tests/eval_results/heat_predictor_eval.json +34 -0
- tests/eval_results/rag_eval.json +166 -0
- tests/eval_results/uhi_eval.json +22 -0
- tests/eval_uhi.py +77 -0
Dockerfile
CHANGED
|
@@ -21,14 +21,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 21 |
libgomp1 ca-certificates curl \
|
| 22 |
&& rm -rf /var/lib/apt/lists/*
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
COPY requirements.txt .
|
| 25 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 26 |
|
| 27 |
COPY config.py .
|
| 28 |
COPY src/ src/
|
| 29 |
COPY models/ models/
|
|
|
|
| 30 |
COPY --from=builder /app/frontend/dist frontend/dist
|
| 31 |
|
|
|
|
|
|
|
|
|
|
| 32 |
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser:appuser /app
|
| 33 |
USER appuser
|
| 34 |
|
|
|
|
| 21 |
libgomp1 ca-certificates curl \
|
| 22 |
&& rm -rf /var/lib/apt/lists/*
|
| 23 |
|
| 24 |
+
# Install PyTorch CPU-only first (separate layer for caching)
|
| 25 |
+
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
| 26 |
+
|
| 27 |
+
# Install remaining Python dependencies
|
| 28 |
COPY requirements.txt .
|
| 29 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 30 |
|
| 31 |
COPY config.py .
|
| 32 |
COPY src/ src/
|
| 33 |
COPY models/ models/
|
| 34 |
+
COPY scripts/ scripts/
|
| 35 |
COPY --from=builder /app/frontend/dist frontend/dist
|
| 36 |
|
| 37 |
+
# Build RAG index at image build time
|
| 38 |
+
RUN python scripts/build_rag_index.py
|
| 39 |
+
|
| 40 |
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser:appuser /app
|
| 41 |
USER appuser
|
| 42 |
|
frontend/.gitignore
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
.vercel
|
|
|
|
|
|
| 1 |
.vercel
|
| 2 |
+
.env*.local
|
frontend/src/components/Sidebar.tsx
CHANGED
|
@@ -14,7 +14,7 @@ const NAV_ITEMS = [
|
|
| 14 |
{ to: '/heat-monitor', label: 'Heat Monitor', icon: Thermometer, tourId: 'nav-heat-monitor' },
|
| 15 |
{ to: '/calibrate', label: 'Program Design', icon: SlidersHorizontal, tourId: 'nav-calibrate' },
|
| 16 |
{ to: '/alerts', label: 'Alerts', icon: Bell, tourId: 'nav-alerts' },
|
| 17 |
-
{ to: '/pipeline', label: '
|
| 18 |
]
|
| 19 |
|
| 20 |
export default function Sidebar() {
|
|
|
|
| 14 |
{ to: '/heat-monitor', label: 'Heat Monitor', icon: Thermometer, tourId: 'nav-heat-monitor' },
|
| 15 |
{ to: '/calibrate', label: 'Program Design', icon: SlidersHorizontal, tourId: 'nav-calibrate' },
|
| 16 |
{ to: '/alerts', label: 'Alerts', icon: Bell, tourId: 'nav-alerts' },
|
| 17 |
+
{ to: '/pipeline', label: 'How It Works', icon: Settings, tourId: 'nav-pipeline' },
|
| 18 |
]
|
| 19 |
|
| 20 |
export default function Sidebar() {
|
frontend/src/lib/tour.ts
CHANGED
|
@@ -4,117 +4,120 @@ export const tourSteps: Step[] = [
|
|
| 4 |
// ── Dashboard ──
|
| 5 |
{
|
| 6 |
target: '[data-tour="hero"]',
|
| 7 |
-
title: 'Welcome
|
| 8 |
content:
|
| 9 |
-
'This
|
| 10 |
-
'It
|
| 11 |
-
'
|
| 12 |
-
'
|
| 13 |
placement: 'bottom',
|
| 14 |
disableBeacon: true,
|
| 15 |
},
|
| 16 |
{
|
| 17 |
target: '[data-tour="stage-cards"]',
|
| 18 |
-
title: '
|
| 19 |
content:
|
| 20 |
-
'
|
| 21 |
-
'
|
|
|
|
| 22 |
placement: 'bottom',
|
| 23 |
disableBeacon: true,
|
| 24 |
},
|
| 25 |
{
|
| 26 |
target: '[data-tour="metrics"]',
|
| 27 |
-
title: '
|
| 28 |
content:
|
| 29 |
-
'
|
| 30 |
-
'
|
| 31 |
placement: 'top',
|
| 32 |
disableBeacon: true,
|
| 33 |
},
|
| 34 |
// ── Navigate to Zones ──
|
| 35 |
{
|
| 36 |
target: '[data-tour="nav-zones"]',
|
| 37 |
-
title: '
|
| 38 |
-
content: 'Next: the 20
|
| 39 |
placement: 'right',
|
| 40 |
disableBeacon: true,
|
| 41 |
},
|
| 42 |
{
|
| 43 |
target: '[data-tour="zones-metrics"]',
|
| 44 |
-
title: '
|
| 45 |
content:
|
| 46 |
-
'
|
| 47 |
-
'Each zone has
|
|
|
|
| 48 |
placement: 'bottom',
|
| 49 |
disableBeacon: true,
|
| 50 |
},
|
| 51 |
// ── Navigate to Heat Monitor ──
|
| 52 |
{
|
| 53 |
target: '[data-tour="nav-heat-monitor"]',
|
| 54 |
-
title: '
|
| 55 |
-
content: 'Next: real-time
|
| 56 |
placement: 'right',
|
| 57 |
disableBeacon: true,
|
| 58 |
},
|
| 59 |
{
|
| 60 |
target: '[data-tour="heat-monitor-metrics"]',
|
| 61 |
-
title: '
|
| 62 |
content:
|
| 63 |
-
'Temperature
|
| 64 |
-
'
|
|
|
|
| 65 |
placement: 'bottom',
|
| 66 |
disableBeacon: true,
|
| 67 |
},
|
| 68 |
{
|
| 69 |
target: '[data-tour="heat-monitor-tabs"]',
|
| 70 |
-
title: '
|
| 71 |
content:
|
| 72 |
-
'The trends tab shows
|
| 73 |
-
'
|
| 74 |
placement: 'top',
|
| 75 |
disableBeacon: true,
|
| 76 |
},
|
| 77 |
// ── Navigate to Program Designer ──
|
| 78 |
{
|
| 79 |
target: '[data-tour="nav-calibrate"]',
|
| 80 |
-
title: '
|
| 81 |
-
content: 'Next:
|
| 82 |
placement: 'right',
|
| 83 |
disableBeacon: true,
|
| 84 |
},
|
| 85 |
{
|
| 86 |
target: '[data-tour="program-controls"]',
|
| 87 |
-
title: '
|
| 88 |
content:
|
| 89 |
-
'Enter
|
| 90 |
-
'
|
| 91 |
-
'
|
| 92 |
placement: 'bottom',
|
| 93 |
disableBeacon: true,
|
| 94 |
},
|
| 95 |
{
|
| 96 |
target: '[data-tour="program-results"]',
|
| 97 |
-
title: '
|
| 98 |
content:
|
| 99 |
-
'Results update live as you
|
| 100 |
-
'
|
| 101 |
placement: 'top',
|
| 102 |
disableBeacon: true,
|
| 103 |
},
|
| 104 |
// ── Navigate to Alerts ──
|
| 105 |
{
|
| 106 |
target: '[data-tour="nav-alerts"]',
|
| 107 |
-
title: '
|
| 108 |
content: 'Next: how workers are notified of heat danger and payouts.',
|
| 109 |
placement: 'right',
|
| 110 |
disableBeacon: true,
|
| 111 |
},
|
| 112 |
{
|
| 113 |
target: '[data-tour="alerts-feed"]',
|
| 114 |
-
title: '
|
| 115 |
content:
|
| 116 |
-
'
|
| 117 |
-
'
|
| 118 |
placement: 'top',
|
| 119 |
disableBeacon: true,
|
| 120 |
},
|
|
@@ -123,9 +126,9 @@ export const tourSteps: Step[] = [
|
|
| 123 |
target: '[data-tour="nav-home"]',
|
| 124 |
title: 'The hard problems remain',
|
| 125 |
content:
|
| 126 |
-
'The full chain from satellite
|
| 127 |
'The hard part is still human: enrolling informal workers who need coverage most, ' +
|
| 128 |
-
'building trust in a product that pays based on a weather
|
| 129 |
'and making sure people actually rest when it\u2019s dangerous to work. ' +
|
| 130 |
'That\u2019s where the investment should go.',
|
| 131 |
placement: 'right',
|
|
|
|
| 4 |
// ── Dashboard ──
|
| 5 |
{
|
| 6 |
target: '[data-tour="hero"]',
|
| 7 |
+
title: 'Welcome',
|
| 8 |
content:
|
| 9 |
+
'This tool protects outdoor workers in East Africa from extreme heat. ' +
|
| 10 |
+
'It reads satellite temperature data, detects dangerous heat building up across 20 neighborhoods ' +
|
| 11 |
+
'in Nairobi, Dar es Salaam, Kampala, and Kigali, and automatically sends payout notifications ' +
|
| 12 |
+
'to enrolled workers when conditions become unsafe.',
|
| 13 |
placement: 'bottom',
|
| 14 |
disableBeacon: true,
|
| 15 |
},
|
| 16 |
{
|
| 17 |
target: '[data-tour="stage-cards"]',
|
| 18 |
+
title: 'From satellite data to worker payouts',
|
| 19 |
content:
|
| 20 |
+
'Data moves through three stages: adjusting satellite temperatures for local city heat effects, ' +
|
| 21 |
+
'predicting whether dangerous heat will arrive in the next seven days, ' +
|
| 22 |
+
'and designing the coverage program to fit the available budget.',
|
| 23 |
placement: 'bottom',
|
| 24 |
disableBeacon: true,
|
| 25 |
},
|
| 26 |
{
|
| 27 |
target: '[data-tour="metrics"]',
|
| 28 |
+
title: 'Key numbers at a glance',
|
| 29 |
content:
|
| 30 |
+
'How many areas are in danger, how many workers are covered, ' +
|
| 31 |
+
'and how reliably the system is running. These update automatically.',
|
| 32 |
placement: 'top',
|
| 33 |
disableBeacon: true,
|
| 34 |
},
|
| 35 |
// ── Navigate to Zones ──
|
| 36 |
{
|
| 37 |
target: '[data-tour="nav-zones"]',
|
| 38 |
+
title: 'Where workers are at risk',
|
| 39 |
+
content: 'Next: the 20 neighborhoods being monitored.',
|
| 40 |
placement: 'right',
|
| 41 |
disableBeacon: true,
|
| 42 |
},
|
| 43 |
{
|
| 44 |
target: '[data-tour="zones-metrics"]',
|
| 45 |
+
title: 'Neighborhoods with the highest heat risk',
|
| 46 |
content:
|
| 47 |
+
'Informal settlements with tin roofs, high worker density, and little shade ' +
|
| 48 |
+
'are the most dangerous. Each zone has a vulnerability profile based on settlement type, ' +
|
| 49 |
+
'worker population, and local heat conditions.',
|
| 50 |
placement: 'bottom',
|
| 51 |
disableBeacon: true,
|
| 52 |
},
|
| 53 |
// ── Navigate to Heat Monitor ──
|
| 54 |
{
|
| 55 |
target: '[data-tour="nav-heat-monitor"]',
|
| 56 |
+
title: 'Live heat tracking',
|
| 57 |
+
content: 'Next: real-time temperatures and danger levels across all zones.',
|
| 58 |
placement: 'right',
|
| 59 |
disableBeacon: true,
|
| 60 |
},
|
| 61 |
{
|
| 62 |
target: '[data-tour="heat-monitor-metrics"]',
|
| 63 |
+
title: 'When does heat become dangerous?',
|
| 64 |
content:
|
| 65 |
+
'Temperature alone doesn\u2019t tell the full story \u2014 humidity makes heat deadly. ' +
|
| 66 |
+
'The heat stress index combines both. When it stays dangerously high for multiple days, ' +
|
| 67 |
+
'payouts are triggered automatically.',
|
| 68 |
placement: 'bottom',
|
| 69 |
disableBeacon: true,
|
| 70 |
},
|
| 71 |
{
|
| 72 |
target: '[data-tour="heat-monitor-tabs"]',
|
| 73 |
+
title: '90 days of temperature history',
|
| 74 |
content:
|
| 75 |
+
'The trends tab shows how heat has been building in each zone, ' +
|
| 76 |
+
'making it easy to spot heat waves forming before they peak.',
|
| 77 |
placement: 'top',
|
| 78 |
disableBeacon: true,
|
| 79 |
},
|
| 80 |
// ── Navigate to Program Designer ──
|
| 81 |
{
|
| 82 |
target: '[data-tour="nav-calibrate"]',
|
| 83 |
+
title: 'Designing the coverage program',
|
| 84 |
+
content: 'Next: set your budget and see how far it stretches.',
|
| 85 |
placement: 'right',
|
| 86 |
disableBeacon: true,
|
| 87 |
},
|
| 88 |
{
|
| 89 |
target: '[data-tour="program-controls"]',
|
| 90 |
+
title: 'Set your budget and payout amount',
|
| 91 |
content:
|
| 92 |
+
'Enter the total budget and how much each worker gets paid per heat event. ' +
|
| 93 |
+
'The system calculates what it costs to cover each zone based on how often ' +
|
| 94 |
+
'dangerous heat occurs, then funds the highest-risk areas first.',
|
| 95 |
placement: 'bottom',
|
| 96 |
disableBeacon: true,
|
| 97 |
},
|
| 98 |
{
|
| 99 |
target: '[data-tour="program-results"]',
|
| 100 |
+
title: 'Coverage results',
|
| 101 |
content:
|
| 102 |
+
'Results update live as you adjust the sliders. Each zone is ranked by priority \u2014 ' +
|
| 103 |
+
'you can see the cost per worker, how many are covered, and what percentage of the workforce is protected.',
|
| 104 |
placement: 'top',
|
| 105 |
disableBeacon: true,
|
| 106 |
},
|
| 107 |
// ── Navigate to Alerts ──
|
| 108 |
{
|
| 109 |
target: '[data-tour="nav-alerts"]',
|
| 110 |
+
title: 'Reaching workers when it matters',
|
| 111 |
content: 'Next: how workers are notified of heat danger and payouts.',
|
| 112 |
placement: 'right',
|
| 113 |
disableBeacon: true,
|
| 114 |
},
|
| 115 |
{
|
| 116 |
target: '[data-tour="alerts-feed"]',
|
| 117 |
+
title: 'Alerts in English and Swahili',
|
| 118 |
content:
|
| 119 |
+
'When dangerous heat is detected, workers receive SMS and WhatsApp messages ' +
|
| 120 |
+
'with the temperature reading, danger level, protective actions, and their payout amount.',
|
| 121 |
placement: 'top',
|
| 122 |
disableBeacon: true,
|
| 123 |
},
|
|
|
|
| 126 |
target: '[data-tour="nav-home"]',
|
| 127 |
title: 'The hard problems remain',
|
| 128 |
content:
|
| 129 |
+
'The full chain from satellite data to worker payout notification, automated. ' +
|
| 130 |
'The hard part is still human: enrolling informal workers who need coverage most, ' +
|
| 131 |
+
'building trust in a product that pays based on a weather reading, ' +
|
| 132 |
'and making sure people actually rest when it\u2019s dangerous to work. ' +
|
| 133 |
'That\u2019s where the investment should go.',
|
| 134 |
placement: 'right',
|
frontend/src/pages/Dashboard.tsx
CHANGED
|
@@ -22,26 +22,26 @@ export default function Dashboard() {
|
|
| 22 |
<div data-tour="hero" className="pt-2 pb-6">
|
| 23 |
<h1 className="page-title">Extreme Heat Risk Engine</h1>
|
| 24 |
<p className="page-caption">
|
| 25 |
-
|
| 26 |
</p>
|
| 27 |
</div>
|
| 28 |
|
| 29 |
{/* Stage Cards */}
|
| 30 |
<div data-tour="stage-cards" className="mb-8">
|
| 31 |
-
<div className="section-header">
|
| 32 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
| 33 |
<Link to="/heat-monitor" className="stage-card no-underline">
|
| 34 |
<div className="flex items-center gap-3 mb-2">
|
| 35 |
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center">
|
| 36 |
<Satellite size={18} className="text-info" />
|
| 37 |
</div>
|
| 38 |
-
<h3 className="text-sm font-semibold text-[#1a1a1a] font-sans m-0">
|
| 39 |
</div>
|
| 40 |
<p className="text-xs text-warm-body leading-relaxed m-0">
|
| 41 |
-
|
| 42 |
</p>
|
| 43 |
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-warm-muted uppercase tracking-wider font-semibold">
|
| 44 |
-
{s?.data_sources?.join(' + ')} +
|
| 45 |
</div>
|
| 46 |
</Link>
|
| 47 |
|
|
@@ -54,13 +54,13 @@ export default function Dashboard() {
|
|
| 54 |
<div className="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center">
|
| 55 |
<Thermometer size={18} className="text-warning" />
|
| 56 |
</div>
|
| 57 |
-
<h3 className="text-sm font-semibold text-[#1a1a1a] font-sans m-0">
|
| 58 |
</div>
|
| 59 |
<p className="text-xs text-warm-body leading-relaxed m-0">
|
| 60 |
-
|
| 61 |
</p>
|
| 62 |
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-warm-muted uppercase tracking-wider font-semibold">
|
| 63 |
-
|
| 64 |
</div>
|
| 65 |
</Link>
|
| 66 |
|
|
@@ -76,10 +76,10 @@ export default function Dashboard() {
|
|
| 76 |
<h3 className="text-sm font-semibold text-[#1a1a1a] font-sans m-0">Program Design</h3>
|
| 77 |
</div>
|
| 78 |
<p className="text-xs text-warm-body leading-relaxed m-0">
|
| 79 |
-
|
| 80 |
</p>
|
| 81 |
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-warm-muted uppercase tracking-wider font-semibold">
|
| 82 |
-
Pricing + Budget +
|
| 83 |
</div>
|
| 84 |
</Link>
|
| 85 |
</div>
|
|
@@ -90,9 +90,9 @@ export default function Dashboard() {
|
|
| 90 |
<div className="section-header">Current Status</div>
|
| 91 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-stagger">
|
| 92 |
<MetricCard
|
| 93 |
-
label="Active
|
| 94 |
value={s?.active_triggers}
|
| 95 |
-
subtitle="zones
|
| 96 |
/>
|
| 97 |
<MetricCard
|
| 98 |
label="Zones Monitored"
|
|
@@ -105,9 +105,9 @@ export default function Dashboard() {
|
|
| 105 |
subtitle="across all zones"
|
| 106 |
/>
|
| 107 |
<MetricCard
|
| 108 |
-
label="
|
| 109 |
value={s?.total_runs}
|
| 110 |
-
subtitle={`${Math.round((s?.success_rate ?? 0) * 100)}%
|
| 111 |
/>
|
| 112 |
</div>
|
| 113 |
</div>
|
|
@@ -120,12 +120,12 @@ export default function Dashboard() {
|
|
| 120 |
style={{ borderBottom: '2px solid #d4a019', paddingBottom: 8, marginBottom: 16 }}
|
| 121 |
>
|
| 122 |
{showRuns ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
| 123 |
-
|
| 124 |
</button>
|
| 125 |
{showRuns && (
|
| 126 |
<div className="animate-tab-enter">
|
| 127 |
{runs.isLoading ? (
|
| 128 |
-
<LoadingSpinner message="Loading
|
| 129 |
) : runs.isError ? (
|
| 130 |
<ErrorState onRetry={() => runs.refetch()} />
|
| 131 |
) : (
|
|
@@ -138,8 +138,8 @@ export default function Dashboard() {
|
|
| 138 |
<th>Status</th>
|
| 139 |
<th>Duration</th>
|
| 140 |
<th>Zones</th>
|
| 141 |
-
<th>
|
| 142 |
-
<th>
|
| 143 |
<th>Cost (USD)</th>
|
| 144 |
</tr>
|
| 145 |
</thead>
|
|
|
|
| 22 |
<div data-tour="hero" className="pt-2 pb-6">
|
| 23 |
<h1 className="page-title">Extreme Heat Risk Engine</h1>
|
| 24 |
<p className="page-caption">
|
| 25 |
+
Protecting outdoor workers across East Africa with automatic heat-triggered payouts
|
| 26 |
</p>
|
| 27 |
</div>
|
| 28 |
|
| 29 |
{/* Stage Cards */}
|
| 30 |
<div data-tour="stage-cards" className="mb-8">
|
| 31 |
+
<div className="section-header">How It Works</div>
|
| 32 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
| 33 |
<Link to="/heat-monitor" className="stage-card no-underline">
|
| 34 |
<div className="flex items-center gap-3 mb-2">
|
| 35 |
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center">
|
| 36 |
<Satellite size={18} className="text-info" />
|
| 37 |
</div>
|
| 38 |
+
<h3 className="text-sm font-semibold text-[#1a1a1a] font-sans m-0">Satellite Temperature + City Heat Adjustment</h3>
|
| 39 |
</div>
|
| 40 |
<p className="text-xs text-warm-body leading-relaxed m-0">
|
| 41 |
+
Satellite readings adjusted for local urban heat effects — tin roofs, concrete, and lack of shade make neighborhoods hotter than surrounding areas
|
| 42 |
</p>
|
| 43 |
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-warm-muted uppercase tracking-wider font-semibold">
|
| 44 |
+
{s?.data_sources?.join(' + ')} + City Heat Model
|
| 45 |
</div>
|
| 46 |
</Link>
|
| 47 |
|
|
|
|
| 54 |
<div className="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center">
|
| 55 |
<Thermometer size={18} className="text-warning" />
|
| 56 |
</div>
|
| 57 |
+
<h3 className="text-sm font-semibold text-[#1a1a1a] font-sans m-0">7-Day Danger Forecast</h3>
|
| 58 |
</div>
|
| 59 |
<p className="text-xs text-warm-body leading-relaxed m-0">
|
| 60 |
+
Predicts how likely dangerous heat is over the next week, combining temperature, humidity, and recent trends for each zone
|
| 61 |
</p>
|
| 62 |
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-warm-muted uppercase tracking-wider font-semibold">
|
| 63 |
+
AI Forecast + Heat Stress + Feels-Like Temp
|
| 64 |
</div>
|
| 65 |
</Link>
|
| 66 |
|
|
|
|
| 76 |
<h3 className="text-sm font-semibold text-[#1a1a1a] font-sans m-0">Program Design</h3>
|
| 77 |
</div>
|
| 78 |
<p className="text-xs text-warm-body leading-relaxed m-0">
|
| 79 |
+
Set your budget and payout amount — the system prices each zone based on heat risk and covers the most vulnerable areas first
|
| 80 |
</p>
|
| 81 |
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-warm-muted uppercase tracking-wider font-semibold">
|
| 82 |
+
Pricing + Budget + Coverage
|
| 83 |
</div>
|
| 84 |
</Link>
|
| 85 |
</div>
|
|
|
|
| 90 |
<div className="section-header">Current Status</div>
|
| 91 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-stagger">
|
| 92 |
<MetricCard
|
| 93 |
+
label="Active Danger Alerts"
|
| 94 |
value={s?.active_triggers}
|
| 95 |
+
subtitle="zones in unsafe heat"
|
| 96 |
/>
|
| 97 |
<MetricCard
|
| 98 |
label="Zones Monitored"
|
|
|
|
| 105 |
subtitle="across all zones"
|
| 106 |
/>
|
| 107 |
<MetricCard
|
| 108 |
+
label="Weekly Updates"
|
| 109 |
value={s?.total_runs}
|
| 110 |
+
subtitle={`${Math.round((s?.success_rate ?? 0) * 100)}% successful`}
|
| 111 |
/>
|
| 112 |
</div>
|
| 113 |
</div>
|
|
|
|
| 120 |
style={{ borderBottom: '2px solid #d4a019', paddingBottom: 8, marginBottom: 16 }}
|
| 121 |
>
|
| 122 |
{showRuns ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
| 123 |
+
Update History
|
| 124 |
</button>
|
| 125 |
{showRuns && (
|
| 126 |
<div className="animate-tab-enter">
|
| 127 |
{runs.isLoading ? (
|
| 128 |
+
<LoadingSpinner message="Loading history..." />
|
| 129 |
) : runs.isError ? (
|
| 130 |
<ErrorState onRetry={() => runs.refetch()} />
|
| 131 |
) : (
|
|
|
|
| 138 |
<th>Status</th>
|
| 139 |
<th>Duration</th>
|
| 140 |
<th>Zones</th>
|
| 141 |
+
<th>Alerts</th>
|
| 142 |
+
<th>Messages</th>
|
| 143 |
<th>Cost (USD)</th>
|
| 144 |
</tr>
|
| 145 |
</thead>
|
frontend/src/pages/HeatMonitor.tsx
CHANGED
|
@@ -70,7 +70,7 @@ export default function HeatMonitor() {
|
|
| 70 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-stagger">
|
| 71 |
<MetricCard label="Zones Tracked" value={data.length} subtitle="across all cities" />
|
| 72 |
<MetricCard label="Critical Heat" value={criticalCount} subtitle="zones at critical risk" />
|
| 73 |
-
<MetricCard label="
|
| 74 |
<MetricCard label="Avg Temperature" value={`${avgTemp.toFixed(1)}°C`} subtitle="across all zones" />
|
| 75 |
</div>
|
| 76 |
</div>
|
|
@@ -101,12 +101,12 @@ export default function HeatMonitor() {
|
|
| 101 |
<tr>
|
| 102 |
<th>Zone</th>
|
| 103 |
<th>Temp (°C)</th>
|
| 104 |
-
<th>
|
| 105 |
-
<th>
|
| 106 |
<th>Consecutive Hot Days</th>
|
| 107 |
-
<th>7-Day
|
| 108 |
<th>Confidence</th>
|
| 109 |
-
<th>
|
| 110 |
<th>Risk Level</th>
|
| 111 |
</tr>
|
| 112 |
</thead>
|
|
@@ -116,7 +116,7 @@ export default function HeatMonitor() {
|
|
| 116 |
const probColor = pct > 70 ? '#e63946' : pct > 50 ? '#e67e22' : pct > 20 ? '#d4a019' : '#2a9d8f'
|
| 117 |
const tier = idx.model_tier ?? 'climatology'
|
| 118 |
const tierColor = tier === 'full_model' ? '#2a9d8f' : tier === 'persistence' ? '#d4a019' : '#888'
|
| 119 |
-
const tierLabel = tier === '
|
| 120 |
return (
|
| 121 |
<tr key={idx.zone_id}>
|
| 122 |
<td>
|
|
@@ -188,7 +188,7 @@ export default function HeatMonitor() {
|
|
| 188 |
))}
|
| 189 |
</div>
|
| 190 |
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-warm-muted font-sans">
|
| 191 |
-
<span className="font-semibold uppercase tracking-wider">
|
| 192 |
{[
|
| 193 |
{ label: '<28°C Safe', color: '#2a9d8f' },
|
| 194 |
{ label: '28-30°C Caution', color: '#d4a019' },
|
|
@@ -274,7 +274,7 @@ export default function HeatMonitor() {
|
|
| 274 |
}}
|
| 275 |
labelStyle={{ color: '#d4a019', fontWeight: 600 }}
|
| 276 |
formatter={(value: number, name: string) => {
|
| 277 |
-
const labels: Record<string, string> = { temp: '
|
| 278 |
return [typeof value === 'number' ? value.toFixed(1) : value, labels[name] ?? name]
|
| 279 |
}}
|
| 280 |
/>
|
|
@@ -311,19 +311,19 @@ export default function HeatMonitor() {
|
|
| 311 |
<div className="mt-3 flex items-center gap-6 text-xs text-warm-muted font-sans">
|
| 312 |
<span className="flex items-center gap-1.5">
|
| 313 |
<span className="w-4 h-0.5 inline-block rounded" style={{ borderTop: '1.5px dashed #888', background: 'none' }} />
|
| 314 |
-
|
| 315 |
</span>
|
| 316 |
<span className="flex items-center gap-1.5">
|
| 317 |
<span className="w-4 h-0.5 bg-[#e63946] inline-block rounded" />
|
| 318 |
-
|
| 319 |
</span>
|
| 320 |
<span className="flex items-center gap-1.5">
|
| 321 |
<span className="w-4 h-0.5 inline-block rounded" style={{ borderTop: '1.5px dashed #1565C0', background: 'none' }} />
|
| 322 |
-
|
| 323 |
</span>
|
| 324 |
<span className="flex items-center gap-1.5">
|
| 325 |
<span className="w-4 h-0.5 inline-block rounded" style={{ borderTop: '1.5px dashed #e63946', background: 'none' }} />
|
| 326 |
-
35°C
|
| 327 |
</span>
|
| 328 |
</div>
|
| 329 |
</div>
|
|
|
|
| 70 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-stagger">
|
| 71 |
<MetricCard label="Zones Tracked" value={data.length} subtitle="across all cities" />
|
| 72 |
<MetricCard label="Critical Heat" value={criticalCount} subtitle="zones at critical risk" />
|
| 73 |
+
<MetricCard label="High Heat Stress" value={wbgtDanger} subtitle="heat stress > 32°C" />
|
| 74 |
<MetricCard label="Avg Temperature" value={`${avgTemp.toFixed(1)}°C`} subtitle="across all zones" />
|
| 75 |
</div>
|
| 76 |
</div>
|
|
|
|
| 101 |
<tr>
|
| 102 |
<th>Zone</th>
|
| 103 |
<th>Temp (°C)</th>
|
| 104 |
+
<th>Heat Stress (°C)</th>
|
| 105 |
+
<th>Feels Like</th>
|
| 106 |
<th>Consecutive Hot Days</th>
|
| 107 |
+
<th>7-Day Danger</th>
|
| 108 |
<th>Confidence</th>
|
| 109 |
+
<th>Forecast Type</th>
|
| 110 |
<th>Risk Level</th>
|
| 111 |
</tr>
|
| 112 |
</thead>
|
|
|
|
| 116 |
const probColor = pct > 70 ? '#e63946' : pct > 50 ? '#e67e22' : pct > 20 ? '#d4a019' : '#2a9d8f'
|
| 117 |
const tier = idx.model_tier ?? 'climatology'
|
| 118 |
const tierColor = tier === 'full_model' ? '#2a9d8f' : tier === 'persistence' ? '#d4a019' : '#888'
|
| 119 |
+
const tierLabel = tier === 'ensemble' ? 'AI-Powered' : tier === 'full_model' ? 'AI-Powered' : tier === 'persistence' ? 'Recent Trend' : 'Historical Avg'
|
| 120 |
return (
|
| 121 |
<tr key={idx.zone_id}>
|
| 122 |
<td>
|
|
|
|
| 188 |
))}
|
| 189 |
</div>
|
| 190 |
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-warm-muted font-sans">
|
| 191 |
+
<span className="font-semibold uppercase tracking-wider">Heat Stress:</span>
|
| 192 |
{[
|
| 193 |
{ label: '<28°C Safe', color: '#2a9d8f' },
|
| 194 |
{ label: '28-30°C Caution', color: '#d4a019' },
|
|
|
|
| 274 |
}}
|
| 275 |
labelStyle={{ color: '#d4a019', fontWeight: 600 }}
|
| 276 |
formatter={(value: number, name: string) => {
|
| 277 |
+
const labels: Record<string, string> = { temp: 'Adjusted Temp (°C)', gridTemp: 'Satellite Temp (°C)', wbgt: 'Heat Stress (°C)', humidity: 'Humidity (%)' }
|
| 278 |
return [typeof value === 'number' ? value.toFixed(1) : value, labels[name] ?? name]
|
| 279 |
}}
|
| 280 |
/>
|
|
|
|
| 311 |
<div className="mt-3 flex items-center gap-6 text-xs text-warm-muted font-sans">
|
| 312 |
<span className="flex items-center gap-1.5">
|
| 313 |
<span className="w-4 h-0.5 inline-block rounded" style={{ borderTop: '1.5px dashed #888', background: 'none' }} />
|
| 314 |
+
Satellite Temp (°C)
|
| 315 |
</span>
|
| 316 |
<span className="flex items-center gap-1.5">
|
| 317 |
<span className="w-4 h-0.5 bg-[#e63946] inline-block rounded" />
|
| 318 |
+
Adjusted Temp (°C)
|
| 319 |
</span>
|
| 320 |
<span className="flex items-center gap-1.5">
|
| 321 |
<span className="w-4 h-0.5 inline-block rounded" style={{ borderTop: '1.5px dashed #1565C0', background: 'none' }} />
|
| 322 |
+
Heat Stress (°C)
|
| 323 |
</span>
|
| 324 |
<span className="flex items-center gap-1.5">
|
| 325 |
<span className="w-4 h-0.5 inline-block rounded" style={{ borderTop: '1.5px dashed #e63946', background: 'none' }} />
|
| 326 |
+
35°C danger level
|
| 327 |
</span>
|
| 328 |
</div>
|
| 329 |
</div>
|
frontend/src/pages/Pipeline.tsx
CHANGED
|
@@ -12,12 +12,12 @@ const STATUS_COLOR: Record<string, string> = {
|
|
| 12 |
}
|
| 13 |
|
| 14 |
const STEP_LABELS: Record<string, string> = {
|
| 15 |
-
ingest: '
|
| 16 |
-
heal: 'Data
|
| 17 |
-
index: 'Heat
|
| 18 |
-
calibrate: '
|
| 19 |
-
explain: 'Alert
|
| 20 |
-
notify: '
|
| 21 |
}
|
| 22 |
|
| 23 |
// ---------------------------------------------------------------------------
|
|
@@ -26,53 +26,53 @@ const STEP_LABELS: Record<string, string> = {
|
|
| 26 |
|
| 27 |
const ARCH_STEPS = [
|
| 28 |
{
|
| 29 |
-
num: 1, name: '
|
| 30 |
-
desc: '
|
| 31 |
options: [
|
| 32 |
-
{ label: '
|
| 33 |
-
{ label: '
|
| 34 |
],
|
| 35 |
},
|
| 36 |
{
|
| 37 |
-
num: 2, name: '
|
| 38 |
-
desc: '
|
| 39 |
options: [
|
| 40 |
-
{ label: '
|
| 41 |
-
{ label: '
|
| 42 |
],
|
| 43 |
},
|
| 44 |
{
|
| 45 |
-
num: 3, name: '
|
| 46 |
-
desc: 'Calculate
|
| 47 |
options: [
|
| 48 |
-
{ label: '
|
| 49 |
-
{ label: '
|
| 50 |
-
{ label: 'Consecutive Days', note: '
|
| 51 |
],
|
| 52 |
},
|
| 53 |
{
|
| 54 |
-
num: 4, name: '
|
| 55 |
-
desc: '
|
| 56 |
options: [
|
| 57 |
-
{ label: '
|
| 58 |
-
{ label: '
|
| 59 |
],
|
| 60 |
},
|
| 61 |
{
|
| 62 |
-
num: 5, name: '
|
| 63 |
-
desc: '
|
| 64 |
options: [
|
| 65 |
-
{ label: '
|
| 66 |
-
{ label: '
|
| 67 |
],
|
| 68 |
},
|
| 69 |
{
|
| 70 |
num: 6, name: 'Notify', table: 'notifications', color: '#d4a019',
|
| 71 |
-
desc: '
|
| 72 |
options: [
|
| 73 |
-
{ label: '
|
| 74 |
-
{ label: '
|
| 75 |
-
{ label: 'WhatsApp', note: '
|
| 76 |
],
|
| 77 |
},
|
| 78 |
]
|
|
@@ -139,22 +139,22 @@ function BuildYourOwnTab() {
|
|
| 139 |
<div className="space-y-6">
|
| 140 |
<div className="card card-body">
|
| 141 |
<p style={{ fontSize: '0.9rem', color: '#555', lineHeight: 1.7, marginBottom: '16px' }}>
|
| 142 |
-
This
|
| 143 |
-
|
| 144 |
-
|
| 145 |
</p>
|
| 146 |
<p style={{ fontSize: '0.85rem', color: '#888', lineHeight: 1.7 }}>
|
| 147 |
-
To
|
| 148 |
</p>
|
| 149 |
</div>
|
| 150 |
|
| 151 |
<div className="section-header">Region-Specific Files</div>
|
| 152 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 153 |
{[
|
| 154 |
-
{ file: 'config.py', desc: '
|
| 155 |
-
{ file: 'src/healing/healer.py', desc: '
|
| 156 |
-
{ file: 'src/explanation/knowledge_base.py', desc: 'Local heat safety
|
| 157 |
-
{ file: 'src/calibration/basis_risk.py', desc: '
|
| 158 |
].map(item => (
|
| 159 |
<div key={item.file} style={{
|
| 160 |
background: '#fff', border: '1px solid #e0dcd5', borderRadius: '8px',
|
|
@@ -172,10 +172,10 @@ function BuildYourOwnTab() {
|
|
| 172 |
<div className="section-header">Globally Portable (No Changes Needed)</div>
|
| 173 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 174 |
{[
|
| 175 |
-
{ file: 'src/ingestion/nasa_power.py', desc: 'Works anywhere on earth — just provide
|
| 176 |
-
{ file: 'src/indexing/heat_index.py', desc: '
|
| 177 |
-
{ file: 'src/indexing/heat_risk.py', desc: '
|
| 178 |
-
{ file: 'src/notification/sender.py', desc: '
|
| 179 |
].map(item => (
|
| 180 |
<div key={item.file} style={{
|
| 181 |
background: '#fff', border: '1px solid #e0dcd5', borderRadius: '8px',
|
|
@@ -210,14 +210,14 @@ export default function Pipeline() {
|
|
| 210 |
const runsData = runs.data?.runs ?? []
|
| 211 |
const s = stats.data
|
| 212 |
|
| 213 |
-
const TABS = ['Architecture', '
|
| 214 |
|
| 215 |
return (
|
| 216 |
<div data-tour="pipeline-title" className="animate-slide-up">
|
| 217 |
<div className="pt-2 pb-6">
|
| 218 |
-
<h1 className="page-title">
|
| 219 |
<p className="page-caption">
|
| 220 |
-
|
| 221 |
</p>
|
| 222 |
</div>
|
| 223 |
|
|
@@ -225,14 +225,14 @@ export default function Pipeline() {
|
|
| 225 |
<div className="mb-8">
|
| 226 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-stagger">
|
| 227 |
<MetricCard
|
| 228 |
-
label="Total
|
| 229 |
value={s?.total_runs}
|
| 230 |
subtitle={`${s?.successful_runs} successful`}
|
| 231 |
/>
|
| 232 |
<MetricCard
|
| 233 |
-
label="
|
| 234 |
value={`${Math.round((s?.success_rate ?? 0) * 100)}%`}
|
| 235 |
-
subtitle="
|
| 236 |
/>
|
| 237 |
<MetricCard
|
| 238 |
label="Total Cost"
|
|
@@ -275,7 +275,7 @@ export default function Pipeline() {
|
|
| 275 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 276 |
<div>
|
| 277 |
<p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-1">
|
| 278 |
-
Total
|
| 279 |
</p>
|
| 280 |
<p className="text-lg font-serif font-bold text-[#1a1a1a]">
|
| 281 |
${s?.total_cost_usd?.toFixed(2)}
|
|
@@ -283,7 +283,7 @@ export default function Pipeline() {
|
|
| 283 |
</div>
|
| 284 |
<div>
|
| 285 |
<p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-1">
|
| 286 |
-
Average per
|
| 287 |
</p>
|
| 288 |
<p className="text-lg font-serif font-bold text-[#1a1a1a]">
|
| 289 |
${s?.avg_cost_per_run_usd?.toFixed(4)}
|
|
@@ -291,7 +291,7 @@ export default function Pipeline() {
|
|
| 291 |
</div>
|
| 292 |
<div>
|
| 293 |
<p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-1">
|
| 294 |
-
Last
|
| 295 |
</p>
|
| 296 |
<p className="text-lg font-serif font-bold text-[#1a1a1a]">
|
| 297 |
{s?.last_run ? new Date(s.last_run).toLocaleDateString() : '--'}
|
|
@@ -301,7 +301,7 @@ export default function Pipeline() {
|
|
| 301 |
</div>
|
| 302 |
|
| 303 |
{/* Run History */}
|
| 304 |
-
<div className="section-header">
|
| 305 |
<div className="table-container">
|
| 306 |
<table>
|
| 307 |
<thead>
|
|
@@ -312,8 +312,8 @@ export default function Pipeline() {
|
|
| 312 |
<th>Status</th>
|
| 313 |
<th>Duration</th>
|
| 314 |
<th>Zones</th>
|
| 315 |
-
<th>
|
| 316 |
-
<th>
|
| 317 |
<th>Cost (USD)</th>
|
| 318 |
</tr>
|
| 319 |
</thead>
|
|
@@ -344,7 +344,7 @@ export default function Pipeline() {
|
|
| 344 |
<td colSpan={9} className="bg-warm-header-bg !py-0 !px-0">
|
| 345 |
<div className="px-6 py-4">
|
| 346 |
<p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-3">
|
| 347 |
-
|
| 348 |
</p>
|
| 349 |
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
| 350 |
{run.steps.map((step, i) => (
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
const STEP_LABELS: Record<string, string> = {
|
| 15 |
+
ingest: 'Collect Data',
|
| 16 |
+
heal: 'Fix Data Issues',
|
| 17 |
+
index: 'Calculate Heat Stress',
|
| 18 |
+
calibrate: 'Detect Danger',
|
| 19 |
+
explain: 'Write Alert Messages',
|
| 20 |
+
notify: 'Notify Workers',
|
| 21 |
}
|
| 22 |
|
| 23 |
// ---------------------------------------------------------------------------
|
|
|
|
| 26 |
|
| 27 |
const ARCH_STEPS = [
|
| 28 |
{
|
| 29 |
+
num: 1, name: 'Collect', table: 'daily_readings', color: '#2E7D32',
|
| 30 |
+
desc: 'Pull temperature and humidity readings from satellite and ground sources',
|
| 31 |
options: [
|
| 32 |
+
{ label: 'Satellite Data (NASA)', note: 'Free global temperature and humidity data', active: true },
|
| 33 |
+
{ label: 'Local Weather Stations', note: 'Your own stations or uploaded files' },
|
| 34 |
],
|
| 35 |
},
|
| 36 |
{
|
| 37 |
+
num: 2, name: 'Fix', table: 'healed_readings', color: '#1565C0',
|
| 38 |
+
desc: 'Detect and fix bad readings — missing data, sensor errors, outliers',
|
| 39 |
options: [
|
| 40 |
+
{ label: 'AI-Powered Repair', note: 'Automatically investigates and corrects problems', active: true },
|
| 41 |
+
{ label: 'Automated Rules', note: 'Pattern-based detection when AI is unavailable' },
|
| 42 |
],
|
| 43 |
},
|
| 44 |
{
|
| 45 |
+
num: 3, name: 'Assess', table: 'heat_indices', color: '#7B1FA2',
|
| 46 |
+
desc: 'Calculate heat stress and feels-like temperature for each zone',
|
| 47 |
options: [
|
| 48 |
+
{ label: 'Heat Stress Index', note: 'Combines temperature and humidity into a danger score', active: true },
|
| 49 |
+
{ label: 'Feels-Like Temperature', note: 'What the heat actually feels like for someone working outside', active: true },
|
| 50 |
+
{ label: 'Consecutive Hot Days', note: 'Counts how many days in a row exceed safe levels', active: true },
|
| 51 |
],
|
| 52 |
},
|
| 53 |
{
|
| 54 |
+
num: 4, name: 'Detect', table: 'trigger_events', color: '#E65100',
|
| 55 |
+
desc: 'Identify when heat becomes dangerous enough to trigger a payout',
|
| 56 |
options: [
|
| 57 |
+
{ label: 'Combined Danger Score', note: 'Weighs temperature, heat stress, duration, vulnerability, and exposure', active: true },
|
| 58 |
+
{ label: 'Coverage Gap Analysis', note: 'Estimates how well the trigger matches workers\u2019 real experience', active: true },
|
| 59 |
],
|
| 60 |
},
|
| 61 |
{
|
| 62 |
+
num: 5, name: 'Alert', table: 'explanations', color: '#C62828',
|
| 63 |
+
desc: 'Write clear heat alerts in English and Swahili with safety advice',
|
| 64 |
options: [
|
| 65 |
+
{ label: 'AI-Generated Messages', note: 'Personalized alerts with local safety guidance', active: true },
|
| 66 |
+
{ label: 'Pre-Written Templates', note: 'Standard messages when AI is unavailable' },
|
| 67 |
],
|
| 68 |
},
|
| 69 |
{
|
| 70 |
num: 6, name: 'Notify', table: 'notifications', color: '#d4a019',
|
| 71 |
+
desc: 'Send alerts and payout notifications to enrolled workers',
|
| 72 |
options: [
|
| 73 |
+
{ label: 'Test Mode', note: 'Preview messages without sending', active: true },
|
| 74 |
+
{ label: 'Text Message (SMS)', note: 'Delivered to worker phones' },
|
| 75 |
+
{ label: 'WhatsApp', note: 'Delivered via WhatsApp' },
|
| 76 |
],
|
| 77 |
},
|
| 78 |
]
|
|
|
|
| 139 |
<div className="space-y-6">
|
| 140 |
<div className="card card-body">
|
| 141 |
<p style={{ fontSize: '0.9rem', color: '#555', lineHeight: 1.7, marginBottom: '16px' }}>
|
| 142 |
+
This system is designed to work anywhere with dangerous outdoor heat. The core logic — collecting data,
|
| 143 |
+
detecting danger, generating alerts — is universal. Only the local details change: which neighborhoods,
|
| 144 |
+
what temperature thresholds, local safety guidance, and payout amounts.
|
| 145 |
</p>
|
| 146 |
<p style={{ fontSize: '0.85rem', color: '#888', lineHeight: 1.7 }}>
|
| 147 |
+
To deploy in a new region, customize these files:
|
| 148 |
</p>
|
| 149 |
</div>
|
| 150 |
|
| 151 |
<div className="section-header">Region-Specific Files</div>
|
| 152 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 153 |
{[
|
| 154 |
+
{ file: 'config.py', desc: 'Where your zones are, how many workers, heat risk levels, and payout amounts' },
|
| 155 |
+
{ file: 'src/healing/healer.py', desc: 'What normal temperatures look like in your cities, month by month' },
|
| 156 |
+
{ file: 'src/explanation/knowledge_base.py', desc: 'Local heat safety advice, emergency contacts, and alert language translations' },
|
| 157 |
+
{ file: 'src/calibration/basis_risk.py', desc: 'How much hotter neighborhoods are than surrounding areas, and outdoor work patterns' },
|
| 158 |
].map(item => (
|
| 159 |
<div key={item.file} style={{
|
| 160 |
background: '#fff', border: '1px solid #e0dcd5', borderRadius: '8px',
|
|
|
|
| 172 |
<div className="section-header">Globally Portable (No Changes Needed)</div>
|
| 173 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 174 |
{[
|
| 175 |
+
{ file: 'src/ingestion/nasa_power.py', desc: 'Works anywhere on earth — just provide coordinates' },
|
| 176 |
+
{ file: 'src/indexing/heat_index.py', desc: 'Heat stress and feels-like temperature calculations work everywhere' },
|
| 177 |
+
{ file: 'src/indexing/heat_risk.py', desc: 'Danger scoring that adjusts to your settings' },
|
| 178 |
+
{ file: 'src/notification/sender.py', desc: 'Test mode, text message, or WhatsApp delivery' },
|
| 179 |
].map(item => (
|
| 180 |
<div key={item.file} style={{
|
| 181 |
background: '#fff', border: '1px solid #e0dcd5', borderRadius: '8px',
|
|
|
|
| 210 |
const runsData = runs.data?.runs ?? []
|
| 211 |
const s = stats.data
|
| 212 |
|
| 213 |
+
const TABS = ['Architecture', 'Update History', 'Deploy to Your Region']
|
| 214 |
|
| 215 |
return (
|
| 216 |
<div data-tour="pipeline-title" className="animate-slide-up">
|
| 217 |
<div className="pt-2 pb-6">
|
| 218 |
+
<h1 className="page-title">How It Works</h1>
|
| 219 |
<p className="page-caption">
|
| 220 |
+
Six automated steps turn satellite data into worker payouts
|
| 221 |
</p>
|
| 222 |
</div>
|
| 223 |
|
|
|
|
| 225 |
<div className="mb-8">
|
| 226 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-stagger">
|
| 227 |
<MetricCard
|
| 228 |
+
label="Total Updates"
|
| 229 |
value={s?.total_runs}
|
| 230 |
subtitle={`${s?.successful_runs} successful`}
|
| 231 |
/>
|
| 232 |
<MetricCard
|
| 233 |
+
label="Reliability"
|
| 234 |
value={`${Math.round((s?.success_rate ?? 0) * 100)}%`}
|
| 235 |
+
subtitle="of updates completed"
|
| 236 |
/>
|
| 237 |
<MetricCard
|
| 238 |
label="Total Cost"
|
|
|
|
| 275 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 276 |
<div>
|
| 277 |
<p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-1">
|
| 278 |
+
Total System Cost
|
| 279 |
</p>
|
| 280 |
<p className="text-lg font-serif font-bold text-[#1a1a1a]">
|
| 281 |
${s?.total_cost_usd?.toFixed(2)}
|
|
|
|
| 283 |
</div>
|
| 284 |
<div>
|
| 285 |
<p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-1">
|
| 286 |
+
Average per Update
|
| 287 |
</p>
|
| 288 |
<p className="text-lg font-serif font-bold text-[#1a1a1a]">
|
| 289 |
${s?.avg_cost_per_run_usd?.toFixed(4)}
|
|
|
|
| 291 |
</div>
|
| 292 |
<div>
|
| 293 |
<p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-1">
|
| 294 |
+
Last Update
|
| 295 |
</p>
|
| 296 |
<p className="text-lg font-serif font-bold text-[#1a1a1a]">
|
| 297 |
{s?.last_run ? new Date(s.last_run).toLocaleDateString() : '--'}
|
|
|
|
| 301 |
</div>
|
| 302 |
|
| 303 |
{/* Run History */}
|
| 304 |
+
<div className="section-header">Update History</div>
|
| 305 |
<div className="table-container">
|
| 306 |
<table>
|
| 307 |
<thead>
|
|
|
|
| 312 |
<th>Status</th>
|
| 313 |
<th>Duration</th>
|
| 314 |
<th>Zones</th>
|
| 315 |
+
<th>Alerts</th>
|
| 316 |
+
<th>Messages</th>
|
| 317 |
<th>Cost (USD)</th>
|
| 318 |
</tr>
|
| 319 |
</thead>
|
|
|
|
| 344 |
<td colSpan={9} className="bg-warm-header-bg !py-0 !px-0">
|
| 345 |
<div className="px-6 py-4">
|
| 346 |
<p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-3">
|
| 347 |
+
Processing Steps
|
| 348 |
</p>
|
| 349 |
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
| 350 |
{run.steps.map((step, i) => (
|
frontend/src/pages/ProgramDesigner.tsx
CHANGED
|
@@ -56,7 +56,7 @@ export default function ProgramDesigner() {
|
|
| 56 |
<div className="pt-2 pb-6">
|
| 57 |
<h1 className="page-title">Program Design</h1>
|
| 58 |
<p className="page-caption">
|
| 59 |
-
Set budget
|
| 60 |
</p>
|
| 61 |
</div>
|
| 62 |
|
|
@@ -92,7 +92,7 @@ export default function ProgramDesigner() {
|
|
| 92 |
<div>
|
| 93 |
<div className="flex items-center justify-between mb-1.5">
|
| 94 |
<label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
|
| 95 |
-
Payout
|
| 96 |
</label>
|
| 97 |
<span className="font-mono font-semibold text-sm text-[#1a1a1a]">
|
| 98 |
${params.payout_usd}
|
|
@@ -117,7 +117,7 @@ export default function ProgramDesigner() {
|
|
| 117 |
<div>
|
| 118 |
<div className="flex items-center justify-between mb-1.5">
|
| 119 |
<label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
|
| 120 |
-
Worker
|
| 121 |
</label>
|
| 122 |
<span className="font-mono font-semibold text-sm text-[#1a1a1a]">
|
| 123 |
${params.worker_contribution_usd}
|
|
@@ -142,7 +142,7 @@ export default function ProgramDesigner() {
|
|
| 142 |
<div>
|
| 143 |
<div className="flex items-center justify-between mb-1.5">
|
| 144 |
<label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
|
| 145 |
-
|
| 146 |
</label>
|
| 147 |
<span className="font-mono font-semibold text-sm text-[#1a1a1a]">
|
| 148 |
{params.temp_threshold}°C
|
|
@@ -167,7 +167,7 @@ export default function ProgramDesigner() {
|
|
| 167 |
<div>
|
| 168 |
<div className="flex items-center justify-between mb-1.5">
|
| 169 |
<label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
|
| 170 |
-
|
| 171 |
</label>
|
| 172 |
<span className="font-mono font-semibold text-sm text-[#1a1a1a]">
|
| 173 |
{params.consecutive_days}
|
|
@@ -193,7 +193,7 @@ export default function ProgramDesigner() {
|
|
| 193 |
{/* Results */}
|
| 194 |
<div data-tour="program-results">
|
| 195 |
{calibrate.isLoading ? (
|
| 196 |
-
<LoadingSpinner message="
|
| 197 |
) : calibrate.isError ? (
|
| 198 |
<ErrorState onRetry={() => calibrate.refetch()} />
|
| 199 |
) : (
|
|
@@ -218,7 +218,7 @@ export default function ProgramDesigner() {
|
|
| 218 |
<MetricCard
|
| 219 |
label="Avg Cost / Worker"
|
| 220 |
value={`$${summary?.avg_cost_per_worker?.toFixed(2) ?? '--'}`}
|
| 221 |
-
subtitle="annual
|
| 222 |
/>
|
| 223 |
</div>
|
| 224 |
|
|
@@ -230,12 +230,12 @@ export default function ProgramDesigner() {
|
|
| 230 |
<th>Rank</th>
|
| 231 |
<th>Zone</th>
|
| 232 |
<th>City</th>
|
| 233 |
-
<th>
|
| 234 |
<th>Allocated Budget</th>
|
| 235 |
<th>Workers Covered</th>
|
| 236 |
<th>Coverage %</th>
|
| 237 |
-
<th>
|
| 238 |
-
<th>
|
| 239 |
</tr>
|
| 240 |
</thead>
|
| 241 |
<tbody>
|
|
|
|
| 56 |
<div className="pt-2 pb-6">
|
| 57 |
<h1 className="page-title">Program Design</h1>
|
| 58 |
<p className="page-caption">
|
| 59 |
+
Set your budget and payout amounts, then see how many workers get covered across each zone
|
| 60 |
</p>
|
| 61 |
</div>
|
| 62 |
|
|
|
|
| 92 |
<div>
|
| 93 |
<div className="flex items-center justify-between mb-1.5">
|
| 94 |
<label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
|
| 95 |
+
Payout per Alert
|
| 96 |
</label>
|
| 97 |
<span className="font-mono font-semibold text-sm text-[#1a1a1a]">
|
| 98 |
${params.payout_usd}
|
|
|
|
| 117 |
<div>
|
| 118 |
<div className="flex items-center justify-between mb-1.5">
|
| 119 |
<label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
|
| 120 |
+
Worker Contribution
|
| 121 |
</label>
|
| 122 |
<span className="font-mono font-semibold text-sm text-[#1a1a1a]">
|
| 123 |
${params.worker_contribution_usd}
|
|
|
|
| 142 |
<div>
|
| 143 |
<div className="flex items-center justify-between mb-1.5">
|
| 144 |
<label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
|
| 145 |
+
Danger Temperature
|
| 146 |
</label>
|
| 147 |
<span className="font-mono font-semibold text-sm text-[#1a1a1a]">
|
| 148 |
{params.temp_threshold}°C
|
|
|
|
| 167 |
<div>
|
| 168 |
<div className="flex items-center justify-between mb-1.5">
|
| 169 |
<label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
|
| 170 |
+
Consecutive Hot Days
|
| 171 |
</label>
|
| 172 |
<span className="font-mono font-semibold text-sm text-[#1a1a1a]">
|
| 173 |
{params.consecutive_days}
|
|
|
|
| 193 |
{/* Results */}
|
| 194 |
<div data-tour="program-results">
|
| 195 |
{calibrate.isLoading ? (
|
| 196 |
+
<LoadingSpinner message="Calculating costs and coverage..." />
|
| 197 |
) : calibrate.isError ? (
|
| 198 |
<ErrorState onRetry={() => calibrate.refetch()} />
|
| 199 |
) : (
|
|
|
|
| 218 |
<MetricCard
|
| 219 |
label="Avg Cost / Worker"
|
| 220 |
value={`$${summary?.avg_cost_per_worker?.toFixed(2) ?? '--'}`}
|
| 221 |
+
subtitle="annual cost"
|
| 222 |
/>
|
| 223 |
</div>
|
| 224 |
|
|
|
|
| 230 |
<th>Rank</th>
|
| 231 |
<th>Zone</th>
|
| 232 |
<th>City</th>
|
| 233 |
+
<th>Cost per Worker</th>
|
| 234 |
<th>Allocated Budget</th>
|
| 235 |
<th>Workers Covered</th>
|
| 236 |
<th>Coverage %</th>
|
| 237 |
+
<th>Danger Days/Year</th>
|
| 238 |
+
<th>Coverage Gap</th>
|
| 239 |
</tr>
|
| 240 |
</thead>
|
| 241 |
<tbody>
|
frontend/src/pages/Zones.tsx
CHANGED
|
@@ -59,9 +59,9 @@ export default function Zones() {
|
|
| 59 |
subtitle="estimated outdoor workers"
|
| 60 |
/>
|
| 61 |
<MetricCard
|
| 62 |
-
label="High
|
| 63 |
value={allZones.filter((z) => z.heat_vulnerability === 'high' || z.heat_vulnerability === 'critical').length}
|
| 64 |
-
subtitle="
|
| 65 |
/>
|
| 66 |
<MetricCard
|
| 67 |
label="Workers Enrolled"
|
|
@@ -117,14 +117,14 @@ export default function Zones() {
|
|
| 117 |
<tr>
|
| 118 |
<th>Zone</th>
|
| 119 |
<th>City</th>
|
| 120 |
-
<th>
|
| 121 |
-
<th>Heat
|
| 122 |
<th>Workers</th>
|
| 123 |
-
<th>
|
| 124 |
-
<th>
|
| 125 |
-
<th>
|
| 126 |
-
<th>
|
| 127 |
-
<th>
|
| 128 |
<th>Risk Level</th>
|
| 129 |
</tr>
|
| 130 |
</thead>
|
|
|
|
| 59 |
subtitle="estimated outdoor workers"
|
| 60 |
/>
|
| 61 |
<MetricCard
|
| 62 |
+
label="High Risk Zones"
|
| 63 |
value={allZones.filter((z) => z.heat_vulnerability === 'high' || z.heat_vulnerability === 'critical').length}
|
| 64 |
+
subtitle="most vulnerable areas"
|
| 65 |
/>
|
| 66 |
<MetricCard
|
| 67 |
label="Workers Enrolled"
|
|
|
|
| 117 |
<tr>
|
| 118 |
<th>Zone</th>
|
| 119 |
<th>City</th>
|
| 120 |
+
<th>Area Type</th>
|
| 121 |
+
<th>Heat Risk</th>
|
| 122 |
<th>Workers</th>
|
| 123 |
+
<th>Satellite Temp</th>
|
| 124 |
+
<th>City Heat Effect</th>
|
| 125 |
+
<th>Adjusted Temp</th>
|
| 126 |
+
<th>Heat Stress (°C)</th>
|
| 127 |
+
<th>7-Day Danger</th>
|
| 128 |
<th>Risk Level</th>
|
| 129 |
</tr>
|
| 130 |
</thead>
|
models/faiss_index/bm25.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8645eea4f69a755b8e1d304c2490c284e5cfd7a65e9c1a165b6e95abc7a0cef3
|
| 3 |
+
size 26078
|
models/faiss_index/corpus.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:62fb21e3e2c00639b0011e81ff949ab1eea8bd0c2a9353e2063c14822680e91c
|
| 3 |
+
size 11579
|
models/faiss_index/index.faiss
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:01f39d31409997d00a20e98a884150ca1ff19357ce000b6496763a3e27b8183f
|
| 3 |
+
size 135213
|
models/heat_lstm.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:daca55983ad2c6f10f8ac68328ab0e508e8a1380afd6db2f69cb09dd13905ef1
|
| 3 |
+
size 211173
|
models/lstm_norm.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mean": [
|
| 3 |
+
28.068910598754883,
|
| 4 |
+
65.23885345458984,
|
| 5 |
+
3.061713695526123,
|
| 6 |
+
29.787622451782227,
|
| 7 |
+
31.6322021484375,
|
| 8 |
+
-0.03376865014433861
|
| 9 |
+
],
|
| 10 |
+
"std": [
|
| 11 |
+
4.098460674285889,
|
| 12 |
+
9.93588924407959,
|
| 13 |
+
1.0045255422592163,
|
| 14 |
+
4.900587558746338,
|
| 15 |
+
8.475600242614746,
|
| 16 |
+
1.8429570198059082
|
| 17 |
+
]
|
| 18 |
+
}
|
requirements.txt
CHANGED
|
@@ -8,3 +8,13 @@ xgboost>=2.0.0
|
|
| 8 |
scikit-learn>=1.4.0
|
| 9 |
asyncpg>=0.30.0
|
| 10 |
aiofiles>=24.1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
scikit-learn>=1.4.0
|
| 9 |
asyncpg>=0.30.0
|
| 10 |
aiofiles>=24.1.0
|
| 11 |
+
# ERA5 data access
|
| 12 |
+
gcsfs>=2024.2.0
|
| 13 |
+
xarray>=2024.1.0
|
| 14 |
+
zarr>=2.16.0
|
| 15 |
+
pyarrow>=15.0.0
|
| 16 |
+
# RAG
|
| 17 |
+
faiss-cpu>=1.9.0
|
| 18 |
+
sentence-transformers>=2.7.0
|
| 19 |
+
# torch is installed separately via --index-url https://download.pytorch.org/whl/cpu
|
| 20 |
+
# in the Dockerfile to get the CPU-only build (~200MB vs ~2GB)
|
scripts/build_rag_index.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Build the FAISS + BM25 RAG index from the knowledge base.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python3 scripts/build_rag_index.py
|
| 6 |
+
|
| 7 |
+
Run this before starting the app, or include it in the Dockerfile.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
# Ensure project root is on sys.path
|
| 15 |
+
project_root = Path(__file__).resolve().parents[1]
|
| 16 |
+
sys.path.insert(0, str(project_root))
|
| 17 |
+
|
| 18 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
| 19 |
+
|
| 20 |
+
from src.explanation.rag_index_builder import build_index
|
| 21 |
+
|
| 22 |
+
index, corpus, bm25 = build_index(force_rebuild=True)
|
| 23 |
+
print(f"RAG index built successfully: {len(corpus)} documents, {index.ntotal} vectors")
|
scripts/train_lstm.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train the LSTM heat wave predictor.
|
| 2 |
+
|
| 3 |
+
Tries ERA5 data first, falls back to synthetic data generation
|
| 4 |
+
(same seasonal + AR(1) approach as the existing XGBoost trainer).
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python3 scripts/train_lstm.py
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
import time
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, ".")
|
| 14 |
+
|
| 15 |
+
from src.prediction.lstm_model import LSTMTrainer, generate_synthetic_zone_data
|
| 16 |
+
from config import ZONES
|
| 17 |
+
|
| 18 |
+
print("=" * 60)
|
| 19 |
+
print("LSTM Heat Wave Predictor -- Training")
|
| 20 |
+
print("=" * 60)
|
| 21 |
+
|
| 22 |
+
# Try ERA5 data first, fall back to synthetic
|
| 23 |
+
zone_data = None
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from src.ingestion.era5_fetcher import fetch_era5_sync
|
| 27 |
+
|
| 28 |
+
print("\nFetching ERA5 data for training...")
|
| 29 |
+
raw = fetch_era5_sync(ZONES, days_back=365)
|
| 30 |
+
# Convert to training format if fetch succeeds
|
| 31 |
+
if raw and len(raw) > 0:
|
| 32 |
+
zone_data = raw
|
| 33 |
+
print(f" Loaded ERA5 data for {len(zone_data)} zones")
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"\nERA5 unavailable ({e}), using synthetic training data")
|
| 36 |
+
|
| 37 |
+
if zone_data is None:
|
| 38 |
+
print("\nGenerating synthetic training data (2 years x 20 zones)...")
|
| 39 |
+
t0 = time.time()
|
| 40 |
+
zone_data = generate_synthetic_zone_data(ZONES, n_days=730, seed=42)
|
| 41 |
+
elapsed = time.time() - t0
|
| 42 |
+
total_days = sum(len(v) for v in zone_data.values())
|
| 43 |
+
print(f" Generated {total_days:,} zone-days in {elapsed:.1f}s")
|
| 44 |
+
|
| 45 |
+
# Train
|
| 46 |
+
print("\nTraining LSTM...")
|
| 47 |
+
t0 = time.time()
|
| 48 |
+
trainer = LSTMTrainer(epochs=50, patience=5)
|
| 49 |
+
metrics = trainer.train(zone_data)
|
| 50 |
+
elapsed = time.time() - t0
|
| 51 |
+
|
| 52 |
+
print(f"\nTraining complete in {elapsed:.1f}s")
|
| 53 |
+
print(f" Epochs trained: {metrics.get('epochs_trained', '?')}")
|
| 54 |
+
print(f" Train loss: {metrics.get('train_loss', '?')}")
|
| 55 |
+
print(f" Val loss: {metrics.get('val_loss', '?')}")
|
| 56 |
+
print(f" Val AUROC: {metrics.get('val_auroc', '?')}")
|
| 57 |
+
print(f" Samples: {metrics.get('samples', '?')}")
|
| 58 |
+
print("=" * 60)
|
src/api.py
CHANGED
|
@@ -7,6 +7,7 @@ When the real pipeline has been run, serves pipeline results instead.
|
|
| 7 |
|
| 8 |
import logging
|
| 9 |
import random
|
|
|
|
| 10 |
from datetime import datetime, timedelta
|
| 11 |
from pathlib import Path
|
| 12 |
|
|
@@ -21,10 +22,24 @@ from src.downscaling.uhi_model import UHICorrector
|
|
| 21 |
from src.prediction.heat_forecast import HeatWavePredictor
|
| 22 |
from src.pricing.actuarial import ActuarialPricer
|
| 23 |
from src.pricing.budget_optimizer import BudgetOptimizer
|
|
|
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
app.add_middleware(
|
| 30 |
CORSMiddleware,
|
|
@@ -333,7 +348,16 @@ def _generate_demo_data():
|
|
| 333 |
}
|
| 334 |
|
| 335 |
|
| 336 |
-
_demo =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
# Singletons for calibrate endpoint (avoid re-instantiation per request)
|
| 339 |
_actuarial_pricer = ActuarialPricer()
|
|
@@ -349,17 +373,17 @@ def health():
|
|
| 349 |
|
| 350 |
@app.get("/api/zones")
|
| 351 |
def get_zones():
|
| 352 |
-
return {"zones":
|
| 353 |
|
| 354 |
|
| 355 |
@app.get("/api/indices")
|
| 356 |
def get_indices():
|
| 357 |
-
return {"indices":
|
| 358 |
|
| 359 |
|
| 360 |
@app.get("/api/triggers")
|
| 361 |
def get_triggers():
|
| 362 |
-
triggers =
|
| 363 |
return {
|
| 364 |
"triggers": triggers,
|
| 365 |
"total": len(triggers),
|
|
@@ -373,7 +397,7 @@ def get_triggers():
|
|
| 373 |
|
| 374 |
@app.get("/api/basis-risk")
|
| 375 |
def get_basis_risk():
|
| 376 |
-
br =
|
| 377 |
return {
|
| 378 |
"assessments": br,
|
| 379 |
"total": len(br),
|
|
@@ -383,7 +407,7 @@ def get_basis_risk():
|
|
| 383 |
|
| 384 |
@app.get("/api/notifications")
|
| 385 |
def get_notifications():
|
| 386 |
-
notifs =
|
| 387 |
return {
|
| 388 |
"notifications": notifs,
|
| 389 |
"total": len(notifs),
|
|
@@ -398,19 +422,19 @@ def get_notifications():
|
|
| 398 |
def get_enrolled():
|
| 399 |
by_zone = [
|
| 400 |
{"zone_id": z["zone_id"], "zone_name": z["name"], "city": z["city"], "enrolled": z["enrolled_workers"]}
|
| 401 |
-
for z in
|
| 402 |
]
|
| 403 |
-
return {"by_zone": by_zone, "total_enrolled": sum(z["enrolled_workers"] for z in
|
| 404 |
|
| 405 |
|
| 406 |
@app.get("/api/pipeline/runs")
|
| 407 |
def get_pipeline_runs():
|
| 408 |
-
return {"runs":
|
| 409 |
|
| 410 |
|
| 411 |
@app.get("/api/pipeline/stats")
|
| 412 |
def get_pipeline_stats():
|
| 413 |
-
return
|
| 414 |
|
| 415 |
|
| 416 |
@app.get("/api/calibrate")
|
|
@@ -433,10 +457,10 @@ def calibrate(
|
|
| 433 |
total_annual_cost = 0.0
|
| 434 |
zones_triggered = 0
|
| 435 |
|
| 436 |
-
zones_by_id = {z["zone_id"]: z for z in
|
| 437 |
-
basis_by_id = {b["zone_id"]: b for b in
|
| 438 |
|
| 439 |
-
for idx_data in
|
| 440 |
zone_id = idx_data["zone_id"]
|
| 441 |
zone = ZONE_MAP.get(zone_id)
|
| 442 |
if not zone:
|
|
|
|
| 7 |
|
| 8 |
import logging
|
| 9 |
import random
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
from datetime import datetime, timedelta
|
| 12 |
from pathlib import Path
|
| 13 |
|
|
|
|
| 22 |
from src.prediction.heat_forecast import HeatWavePredictor
|
| 23 |
from src.pricing.actuarial import ActuarialPricer
|
| 24 |
from src.pricing.budget_optimizer import BudgetOptimizer
|
| 25 |
+
from src.database.crud import PgConnection
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
+
# Database connection (reads DATABASE_URL from env; falls back to in-memory)
|
| 30 |
+
db = PgConnection()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@asynccontextmanager
|
| 34 |
+
async def lifespan(app: FastAPI):
|
| 35 |
+
await db.connect()
|
| 36 |
+
await db.init_schema()
|
| 37 |
+
logger.info("Database ready (postgres=%s)", db.is_postgres)
|
| 38 |
+
yield
|
| 39 |
+
await db.close()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
app = FastAPI(title="Extreme Heat Risk Engine", version="1.0.0", lifespan=lifespan)
|
| 43 |
|
| 44 |
app.add_middleware(
|
| 45 |
CORSMiddleware,
|
|
|
|
| 348 |
}
|
| 349 |
|
| 350 |
|
| 351 |
+
_demo = None
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _get_demo():
|
| 355 |
+
"""Lazy initialization of demo data — only generated on first API request."""
|
| 356 |
+
global _demo
|
| 357 |
+
if _demo is None:
|
| 358 |
+
_demo = _generate_demo_data()
|
| 359 |
+
return _demo
|
| 360 |
+
|
| 361 |
|
| 362 |
# Singletons for calibrate endpoint (avoid re-instantiation per request)
|
| 363 |
_actuarial_pricer = ActuarialPricer()
|
|
|
|
| 373 |
|
| 374 |
@app.get("/api/zones")
|
| 375 |
def get_zones():
|
| 376 |
+
return {"zones": _get_demo()["zones"], "total": len(_get_demo()["zones"]), "cities": CITIES}
|
| 377 |
|
| 378 |
|
| 379 |
@app.get("/api/indices")
|
| 380 |
def get_indices():
|
| 381 |
+
return {"indices": _get_demo()["indices"], "total": len(_get_demo()["indices"])}
|
| 382 |
|
| 383 |
|
| 384 |
@app.get("/api/triggers")
|
| 385 |
def get_triggers():
|
| 386 |
+
triggers = _get_demo()["triggers"]
|
| 387 |
return {
|
| 388 |
"triggers": triggers,
|
| 389 |
"total": len(triggers),
|
|
|
|
| 397 |
|
| 398 |
@app.get("/api/basis-risk")
|
| 399 |
def get_basis_risk():
|
| 400 |
+
br = _get_demo()["basis_risk"]
|
| 401 |
return {
|
| 402 |
"assessments": br,
|
| 403 |
"total": len(br),
|
|
|
|
| 407 |
|
| 408 |
@app.get("/api/notifications")
|
| 409 |
def get_notifications():
|
| 410 |
+
notifs = _get_demo()["notifications"]
|
| 411 |
return {
|
| 412 |
"notifications": notifs,
|
| 413 |
"total": len(notifs),
|
|
|
|
| 422 |
def get_enrolled():
|
| 423 |
by_zone = [
|
| 424 |
{"zone_id": z["zone_id"], "zone_name": z["name"], "city": z["city"], "enrolled": z["enrolled_workers"]}
|
| 425 |
+
for z in _get_demo()["zones"]
|
| 426 |
]
|
| 427 |
+
return {"by_zone": by_zone, "total_enrolled": sum(z["enrolled_workers"] for z in _get_demo()["zones"])}
|
| 428 |
|
| 429 |
|
| 430 |
@app.get("/api/pipeline/runs")
|
| 431 |
def get_pipeline_runs():
|
| 432 |
+
return {"runs": _get_demo()["pipeline_runs"], "total": len(_get_demo()["pipeline_runs"])}
|
| 433 |
|
| 434 |
|
| 435 |
@app.get("/api/pipeline/stats")
|
| 436 |
def get_pipeline_stats():
|
| 437 |
+
return _get_demo()["stats"]
|
| 438 |
|
| 439 |
|
| 440 |
@app.get("/api/calibrate")
|
|
|
|
| 457 |
total_annual_cost = 0.0
|
| 458 |
zones_triggered = 0
|
| 459 |
|
| 460 |
+
zones_by_id = {z["zone_id"]: z for z in _get_demo()["zones"]}
|
| 461 |
+
basis_by_id = {b["zone_id"]: b for b in _get_demo()["basis_risk"]}
|
| 462 |
|
| 463 |
+
for idx_data in _get_demo()["indices"]:
|
| 464 |
zone_id = idx_data["zone_id"]
|
| 465 |
zone = ZONE_MAP.get(zone_id)
|
| 466 |
if not zone:
|
src/database/crud.py
CHANGED
|
@@ -12,9 +12,8 @@ import logging
|
|
| 12 |
import os
|
| 13 |
import uuid
|
| 14 |
from collections import defaultdict
|
| 15 |
-
from dataclasses import asdict, dataclass, field
|
| 16 |
from datetime import datetime, timezone
|
| 17 |
-
from typing import Any, Dict, List, Optional
|
| 18 |
|
| 19 |
from src.database.schema import get_full_ddl, get_table_names
|
| 20 |
|
|
@@ -143,6 +142,42 @@ class InMemoryStore:
|
|
| 143 |
def count(self, table: str) -> int:
|
| 144 |
return len(self.tables.get(table, []))
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
# ── CRUD functions ───────────────────────────────────────────────────────
|
| 148 |
|
|
@@ -155,32 +190,30 @@ async def upsert_zone(db: PgConnection, zone_data: dict) -> None:
|
|
| 155 |
"""
|
| 156 |
INSERT INTO zones (zone_id, name, city, country, latitude, longitude,
|
| 157 |
elevation_m, area_km2, population_est, settlement_type,
|
| 158 |
-
|
| 159 |
-
|
| 160 |
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
| 161 |
ON CONFLICT (zone_id) DO UPDATE SET
|
| 162 |
name = EXCLUDED.name,
|
| 163 |
population_est = EXCLUDED.population_est,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
notes = EXCLUDED.notes
|
| 165 |
""",
|
| 166 |
zone_data["zone_id"], zone_data["name"], zone_data["city"],
|
| 167 |
zone_data["country"], zone_data["latitude"], zone_data["longitude"],
|
| 168 |
zone_data.get("elevation_m"), zone_data.get("area_km2"),
|
| 169 |
zone_data.get("population_est"), zone_data["settlement_type"],
|
| 170 |
-
zone_data
|
| 171 |
-
zone_data.get("
|
|
|
|
|
|
|
| 172 |
zone_data.get("notes", ""),
|
| 173 |
)
|
| 174 |
elif db._memory:
|
| 175 |
-
|
| 176 |
-
existing = [
|
| 177 |
-
r for r in db._memory.tables["zones"]
|
| 178 |
-
if r["zone_id"] == zone_data["zone_id"]
|
| 179 |
-
]
|
| 180 |
-
if existing:
|
| 181 |
-
existing[0].update(zone_data)
|
| 182 |
-
else:
|
| 183 |
-
db._memory.insert("zones", zone_data)
|
| 184 |
|
| 185 |
|
| 186 |
async def get_zone(db: PgConnection, zone_id: str) -> Optional[dict]:
|
|
@@ -209,21 +242,21 @@ async def insert_daily_reading(db: PgConnection, reading: dict) -> Optional[int]
|
|
| 209 |
if db._pool:
|
| 210 |
return await db.fetchval(
|
| 211 |
"""
|
| 212 |
-
INSERT INTO daily_readings (zone_id, date,
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
|
| 216 |
ON CONFLICT (zone_id, date) DO UPDATE SET
|
| 217 |
-
|
| 218 |
data_quality = EXCLUDED.data_quality
|
| 219 |
RETURNING id
|
| 220 |
""",
|
| 221 |
reading["zone_id"], reading["date"],
|
| 222 |
-
reading.get("
|
| 223 |
-
reading.get("
|
| 224 |
-
reading.get("
|
| 225 |
-
reading.get("
|
| 226 |
-
reading.get("source", "unknown"),
|
| 227 |
reading.get("data_quality", 0.0),
|
| 228 |
)
|
| 229 |
elif db._memory:
|
|
@@ -254,16 +287,16 @@ async def insert_healed_reading(db: PgConnection, reading: dict) -> Optional[int
|
|
| 254 |
return await db.fetchval(
|
| 255 |
"""
|
| 256 |
INSERT INTO healed_readings (zone_id, date, raw_reading_id,
|
| 257 |
-
|
| 258 |
humidity_pct, wind_speed_ms, quality_score, heal_action, fields_corrected)
|
| 259 |
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
|
| 260 |
ON CONFLICT (zone_id, date) DO UPDATE SET
|
| 261 |
quality_score = EXCLUDED.quality_score,
|
| 262 |
heal_action = EXCLUDED.heal_action
|
| 263 |
RETURNING id
|
| 264 |
""",
|
| 265 |
reading["zone_id"], reading["date"], reading.get("raw_reading_id"),
|
| 266 |
-
reading.get("
|
| 267 |
reading.get("temp_max_c"), reading.get("temp_min_c"),
|
| 268 |
reading.get("humidity_pct"), reading.get("wind_speed_ms"),
|
| 269 |
reading.get("quality_score", 0.0),
|
|
@@ -300,78 +333,94 @@ async def insert_healing_log(db: PgConnection, entry: dict) -> Optional[int]:
|
|
| 300 |
return None
|
| 301 |
|
| 302 |
|
| 303 |
-
# ---
|
| 304 |
|
| 305 |
-
async def
|
| 306 |
-
"""Insert
|
| 307 |
if db._pool:
|
| 308 |
-
await db.
|
| 309 |
"""
|
| 310 |
-
INSERT INTO
|
| 311 |
-
|
|
|
|
| 312 |
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
| 313 |
-
ON CONFLICT (zone_id,
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
total_precip_mm = EXCLUDED.total_precip_mm,
|
| 318 |
-
computed_at = NOW()
|
| 319 |
""",
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
|
|
|
| 324 |
)
|
| 325 |
elif db._memory:
|
| 326 |
-
db._memory.
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
|
| 329 |
-
async def
|
| 330 |
-
db: PgConnection, zone_id: str, limit: int =
|
| 331 |
) -> list[dict]:
|
| 332 |
-
"""Fetch recent
|
| 333 |
if db._pool:
|
| 334 |
return await db.fetch(
|
| 335 |
-
""
|
| 336 |
-
SELECT * FROM monthly_indices
|
| 337 |
-
WHERE zone_id = $1
|
| 338 |
-
ORDER BY year DESC, month DESC
|
| 339 |
-
LIMIT $2
|
| 340 |
-
""",
|
| 341 |
zone_id, limit,
|
| 342 |
)
|
| 343 |
elif db._memory:
|
| 344 |
-
return db._memory.
|
| 345 |
return []
|
| 346 |
|
| 347 |
|
| 348 |
-
# ---
|
| 349 |
|
| 350 |
-
async def
|
| 351 |
-
"""Insert a daily
|
| 352 |
if db._pool:
|
| 353 |
return await db.fetchval(
|
| 354 |
"""
|
| 355 |
-
INSERT INTO
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8
|
| 359 |
ON CONFLICT (zone_id, date) DO UPDATE SET
|
| 360 |
-
|
| 361 |
-
|
| 362 |
RETURNING id
|
| 363 |
""",
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
| 369 |
)
|
| 370 |
elif db._memory:
|
| 371 |
-
return db._memory.
|
|
|
|
|
|
|
| 372 |
return None
|
| 373 |
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
# --- Trigger events ---
|
| 376 |
|
| 377 |
async def insert_trigger_event(db: PgConnection, event: dict) -> Optional[int]:
|
|
@@ -380,15 +429,17 @@ async def insert_trigger_event(db: PgConnection, event: dict) -> Optional[int]:
|
|
| 380 |
return await db.fetchval(
|
| 381 |
"""
|
| 382 |
INSERT INTO trigger_events (zone_id, trigger_level, triggered_at,
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
| 386 |
RETURNING id
|
| 387 |
""",
|
| 388 |
event["zone_id"], event["trigger_level"], event["triggered_at"],
|
| 389 |
-
event.get("
|
| 390 |
-
event.get("
|
| 391 |
-
event.get("
|
|
|
|
| 392 |
)
|
| 393 |
elif db._memory:
|
| 394 |
return db._memory.insert("trigger_events", event)
|
|
@@ -534,51 +585,6 @@ async def get_notifications(
|
|
| 534 |
return []
|
| 535 |
|
| 536 |
|
| 537 |
-
# --- Enrolled policies ---
|
| 538 |
-
|
| 539 |
-
async def insert_policy(db: PgConnection, policy: dict) -> Optional[int]:
|
| 540 |
-
"""Insert an insurance policy."""
|
| 541 |
-
if db._pool:
|
| 542 |
-
return await db.fetchval(
|
| 543 |
-
"""
|
| 544 |
-
INSERT INTO enrolled_policies (policy_number, zone_id, holder_name,
|
| 545 |
-
holder_phone, holder_email, settlement_type, premium_kes,
|
| 546 |
-
coverage_start, coverage_end, payment_method)
|
| 547 |
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
| 548 |
-
ON CONFLICT (policy_number) DO NOTHING
|
| 549 |
-
RETURNING id
|
| 550 |
-
""",
|
| 551 |
-
policy.get("policy_number", f"POL-{uuid.uuid4().hex[:8].upper()}"),
|
| 552 |
-
policy["zone_id"], policy["holder_name"],
|
| 553 |
-
policy.get("holder_phone"), policy.get("holder_email"),
|
| 554 |
-
policy["settlement_type"], policy.get("premium_kes"),
|
| 555 |
-
policy["coverage_start"], policy["coverage_end"],
|
| 556 |
-
policy.get("payment_method", "mpesa"),
|
| 557 |
-
)
|
| 558 |
-
elif db._memory:
|
| 559 |
-
return db._memory.insert("enrolled_policies", policy)
|
| 560 |
-
return None
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
async def get_policies_by_zone(db: PgConnection, zone_id: str) -> list[dict]:
|
| 564 |
-
"""Fetch active policies for a zone."""
|
| 565 |
-
if db._pool:
|
| 566 |
-
return await db.fetch(
|
| 567 |
-
"""
|
| 568 |
-
SELECT * FROM enrolled_policies
|
| 569 |
-
WHERE zone_id = $1 AND is_active = TRUE
|
| 570 |
-
ORDER BY holder_name
|
| 571 |
-
""",
|
| 572 |
-
zone_id,
|
| 573 |
-
)
|
| 574 |
-
elif db._memory:
|
| 575 |
-
return [
|
| 576 |
-
r for r in db._memory.query("enrolled_policies", {"zone_id": zone_id}, limit=1000)
|
| 577 |
-
if r.get("is_active", True)
|
| 578 |
-
]
|
| 579 |
-
return []
|
| 580 |
-
|
| 581 |
-
|
| 582 |
# --- Pipeline runs ---
|
| 583 |
|
| 584 |
async def start_pipeline_run(db: PgConnection, run_id: Optional[str] = None) -> str:
|
|
|
|
| 12 |
import os
|
| 13 |
import uuid
|
| 14 |
from collections import defaultdict
|
|
|
|
| 15 |
from datetime import datetime, timezone
|
| 16 |
+
from typing import Any, Dict, List, Optional
|
| 17 |
|
| 18 |
from src.database.schema import get_full_ddl, get_table_names
|
| 19 |
|
|
|
|
| 142 |
def count(self, table: str) -> int:
|
| 143 |
return len(self.tables.get(table, []))
|
| 144 |
|
| 145 |
+
# ── Convenience helpers (mirror async CRUD API for easy testing) ──
|
| 146 |
+
|
| 147 |
+
def insert_zone(self, zone_id: str, data: dict) -> int:
|
| 148 |
+
"""Insert or overwrite a zone."""
|
| 149 |
+
row = dict(data)
|
| 150 |
+
row["zone_id"] = zone_id
|
| 151 |
+
existing = [r for r in self.tables["zones"] if r["zone_id"] == zone_id]
|
| 152 |
+
if existing:
|
| 153 |
+
existing[0].update(row)
|
| 154 |
+
return existing[0].get("id", 0)
|
| 155 |
+
return self.insert("zones", row)
|
| 156 |
+
|
| 157 |
+
def get_zone(self, zone_id: str) -> Optional[dict]:
|
| 158 |
+
rows = self.query("zones", {"zone_id": zone_id}, limit=1)
|
| 159 |
+
return rows[0] if rows else None
|
| 160 |
+
|
| 161 |
+
def insert_heat_index(self, zone_id: str, date: str, data: dict) -> int:
|
| 162 |
+
row = dict(data)
|
| 163 |
+
row["zone_id"] = zone_id
|
| 164 |
+
row["date"] = date
|
| 165 |
+
return self.insert("heat_indices", row)
|
| 166 |
+
|
| 167 |
+
def insert_prediction(self, zone_id: str, date: str, data: dict) -> int:
|
| 168 |
+
row = dict(data)
|
| 169 |
+
row["zone_id"] = zone_id
|
| 170 |
+
row["date"] = date
|
| 171 |
+
return self.insert("predictions", row)
|
| 172 |
+
|
| 173 |
+
def get_recent_heat_indices(self, zone_id: str, limit: int = 90) -> list[dict]:
|
| 174 |
+
rows = self.query("heat_indices", {"zone_id": zone_id}, limit=limit)
|
| 175 |
+
return sorted(rows, key=lambda r: r.get("date", ""), reverse=True)
|
| 176 |
+
|
| 177 |
+
def get_recent_predictions(self, zone_id: str, limit: int = 30) -> list[dict]:
|
| 178 |
+
rows = self.query("predictions", {"zone_id": zone_id}, limit=limit)
|
| 179 |
+
return sorted(rows, key=lambda r: r.get("date", ""), reverse=True)
|
| 180 |
+
|
| 181 |
|
| 182 |
# ── CRUD functions ───────────────────────────────────────────────────────
|
| 183 |
|
|
|
|
| 190 |
"""
|
| 191 |
INSERT INTO zones (zone_id, name, city, country, latitude, longitude,
|
| 192 |
elevation_m, area_km2, population_est, settlement_type,
|
| 193 |
+
worker_population_est, outdoor_exposure_pct,
|
| 194 |
+
heat_vulnerability, hot_months, notes)
|
| 195 |
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
| 196 |
ON CONFLICT (zone_id) DO UPDATE SET
|
| 197 |
name = EXCLUDED.name,
|
| 198 |
population_est = EXCLUDED.population_est,
|
| 199 |
+
worker_population_est = EXCLUDED.worker_population_est,
|
| 200 |
+
outdoor_exposure_pct = EXCLUDED.outdoor_exposure_pct,
|
| 201 |
+
heat_vulnerability = EXCLUDED.heat_vulnerability,
|
| 202 |
+
hot_months = EXCLUDED.hot_months,
|
| 203 |
notes = EXCLUDED.notes
|
| 204 |
""",
|
| 205 |
zone_data["zone_id"], zone_data["name"], zone_data["city"],
|
| 206 |
zone_data["country"], zone_data["latitude"], zone_data["longitude"],
|
| 207 |
zone_data.get("elevation_m"), zone_data.get("area_km2"),
|
| 208 |
zone_data.get("population_est"), zone_data["settlement_type"],
|
| 209 |
+
zone_data.get("worker_population_est"),
|
| 210 |
+
zone_data.get("outdoor_exposure_pct"),
|
| 211 |
+
zone_data["heat_vulnerability"],
|
| 212 |
+
zone_data.get("hot_months", []),
|
| 213 |
zone_data.get("notes", ""),
|
| 214 |
)
|
| 215 |
elif db._memory:
|
| 216 |
+
db._memory.insert_zone(zone_data["zone_id"], zone_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
|
| 219 |
async def get_zone(db: PgConnection, zone_id: str) -> Optional[dict]:
|
|
|
|
| 242 |
if db._pool:
|
| 243 |
return await db.fetchval(
|
| 244 |
"""
|
| 245 |
+
INSERT INTO daily_readings (zone_id, date, temp_mean_c, temp_max_c,
|
| 246 |
+
temp_min_c, humidity_pct, wind_speed_ms, solar_rad_wm2,
|
| 247 |
+
precip_mm, source, data_quality)
|
| 248 |
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
| 249 |
ON CONFLICT (zone_id, date) DO UPDATE SET
|
| 250 |
+
temp_max_c = EXCLUDED.temp_max_c,
|
| 251 |
data_quality = EXCLUDED.data_quality
|
| 252 |
RETURNING id
|
| 253 |
""",
|
| 254 |
reading["zone_id"], reading["date"],
|
| 255 |
+
reading.get("temp_mean_c"), reading.get("temp_max_c"),
|
| 256 |
+
reading.get("temp_min_c"), reading.get("humidity_pct"),
|
| 257 |
+
reading.get("wind_speed_ms"), reading.get("solar_rad_wm2"),
|
| 258 |
+
reading.get("precip_mm"),
|
| 259 |
+
reading.get("source", "unknown"),
|
| 260 |
reading.get("data_quality", 0.0),
|
| 261 |
)
|
| 262 |
elif db._memory:
|
|
|
|
| 287 |
return await db.fetchval(
|
| 288 |
"""
|
| 289 |
INSERT INTO healed_readings (zone_id, date, raw_reading_id,
|
| 290 |
+
temp_mean_c, temp_max_c, temp_min_c,
|
| 291 |
humidity_pct, wind_speed_ms, quality_score, heal_action, fields_corrected)
|
| 292 |
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
| 293 |
ON CONFLICT (zone_id, date) DO UPDATE SET
|
| 294 |
quality_score = EXCLUDED.quality_score,
|
| 295 |
heal_action = EXCLUDED.heal_action
|
| 296 |
RETURNING id
|
| 297 |
""",
|
| 298 |
reading["zone_id"], reading["date"], reading.get("raw_reading_id"),
|
| 299 |
+
reading.get("temp_mean_c"),
|
| 300 |
reading.get("temp_max_c"), reading.get("temp_min_c"),
|
| 301 |
reading.get("humidity_pct"), reading.get("wind_speed_ms"),
|
| 302 |
reading.get("quality_score", 0.0),
|
|
|
|
| 333 |
return None
|
| 334 |
|
| 335 |
|
| 336 |
+
# --- Heat indices ---
|
| 337 |
|
| 338 |
+
async def insert_heat_index(db: PgConnection, record: dict) -> Optional[int]:
|
| 339 |
+
"""Insert a daily heat index record."""
|
| 340 |
if db._pool:
|
| 341 |
+
return await db.fetchval(
|
| 342 |
"""
|
| 343 |
+
INSERT INTO heat_indices (zone_id, date, grid_temp_c, uhi_delta_c,
|
| 344 |
+
corrected_temp_c, wbgt_c, heat_index_c, heat_risk_score,
|
| 345 |
+
risk_level, consecutive_hot_days)
|
| 346 |
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
| 347 |
+
ON CONFLICT (zone_id, date) DO UPDATE SET
|
| 348 |
+
heat_risk_score = EXCLUDED.heat_risk_score,
|
| 349 |
+
risk_level = EXCLUDED.risk_level
|
| 350 |
+
RETURNING id
|
|
|
|
|
|
|
| 351 |
""",
|
| 352 |
+
record["zone_id"], record["date"],
|
| 353 |
+
record.get("grid_temp_c"), record.get("uhi_delta_c"),
|
| 354 |
+
record.get("corrected_temp_c"), record.get("wbgt_c"),
|
| 355 |
+
record.get("heat_index_c"), record.get("heat_risk_score"),
|
| 356 |
+
record.get("risk_level"), record.get("consecutive_hot_days", 0),
|
| 357 |
)
|
| 358 |
elif db._memory:
|
| 359 |
+
return db._memory.insert_heat_index(
|
| 360 |
+
record["zone_id"], record["date"], record,
|
| 361 |
+
)
|
| 362 |
+
return None
|
| 363 |
|
| 364 |
|
| 365 |
+
async def get_recent_heat_indices(
|
| 366 |
+
db: PgConnection, zone_id: str, limit: int = 90
|
| 367 |
) -> list[dict]:
|
| 368 |
+
"""Fetch recent heat index records for a zone."""
|
| 369 |
if db._pool:
|
| 370 |
return await db.fetch(
|
| 371 |
+
"SELECT * FROM heat_indices WHERE zone_id = $1 ORDER BY date DESC LIMIT $2",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
zone_id, limit,
|
| 373 |
)
|
| 374 |
elif db._memory:
|
| 375 |
+
return db._memory.get_recent_heat_indices(zone_id, limit=limit)
|
| 376 |
return []
|
| 377 |
|
| 378 |
|
| 379 |
+
# --- Predictions ---
|
| 380 |
|
| 381 |
+
async def insert_prediction(db: PgConnection, record: dict) -> Optional[int]:
|
| 382 |
+
"""Insert a daily prediction record."""
|
| 383 |
if db._pool:
|
| 384 |
return await db.fetchval(
|
| 385 |
"""
|
| 386 |
+
INSERT INTO predictions (zone_id, date, trigger_probability_7d,
|
| 387 |
+
prediction_confidence, model_tier, xgb_probability,
|
| 388 |
+
lstm_probability, ensemble_method)
|
| 389 |
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
| 390 |
ON CONFLICT (zone_id, date) DO UPDATE SET
|
| 391 |
+
trigger_probability_7d = EXCLUDED.trigger_probability_7d,
|
| 392 |
+
model_tier = EXCLUDED.model_tier
|
| 393 |
RETURNING id
|
| 394 |
""",
|
| 395 |
+
record["zone_id"], record["date"],
|
| 396 |
+
record.get("trigger_probability_7d"),
|
| 397 |
+
record.get("prediction_confidence"),
|
| 398 |
+
record.get("model_tier", "climatology"),
|
| 399 |
+
record.get("xgb_probability"),
|
| 400 |
+
record.get("lstm_probability"),
|
| 401 |
+
record.get("ensemble_method", "average"),
|
| 402 |
)
|
| 403 |
elif db._memory:
|
| 404 |
+
return db._memory.insert_prediction(
|
| 405 |
+
record["zone_id"], record["date"], record,
|
| 406 |
+
)
|
| 407 |
return None
|
| 408 |
|
| 409 |
|
| 410 |
+
async def get_recent_predictions(
|
| 411 |
+
db: PgConnection, zone_id: str, limit: int = 30
|
| 412 |
+
) -> list[dict]:
|
| 413 |
+
"""Fetch recent prediction records for a zone."""
|
| 414 |
+
if db._pool:
|
| 415 |
+
return await db.fetch(
|
| 416 |
+
"SELECT * FROM predictions WHERE zone_id = $1 ORDER BY date DESC LIMIT $2",
|
| 417 |
+
zone_id, limit,
|
| 418 |
+
)
|
| 419 |
+
elif db._memory:
|
| 420 |
+
return db._memory.get_recent_predictions(zone_id, limit=limit)
|
| 421 |
+
return []
|
| 422 |
+
|
| 423 |
+
|
| 424 |
# --- Trigger events ---
|
| 425 |
|
| 426 |
async def insert_trigger_event(db: PgConnection, event: dict) -> Optional[int]:
|
|
|
|
| 429 |
return await db.fetchval(
|
| 430 |
"""
|
| 431 |
INSERT INTO trigger_events (zone_id, trigger_level, triggered_at,
|
| 432 |
+
max_temp_c, max_wbgt_c, consecutive_days, heat_risk_score,
|
| 433 |
+
settlement_type, payout_per_worker_usd, enrolled_workers,
|
| 434 |
+
total_payout_usd)
|
| 435 |
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
| 436 |
RETURNING id
|
| 437 |
""",
|
| 438 |
event["zone_id"], event["trigger_level"], event["triggered_at"],
|
| 439 |
+
event.get("max_temp_c"), event.get("max_wbgt_c"),
|
| 440 |
+
event.get("consecutive_days"), event.get("heat_risk_score"),
|
| 441 |
+
event.get("settlement_type"), event.get("payout_per_worker_usd"),
|
| 442 |
+
event.get("enrolled_workers"), event.get("total_payout_usd"),
|
| 443 |
)
|
| 444 |
elif db._memory:
|
| 445 |
return db._memory.insert("trigger_events", event)
|
|
|
|
| 585 |
return []
|
| 586 |
|
| 587 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
# --- Pipeline runs ---
|
| 589 |
|
| 590 |
async def start_pipeline_run(db: PgConnection, run_id: Optional[str] = None) -> str:
|
src/database/schema.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
PostgreSQL schema for the Climate Risk Index Engine.
|
| 3 |
|
| 4 |
All table definitions as SQL strings with proper types, foreign keys,
|
| 5 |
-
indexes, and constraints.
|
| 6 |
(referenced tables first).
|
| 7 |
"""
|
| 8 |
|
|
@@ -16,9 +16,8 @@ TABLES_ORDERED: list[str] = [
|
|
| 16 |
"daily_readings",
|
| 17 |
"healed_readings",
|
| 18 |
"healing_log",
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
-
"enrolled_policies",
|
| 22 |
"trigger_events",
|
| 23 |
"basis_risk",
|
| 24 |
"explanations",
|
|
@@ -31,26 +30,27 @@ TABLES_ORDERED: list[str] = [
|
|
| 31 |
|
| 32 |
CREATE_ZONES = """
|
| 33 |
CREATE TABLE IF NOT EXISTS zones (
|
| 34 |
-
zone_id
|
| 35 |
-
name
|
| 36 |
-
city
|
| 37 |
-
country
|
| 38 |
-
latitude
|
| 39 |
-
longitude
|
| 40 |
-
elevation_m
|
| 41 |
-
area_km2
|
| 42 |
-
population_est
|
| 43 |
-
settlement_type
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
notes
|
| 49 |
-
created_at
|
| 50 |
);
|
| 51 |
|
| 52 |
CREATE INDEX IF NOT EXISTS idx_zones_city ON zones (city);
|
| 53 |
CREATE INDEX IF NOT EXISTS idx_zones_settlement ON zones (settlement_type);
|
|
|
|
| 54 |
"""
|
| 55 |
|
| 56 |
CREATE_DAILY_READINGS = """
|
|
@@ -58,16 +58,14 @@ CREATE TABLE IF NOT EXISTS daily_readings (
|
|
| 58 |
id BIGSERIAL PRIMARY KEY,
|
| 59 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 60 |
date DATE NOT NULL,
|
| 61 |
-
precip_mm DOUBLE PRECISION,
|
| 62 |
-
precip_nasa_mm DOUBLE PRECISION,
|
| 63 |
-
precip_chirps_mm DOUBLE PRECISION,
|
| 64 |
temp_mean_c DOUBLE PRECISION,
|
| 65 |
temp_max_c DOUBLE PRECISION,
|
| 66 |
temp_min_c DOUBLE PRECISION,
|
| 67 |
humidity_pct DOUBLE PRECISION,
|
| 68 |
wind_speed_ms DOUBLE PRECISION,
|
|
|
|
|
|
|
| 69 |
source TEXT DEFAULT 'unknown',
|
| 70 |
-
source_agreement DOUBLE PRECISION,
|
| 71 |
data_quality DOUBLE PRECISION DEFAULT 0.0,
|
| 72 |
ingested_at TIMESTAMPTZ DEFAULT NOW(),
|
| 73 |
UNIQUE (zone_id, date)
|
|
@@ -83,7 +81,6 @@ CREATE TABLE IF NOT EXISTS healed_readings (
|
|
| 83 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 84 |
date DATE NOT NULL,
|
| 85 |
raw_reading_id BIGINT REFERENCES daily_readings(id),
|
| 86 |
-
precip_mm DOUBLE PRECISION,
|
| 87 |
temp_mean_c DOUBLE PRECISION,
|
| 88 |
temp_max_c DOUBLE PRECISION,
|
| 89 |
temp_min_c DOUBLE PRECISION,
|
|
@@ -119,68 +116,44 @@ CREATE TABLE IF NOT EXISTS healing_log (
|
|
| 119 |
CREATE INDEX IF NOT EXISTS idx_healing_log_zone ON healing_log (zone_id, date DESC);
|
| 120 |
"""
|
| 121 |
|
| 122 |
-
|
| 123 |
-
CREATE TABLE IF NOT EXISTS
|
| 124 |
-
id BIGSERIAL PRIMARY KEY,
|
| 125 |
-
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 126 |
-
year INTEGER NOT NULL,
|
| 127 |
-
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
|
| 128 |
-
spi_1 DOUBLE PRECISION,
|
| 129 |
-
spi_3 DOUBLE PRECISION,
|
| 130 |
-
spi_6 DOUBLE PRECISION,
|
| 131 |
-
total_precip_mm DOUBLE PRECISION,
|
| 132 |
-
mean_precip_mm DOUBLE PRECISION,
|
| 133 |
-
precip_days INTEGER,
|
| 134 |
-
precip_anomaly_pct DOUBLE PRECISION,
|
| 135 |
-
computed_at TIMESTAMPTZ DEFAULT NOW(),
|
| 136 |
-
UNIQUE (zone_id, year, month)
|
| 137 |
-
);
|
| 138 |
-
|
| 139 |
-
CREATE INDEX IF NOT EXISTS idx_monthly_indices_zone_ym ON monthly_indices (zone_id, year DESC, month DESC);
|
| 140 |
-
"""
|
| 141 |
-
|
| 142 |
-
CREATE_FLOOD_RISK_SCORES = """
|
| 143 |
-
CREATE TABLE IF NOT EXISTS flood_risk_scores (
|
| 144 |
id BIGSERIAL PRIMARY KEY,
|
| 145 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 146 |
date DATE NOT NULL,
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
risk_level TEXT CHECK (risk_level IN ('low', 'moderate', 'high', 'critical')),
|
| 154 |
-
|
| 155 |
computed_at TIMESTAMPTZ DEFAULT NOW(),
|
| 156 |
UNIQUE (zone_id, date)
|
| 157 |
);
|
| 158 |
|
| 159 |
-
CREATE INDEX IF NOT EXISTS
|
| 160 |
-
CREATE INDEX IF NOT EXISTS
|
| 161 |
"""
|
| 162 |
|
| 163 |
-
|
| 164 |
-
CREATE TABLE IF NOT EXISTS
|
| 165 |
id BIGSERIAL PRIMARY KEY,
|
| 166 |
-
policy_number TEXT UNIQUE NOT NULL,
|
| 167 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
created_at TIMESTAMPTZ DEFAULT NOW(),
|
| 178 |
-
updated_at TIMESTAMPTZ DEFAULT NOW()
|
| 179 |
);
|
| 180 |
|
| 181 |
-
CREATE INDEX IF NOT EXISTS
|
| 182 |
-
CREATE INDEX IF NOT EXISTS
|
| 183 |
-
CREATE INDEX IF NOT EXISTS idx_policies_phone ON enrolled_policies (holder_phone);
|
| 184 |
"""
|
| 185 |
|
| 186 |
CREATE_TRIGGER_EVENTS = """
|
|
@@ -189,12 +162,14 @@ CREATE TABLE IF NOT EXISTS trigger_events (
|
|
| 189 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 190 |
trigger_level TEXT NOT NULL CHECK (trigger_level IN ('critical', 'warning', 'watch')),
|
| 191 |
triggered_at TIMESTAMPTZ NOT NULL,
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
| 198 |
resolved_at TIMESTAMPTZ,
|
| 199 |
resolution_notes TEXT,
|
| 200 |
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
@@ -239,7 +214,7 @@ CREATE TABLE IF NOT EXISTS explanations (
|
|
| 239 |
english_text TEXT NOT NULL,
|
| 240 |
swahili_text TEXT NOT NULL,
|
| 241 |
payout_amount DOUBLE PRECISION,
|
| 242 |
-
payout_currency TEXT DEFAULT '
|
| 243 |
settlement_type TEXT,
|
| 244 |
protective_actions TEXT[] DEFAULT '{}',
|
| 245 |
provider TEXT DEFAULT 'template',
|
|
@@ -258,6 +233,7 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|
| 258 |
recipient TEXT NOT NULL,
|
| 259 |
channel TEXT NOT NULL CHECK (channel IN ('console', 'sms', 'whatsapp')),
|
| 260 |
status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'dry_run', 'pending')),
|
|
|
|
| 261 |
message_preview TEXT,
|
| 262 |
message_sid TEXT,
|
| 263 |
cost_estimate DOUBLE PRECISION DEFAULT 0.0,
|
|
@@ -267,7 +243,6 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|
| 267 |
|
| 268 |
CREATE INDEX IF NOT EXISTS idx_notifications_zone ON notifications (zone_id, sent_at DESC);
|
| 269 |
CREATE INDEX IF NOT EXISTS idx_notifications_status ON notifications (status);
|
| 270 |
-
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications (recipient);
|
| 271 |
"""
|
| 272 |
|
| 273 |
CREATE_PIPELINE_RUNS = """
|
|
@@ -278,8 +253,11 @@ CREATE TABLE IF NOT EXISTS pipeline_runs (
|
|
| 278 |
finished_at TIMESTAMPTZ,
|
| 279 |
status TEXT DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed', 'partial')),
|
| 280 |
zones_processed INTEGER DEFAULT 0,
|
|
|
|
|
|
|
| 281 |
steps_completed TEXT[] DEFAULT '{}',
|
| 282 |
step_status JSONB DEFAULT '{}',
|
|
|
|
| 283 |
error TEXT,
|
| 284 |
duration_s DOUBLE PRECISION,
|
| 285 |
config_snapshot JSONB DEFAULT '{}'
|
|
@@ -297,9 +275,8 @@ ALL_DDL: dict[str, str] = {
|
|
| 297 |
"daily_readings": CREATE_DAILY_READINGS,
|
| 298 |
"healed_readings": CREATE_HEALED_READINGS,
|
| 299 |
"healing_log": CREATE_HEALING_LOG,
|
| 300 |
-
"
|
| 301 |
-
"
|
| 302 |
-
"enrolled_policies": CREATE_ENROLLED_POLICIES,
|
| 303 |
"trigger_events": CREATE_TRIGGER_EVENTS,
|
| 304 |
"basis_risk": CREATE_BASIS_RISK,
|
| 305 |
"explanations": CREATE_EXPLANATIONS,
|
|
|
|
| 2 |
PostgreSQL schema for the Climate Risk Index Engine.
|
| 3 |
|
| 4 |
All table definitions as SQL strings with proper types, foreign keys,
|
| 5 |
+
indexes, and constraints. Tables are designed to be created in order
|
| 6 |
(referenced tables first).
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 16 |
"daily_readings",
|
| 17 |
"healed_readings",
|
| 18 |
"healing_log",
|
| 19 |
+
"heat_indices",
|
| 20 |
+
"predictions",
|
|
|
|
| 21 |
"trigger_events",
|
| 22 |
"basis_risk",
|
| 23 |
"explanations",
|
|
|
|
| 30 |
|
| 31 |
CREATE_ZONES = """
|
| 32 |
CREATE TABLE IF NOT EXISTS zones (
|
| 33 |
+
zone_id TEXT PRIMARY KEY,
|
| 34 |
+
name TEXT NOT NULL,
|
| 35 |
+
city TEXT NOT NULL,
|
| 36 |
+
country TEXT NOT NULL,
|
| 37 |
+
latitude DOUBLE PRECISION NOT NULL,
|
| 38 |
+
longitude DOUBLE PRECISION NOT NULL,
|
| 39 |
+
elevation_m DOUBLE PRECISION,
|
| 40 |
+
area_km2 DOUBLE PRECISION,
|
| 41 |
+
population_est INTEGER,
|
| 42 |
+
settlement_type TEXT NOT NULL CHECK (settlement_type IN ('formal', 'informal', 'mixed', 'commercial')),
|
| 43 |
+
worker_population_est INTEGER,
|
| 44 |
+
outdoor_exposure_pct DOUBLE PRECISION,
|
| 45 |
+
heat_vulnerability TEXT NOT NULL CHECK (heat_vulnerability IN ('high', 'moderate', 'low')),
|
| 46 |
+
hot_months INTEGER[] DEFAULT '{}',
|
| 47 |
+
notes TEXT DEFAULT '',
|
| 48 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 49 |
);
|
| 50 |
|
| 51 |
CREATE INDEX IF NOT EXISTS idx_zones_city ON zones (city);
|
| 52 |
CREATE INDEX IF NOT EXISTS idx_zones_settlement ON zones (settlement_type);
|
| 53 |
+
CREATE INDEX IF NOT EXISTS idx_zones_vulnerability ON zones (heat_vulnerability);
|
| 54 |
"""
|
| 55 |
|
| 56 |
CREATE_DAILY_READINGS = """
|
|
|
|
| 58 |
id BIGSERIAL PRIMARY KEY,
|
| 59 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 60 |
date DATE NOT NULL,
|
|
|
|
|
|
|
|
|
|
| 61 |
temp_mean_c DOUBLE PRECISION,
|
| 62 |
temp_max_c DOUBLE PRECISION,
|
| 63 |
temp_min_c DOUBLE PRECISION,
|
| 64 |
humidity_pct DOUBLE PRECISION,
|
| 65 |
wind_speed_ms DOUBLE PRECISION,
|
| 66 |
+
solar_rad_wm2 DOUBLE PRECISION,
|
| 67 |
+
precip_mm DOUBLE PRECISION,
|
| 68 |
source TEXT DEFAULT 'unknown',
|
|
|
|
| 69 |
data_quality DOUBLE PRECISION DEFAULT 0.0,
|
| 70 |
ingested_at TIMESTAMPTZ DEFAULT NOW(),
|
| 71 |
UNIQUE (zone_id, date)
|
|
|
|
| 81 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 82 |
date DATE NOT NULL,
|
| 83 |
raw_reading_id BIGINT REFERENCES daily_readings(id),
|
|
|
|
| 84 |
temp_mean_c DOUBLE PRECISION,
|
| 85 |
temp_max_c DOUBLE PRECISION,
|
| 86 |
temp_min_c DOUBLE PRECISION,
|
|
|
|
| 116 |
CREATE INDEX IF NOT EXISTS idx_healing_log_zone ON healing_log (zone_id, date DESC);
|
| 117 |
"""
|
| 118 |
|
| 119 |
+
CREATE_HEAT_INDICES = """
|
| 120 |
+
CREATE TABLE IF NOT EXISTS heat_indices (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
id BIGSERIAL PRIMARY KEY,
|
| 122 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 123 |
date DATE NOT NULL,
|
| 124 |
+
grid_temp_c DOUBLE PRECISION,
|
| 125 |
+
uhi_delta_c DOUBLE PRECISION,
|
| 126 |
+
corrected_temp_c DOUBLE PRECISION,
|
| 127 |
+
wbgt_c DOUBLE PRECISION,
|
| 128 |
+
heat_index_c DOUBLE PRECISION,
|
| 129 |
+
heat_risk_score DOUBLE PRECISION CHECK (heat_risk_score BETWEEN 0 AND 100),
|
| 130 |
risk_level TEXT CHECK (risk_level IN ('low', 'moderate', 'high', 'critical')),
|
| 131 |
+
consecutive_hot_days INTEGER DEFAULT 0,
|
| 132 |
computed_at TIMESTAMPTZ DEFAULT NOW(),
|
| 133 |
UNIQUE (zone_id, date)
|
| 134 |
);
|
| 135 |
|
| 136 |
+
CREATE INDEX IF NOT EXISTS idx_heat_indices_zone_date ON heat_indices (zone_id, date DESC);
|
| 137 |
+
CREATE INDEX IF NOT EXISTS idx_heat_indices_risk ON heat_indices (risk_level);
|
| 138 |
"""
|
| 139 |
|
| 140 |
+
CREATE_PREDICTIONS = """
|
| 141 |
+
CREATE TABLE IF NOT EXISTS predictions (
|
| 142 |
id BIGSERIAL PRIMARY KEY,
|
|
|
|
| 143 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 144 |
+
date DATE NOT NULL,
|
| 145 |
+
trigger_probability_7d DOUBLE PRECISION CHECK (trigger_probability_7d BETWEEN 0 AND 1),
|
| 146 |
+
prediction_confidence DOUBLE PRECISION CHECK (prediction_confidence BETWEEN 0 AND 1),
|
| 147 |
+
model_tier TEXT DEFAULT 'climatology',
|
| 148 |
+
xgb_probability DOUBLE PRECISION,
|
| 149 |
+
lstm_probability DOUBLE PRECISION,
|
| 150 |
+
ensemble_method TEXT DEFAULT 'average',
|
| 151 |
+
predicted_at TIMESTAMPTZ DEFAULT NOW(),
|
| 152 |
+
UNIQUE (zone_id, date)
|
|
|
|
|
|
|
| 153 |
);
|
| 154 |
|
| 155 |
+
CREATE INDEX IF NOT EXISTS idx_predictions_zone_date ON predictions (zone_id, date DESC);
|
| 156 |
+
CREATE INDEX IF NOT EXISTS idx_predictions_tier ON predictions (model_tier);
|
|
|
|
| 157 |
"""
|
| 158 |
|
| 159 |
CREATE_TRIGGER_EVENTS = """
|
|
|
|
| 162 |
zone_id TEXT NOT NULL REFERENCES zones(zone_id),
|
| 163 |
trigger_level TEXT NOT NULL CHECK (trigger_level IN ('critical', 'warning', 'watch')),
|
| 164 |
triggered_at TIMESTAMPTZ NOT NULL,
|
| 165 |
+
max_temp_c DOUBLE PRECISION,
|
| 166 |
+
max_wbgt_c DOUBLE PRECISION,
|
| 167 |
+
consecutive_days INTEGER,
|
| 168 |
+
heat_risk_score DOUBLE PRECISION,
|
| 169 |
+
settlement_type TEXT,
|
| 170 |
+
payout_per_worker_usd DOUBLE PRECISION,
|
| 171 |
+
enrolled_workers INTEGER,
|
| 172 |
+
total_payout_usd DOUBLE PRECISION,
|
| 173 |
resolved_at TIMESTAMPTZ,
|
| 174 |
resolution_notes TEXT,
|
| 175 |
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
| 214 |
english_text TEXT NOT NULL,
|
| 215 |
swahili_text TEXT NOT NULL,
|
| 216 |
payout_amount DOUBLE PRECISION,
|
| 217 |
+
payout_currency TEXT DEFAULT 'USD',
|
| 218 |
settlement_type TEXT,
|
| 219 |
protective_actions TEXT[] DEFAULT '{}',
|
| 220 |
provider TEXT DEFAULT 'template',
|
|
|
|
| 233 |
recipient TEXT NOT NULL,
|
| 234 |
channel TEXT NOT NULL CHECK (channel IN ('console', 'sms', 'whatsapp')),
|
| 235 |
status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'dry_run', 'pending')),
|
| 236 |
+
language TEXT DEFAULT 'en',
|
| 237 |
message_preview TEXT,
|
| 238 |
message_sid TEXT,
|
| 239 |
cost_estimate DOUBLE PRECISION DEFAULT 0.0,
|
|
|
|
| 243 |
|
| 244 |
CREATE INDEX IF NOT EXISTS idx_notifications_zone ON notifications (zone_id, sent_at DESC);
|
| 245 |
CREATE INDEX IF NOT EXISTS idx_notifications_status ON notifications (status);
|
|
|
|
| 246 |
"""
|
| 247 |
|
| 248 |
CREATE_PIPELINE_RUNS = """
|
|
|
|
| 253 |
finished_at TIMESTAMPTZ,
|
| 254 |
status TEXT DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed', 'partial')),
|
| 255 |
zones_processed INTEGER DEFAULT 0,
|
| 256 |
+
triggers_found INTEGER DEFAULT 0,
|
| 257 |
+
notifications_sent INTEGER DEFAULT 0,
|
| 258 |
steps_completed TEXT[] DEFAULT '{}',
|
| 259 |
step_status JSONB DEFAULT '{}',
|
| 260 |
+
total_cost_usd DOUBLE PRECISION DEFAULT 0,
|
| 261 |
error TEXT,
|
| 262 |
duration_s DOUBLE PRECISION,
|
| 263 |
config_snapshot JSONB DEFAULT '{}'
|
|
|
|
| 275 |
"daily_readings": CREATE_DAILY_READINGS,
|
| 276 |
"healed_readings": CREATE_HEALED_READINGS,
|
| 277 |
"healing_log": CREATE_HEALING_LOG,
|
| 278 |
+
"heat_indices": CREATE_HEAT_INDICES,
|
| 279 |
+
"predictions": CREATE_PREDICTIONS,
|
|
|
|
| 280 |
"trigger_events": CREATE_TRIGGER_EVENTS,
|
| 281 |
"basis_risk": CREATE_BASIS_RISK,
|
| 282 |
"explanations": CREATE_EXPLANATIONS,
|
src/explanation/explainer.py
CHANGED
|
@@ -73,6 +73,15 @@ class TriggerExplainer:
|
|
| 73 |
self.model = model
|
| 74 |
self._client = None
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def _get_client(self):
|
| 77 |
if self._client is None:
|
| 78 |
try:
|
|
@@ -134,7 +143,16 @@ class TriggerExplainer:
|
|
| 134 |
async def _generate_claude(
|
| 135 |
self, event, zone: UrbanZone, payout: Dict[str, Any]
|
| 136 |
) -> tuple[str, str]:
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
system = (
|
| 140 |
"You are a heat safety notification system for outdoor workers in East Africa. "
|
|
|
|
| 73 |
self.model = model
|
| 74 |
self._client = None
|
| 75 |
|
| 76 |
+
# Try to load hybrid RAG retriever; falls back to full context injection
|
| 77 |
+
try:
|
| 78 |
+
from src.explanation.rag_provider import HybridRetriever
|
| 79 |
+
self._retriever = HybridRetriever()
|
| 80 |
+
log.info("Hybrid RAG retriever loaded successfully")
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
log.info("RAG retriever not available, using full context injection: %s", exc)
|
| 83 |
+
self._retriever = None
|
| 84 |
+
|
| 85 |
def _get_client(self):
|
| 86 |
if self._client is None:
|
| 87 |
try:
|
|
|
|
| 143 |
async def _generate_claude(
|
| 144 |
self, event, zone: UrbanZone, payout: Dict[str, Any]
|
| 145 |
) -> tuple[str, str]:
|
| 146 |
+
# Use hybrid RAG retrieval if available, otherwise fall back to full injection
|
| 147 |
+
if self._retriever:
|
| 148 |
+
query = (
|
| 149 |
+
f"{event.trigger_level} heat alert {zone.name} {zone.city} "
|
| 150 |
+
f"{zone.settlement_type} outdoor workers"
|
| 151 |
+
)
|
| 152 |
+
context_docs = self._retriever.retrieve(query, zone_id=event.zone_id)
|
| 153 |
+
context = "\n---\n".join(context_docs)
|
| 154 |
+
else:
|
| 155 |
+
context = get_zone_context(event.zone_id)
|
| 156 |
|
| 157 |
system = (
|
| 158 |
"You are a heat safety notification system for outdoor workers in East Africa. "
|
src/explanation/rag_index_builder.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build FAISS + BM25 hybrid index from the knowledge base corpus.
|
| 3 |
+
|
| 4 |
+
Reads the curated knowledge base (zone context, safety guidance, insurance
|
| 5 |
+
info, protective actions, emergency contacts, Swahili glossary) and creates
|
| 6 |
+
a dense FAISS index (bge-base-en-v1.5, 768-dim) plus a lightweight BM25
|
| 7 |
+
index for hybrid retrieval.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import math
|
| 14 |
+
import os
|
| 15 |
+
import pickle
|
| 16 |
+
from collections import defaultdict
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
INDEX_DIR = Path(__file__).resolve().parents[2] / "models" / "faiss_index"
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# Lightweight BM25 Okapi implementation (no external dependency)
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class BM25:
|
| 32 |
+
"""Lightweight BM25 Okapi scoring."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, corpus: list[str], k1: float = 1.5, b: float = 0.75):
|
| 35 |
+
self.k1 = k1
|
| 36 |
+
self.b = b
|
| 37 |
+
self.N = len(corpus)
|
| 38 |
+
self.tokenized = [doc.lower().split() for doc in corpus]
|
| 39 |
+
self.avgdl = sum(len(d) for d in self.tokenized) / max(self.N, 1)
|
| 40 |
+
|
| 41 |
+
# Document frequency
|
| 42 |
+
df: dict[str, int] = defaultdict(int)
|
| 43 |
+
for toks in self.tokenized:
|
| 44 |
+
for word in set(toks):
|
| 45 |
+
df[word] += 1
|
| 46 |
+
|
| 47 |
+
# IDF with smoothing
|
| 48 |
+
self.idf: dict[str, float] = {
|
| 49 |
+
w: math.log((self.N - n + 0.5) / (n + 0.5) + 1)
|
| 50 |
+
for w, n in df.items()
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
def scores(self, query: str) -> list[float]:
|
| 54 |
+
"""Return BM25 scores for all documents given a query string."""
|
| 55 |
+
query_tokens = query.lower().split()
|
| 56 |
+
result: list[float] = []
|
| 57 |
+
for doc_tokens in self.tokenized:
|
| 58 |
+
doc_len = len(doc_tokens)
|
| 59 |
+
score = 0.0
|
| 60 |
+
# Count term frequencies in this document
|
| 61 |
+
tf_map: dict[str, int] = defaultdict(int)
|
| 62 |
+
for t in doc_tokens:
|
| 63 |
+
tf_map[t] += 1
|
| 64 |
+
for qt in query_tokens:
|
| 65 |
+
if qt not in self.idf:
|
| 66 |
+
continue
|
| 67 |
+
tf = tf_map.get(qt, 0)
|
| 68 |
+
idf = self.idf[qt]
|
| 69 |
+
numerator = tf * (self.k1 + 1)
|
| 70 |
+
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)
|
| 71 |
+
score += idf * (numerator / denominator)
|
| 72 |
+
result.append(score)
|
| 73 |
+
return result
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
# Corpus builder
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _build_corpus() -> list[str]:
|
| 82 |
+
"""Assemble the RAG corpus from knowledge_base.py data structures.
|
| 83 |
+
|
| 84 |
+
Returns a list of document strings, each representing one retrievable
|
| 85 |
+
chunk. Target: ~60 documents.
|
| 86 |
+
"""
|
| 87 |
+
from src.explanation.knowledge_base import (
|
| 88 |
+
EMERGENCY_CONTACTS,
|
| 89 |
+
HEAT_SAFETY_GUIDANCE,
|
| 90 |
+
INSURANCE_PRODUCT_INFO,
|
| 91 |
+
PROTECTIVE_ACTIONS,
|
| 92 |
+
SWAHILI_TERMS,
|
| 93 |
+
ZONE_HEAT_CONTEXT,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
docs: list[str] = []
|
| 97 |
+
|
| 98 |
+
# 1. Zone heat contexts -- one document per zone
|
| 99 |
+
for zone_id, text in ZONE_HEAT_CONTEXT.items():
|
| 100 |
+
docs.append(f"[Zone: {zone_id}] {text}")
|
| 101 |
+
|
| 102 |
+
# 2. Heat safety guidance -- one document per guideline
|
| 103 |
+
for i, guideline in enumerate(HEAT_SAFETY_GUIDANCE):
|
| 104 |
+
docs.append(f"[Heat Safety Guideline {i + 1}] {guideline}")
|
| 105 |
+
|
| 106 |
+
# 3. Insurance product info -- split into paragraphs
|
| 107 |
+
paragraphs = [p.strip() for p in INSURANCE_PRODUCT_INFO.split("\n\n") if p.strip()]
|
| 108 |
+
for i, para in enumerate(paragraphs):
|
| 109 |
+
docs.append(f"[Insurance Product Info - Part {i + 1}] {para}")
|
| 110 |
+
|
| 111 |
+
# 4. Protective actions -- one document per trigger level
|
| 112 |
+
for level, actions in PROTECTIVE_ACTIONS.items():
|
| 113 |
+
actions_text = " ".join(actions)
|
| 114 |
+
docs.append(f"[Protective Actions - {level.upper()} level] {actions_text}")
|
| 115 |
+
|
| 116 |
+
# 5. Emergency contacts -- one document per city
|
| 117 |
+
for city, contacts in EMERGENCY_CONTACTS.items():
|
| 118 |
+
contact_lines = [f"{name}: {number}" for name, number in contacts.items()]
|
| 119 |
+
docs.append(f"[Emergency Contacts - {city}] " + " | ".join(contact_lines))
|
| 120 |
+
|
| 121 |
+
# 6. Swahili glossary -- one combined document
|
| 122 |
+
terms = [f"{en} = {sw}" for en, sw in SWAHILI_TERMS.items()]
|
| 123 |
+
docs.append("[Swahili Heat/Insurance Glossary] " + " | ".join(terms))
|
| 124 |
+
|
| 125 |
+
return docs
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
# Index builder
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def build_index(
|
| 134 |
+
force_rebuild: bool = False,
|
| 135 |
+
index_dir: Optional[str | Path] = None,
|
| 136 |
+
) -> tuple:
|
| 137 |
+
"""Build FAISS + BM25 indices from the knowledge base.
|
| 138 |
+
|
| 139 |
+
Returns (faiss.Index, corpus: list[str], bm25: BM25).
|
| 140 |
+
"""
|
| 141 |
+
import faiss
|
| 142 |
+
from sentence_transformers import SentenceTransformer
|
| 143 |
+
|
| 144 |
+
idx_dir = Path(index_dir) if index_dir else INDEX_DIR
|
| 145 |
+
idx_dir.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
|
| 147 |
+
faiss_path = idx_dir / "index.faiss"
|
| 148 |
+
corpus_path = idx_dir / "corpus.pkl"
|
| 149 |
+
bm25_path = idx_dir / "bm25.pkl"
|
| 150 |
+
|
| 151 |
+
# Check if index already exists
|
| 152 |
+
if (
|
| 153 |
+
not force_rebuild
|
| 154 |
+
and faiss_path.exists()
|
| 155 |
+
and corpus_path.exists()
|
| 156 |
+
and bm25_path.exists()
|
| 157 |
+
):
|
| 158 |
+
log.info("Loading existing index from %s", idx_dir)
|
| 159 |
+
index = faiss.read_index(str(faiss_path))
|
| 160 |
+
with open(corpus_path, "rb") as f:
|
| 161 |
+
corpus = pickle.load(f)
|
| 162 |
+
with open(bm25_path, "rb") as f:
|
| 163 |
+
bm25 = pickle.load(f)
|
| 164 |
+
return index, corpus, bm25
|
| 165 |
+
|
| 166 |
+
log.info("Building RAG index (force_rebuild=%s)...", force_rebuild)
|
| 167 |
+
|
| 168 |
+
# Build corpus
|
| 169 |
+
corpus = _build_corpus()
|
| 170 |
+
log.info("Corpus size: %d documents", len(corpus))
|
| 171 |
+
|
| 172 |
+
# Dense embeddings
|
| 173 |
+
model = SentenceTransformer("BAAI/bge-base-en-v1.5")
|
| 174 |
+
embeddings = model.encode(corpus, batch_size=32, normalize_embeddings=True)
|
| 175 |
+
embeddings = np.array(embeddings, dtype="float32")
|
| 176 |
+
|
| 177 |
+
# FAISS index (inner product on normalized vectors = cosine similarity)
|
| 178 |
+
dim = embeddings.shape[1]
|
| 179 |
+
index = faiss.IndexFlatIP(dim)
|
| 180 |
+
index.add(embeddings)
|
| 181 |
+
log.info("FAISS index built: %d vectors, %d dimensions", index.ntotal, dim)
|
| 182 |
+
|
| 183 |
+
# BM25 index
|
| 184 |
+
bm25 = BM25(corpus)
|
| 185 |
+
log.info("BM25 index built: %d documents, avgdl=%.1f", bm25.N, bm25.avgdl)
|
| 186 |
+
|
| 187 |
+
# Persist
|
| 188 |
+
faiss.write_index(index, str(faiss_path))
|
| 189 |
+
with open(corpus_path, "wb") as f:
|
| 190 |
+
pickle.dump(corpus, f)
|
| 191 |
+
with open(bm25_path, "wb") as f:
|
| 192 |
+
pickle.dump(bm25, f)
|
| 193 |
+
log.info("Index written to %s", idx_dir)
|
| 194 |
+
|
| 195 |
+
return index, corpus, bm25
|
src/explanation/rag_provider.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hybrid FAISS + BM25 retrieval provider for the heat alert explanation system.
|
| 3 |
+
|
| 4 |
+
Retrieves the most relevant knowledge base documents for a given query
|
| 5 |
+
using a weighted blend of dense (semantic) and sparse (lexical) scores,
|
| 6 |
+
with optional zone-based boosting.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import pickle
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
from src.explanation.rag_index_builder import INDEX_DIR
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class HybridRetriever:
|
| 24 |
+
"""Hybrid FAISS + BM25 retriever with zone boosting."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, index_dir: Optional[str | Path] = None):
|
| 27 |
+
import faiss
|
| 28 |
+
|
| 29 |
+
idx_dir = Path(index_dir) if index_dir else INDEX_DIR
|
| 30 |
+
|
| 31 |
+
faiss_path = idx_dir / "index.faiss"
|
| 32 |
+
corpus_path = idx_dir / "corpus.pkl"
|
| 33 |
+
bm25_path = idx_dir / "bm25.pkl"
|
| 34 |
+
|
| 35 |
+
if not faiss_path.exists():
|
| 36 |
+
raise FileNotFoundError(
|
| 37 |
+
f"FAISS index not found at {faiss_path}. "
|
| 38 |
+
"Run `python3 scripts/build_rag_index.py` first."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
self.index = faiss.read_index(str(faiss_path))
|
| 42 |
+
with open(corpus_path, "rb") as f:
|
| 43 |
+
self.corpus: list[str] = pickle.load(f)
|
| 44 |
+
with open(bm25_path, "rb") as f:
|
| 45 |
+
self.bm25 = pickle.load(f)
|
| 46 |
+
|
| 47 |
+
# Lazy-loaded embedding model (only loaded on first query)
|
| 48 |
+
self._model = None
|
| 49 |
+
|
| 50 |
+
def _get_model(self):
|
| 51 |
+
if self._model is None:
|
| 52 |
+
from sentence_transformers import SentenceTransformer
|
| 53 |
+
self._model = SentenceTransformer("BAAI/bge-base-en-v1.5")
|
| 54 |
+
return self._model
|
| 55 |
+
|
| 56 |
+
def _embed(self, text: str) -> np.ndarray:
|
| 57 |
+
"""Encode a query string into a normalized embedding vector."""
|
| 58 |
+
model = self._get_model()
|
| 59 |
+
vec = model.encode([text], normalize_embeddings=True)
|
| 60 |
+
return np.array(vec, dtype="float32")
|
| 61 |
+
|
| 62 |
+
def retrieve(
|
| 63 |
+
self,
|
| 64 |
+
query: str,
|
| 65 |
+
zone_id: Optional[str] = None,
|
| 66 |
+
top_k: int = 5,
|
| 67 |
+
threshold: float = 0.35,
|
| 68 |
+
alpha: float = 0.5,
|
| 69 |
+
) -> list[str]:
|
| 70 |
+
"""Hybrid retrieval: alpha * faiss_score + (1 - alpha) * bm25_score.
|
| 71 |
+
|
| 72 |
+
Parameters
|
| 73 |
+
----------
|
| 74 |
+
query : str
|
| 75 |
+
Natural language query.
|
| 76 |
+
zone_id : str, optional
|
| 77 |
+
If provided, documents mentioning this zone get a +0.1 boost.
|
| 78 |
+
top_k : int
|
| 79 |
+
Maximum number of documents to return.
|
| 80 |
+
threshold : float
|
| 81 |
+
Minimum blended score to include a document.
|
| 82 |
+
alpha : float
|
| 83 |
+
Weight for dense (FAISS) vs sparse (BM25). 0.5 = equal blend.
|
| 84 |
+
|
| 85 |
+
Returns
|
| 86 |
+
-------
|
| 87 |
+
list[str]
|
| 88 |
+
Retrieved document texts, ranked by blended score.
|
| 89 |
+
"""
|
| 90 |
+
n_docs = len(self.corpus)
|
| 91 |
+
search_k = min(20, n_docs)
|
| 92 |
+
|
| 93 |
+
# 1. Dense retrieval (FAISS cosine similarity via inner product)
|
| 94 |
+
q_vec = self._embed(query)
|
| 95 |
+
D, I = self.index.search(q_vec.reshape(1, -1), search_k)
|
| 96 |
+
|
| 97 |
+
# 2. Sparse retrieval (BM25)
|
| 98 |
+
bm25_scores = self.bm25.scores(query)
|
| 99 |
+
|
| 100 |
+
# 3. Normalize BM25 scores to [0, 1]
|
| 101 |
+
max_bm25 = max(bm25_scores) if bm25_scores and max(bm25_scores) > 0 else 1.0
|
| 102 |
+
|
| 103 |
+
# 4. Blend scores
|
| 104 |
+
blended: dict[int, float] = {}
|
| 105 |
+
|
| 106 |
+
# Add FAISS scores
|
| 107 |
+
for idx, score in zip(I[0], D[0]):
|
| 108 |
+
idx = int(idx)
|
| 109 |
+
if idx < 0: # FAISS returns -1 for missing entries
|
| 110 |
+
continue
|
| 111 |
+
blended[idx] = alpha * float(score)
|
| 112 |
+
|
| 113 |
+
# Add BM25 scores
|
| 114 |
+
for idx, score in enumerate(bm25_scores):
|
| 115 |
+
norm_score = score / max_bm25
|
| 116 |
+
blended[idx] = blended.get(idx, 0.0) + (1 - alpha) * norm_score
|
| 117 |
+
|
| 118 |
+
# 5. Zone boosting
|
| 119 |
+
if zone_id:
|
| 120 |
+
zone_lower = zone_id.lower()
|
| 121 |
+
for idx in blended:
|
| 122 |
+
if zone_lower in self.corpus[idx].lower():
|
| 123 |
+
blended[idx] += 0.1
|
| 124 |
+
|
| 125 |
+
# 6. Rank, filter, return
|
| 126 |
+
ranked = sorted(blended.items(), key=lambda x: x[1], reverse=True)
|
| 127 |
+
results = [
|
| 128 |
+
self.corpus[idx]
|
| 129 |
+
for idx, score in ranked[:top_k]
|
| 130 |
+
if score >= threshold
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
log.debug(
|
| 134 |
+
"Retrieved %d docs for query '%s' (top score: %.3f)",
|
| 135 |
+
len(results),
|
| 136 |
+
query[:60],
|
| 137 |
+
ranked[0][1] if ranked else 0.0,
|
| 138 |
+
)
|
| 139 |
+
return results
|
src/healing/healer.py
CHANGED
|
@@ -23,7 +23,7 @@ from typing import Any
|
|
| 23 |
|
| 24 |
import anthropic
|
| 25 |
|
| 26 |
-
from config import ZONE_MAP, ZONES,
|
| 27 |
|
| 28 |
log = logging.getLogger(__name__)
|
| 29 |
|
|
@@ -446,7 +446,7 @@ def _tool_seasonal_context(zone_id: str, month: int) -> dict[str, Any]:
|
|
| 446 |
return {"error": f"Unknown zone_id: {zone_id}"}
|
| 447 |
|
| 448 |
city = zone.city
|
| 449 |
-
seasons =
|
| 450 |
|
| 451 |
in_rainy_season = False
|
| 452 |
season_name = "dry season"
|
|
|
|
| 23 |
|
| 24 |
import anthropic
|
| 25 |
|
| 26 |
+
from config import ZONE_MAP, ZONES, HOT_SEASONS
|
| 27 |
|
| 28 |
log = logging.getLogger(__name__)
|
| 29 |
|
|
|
|
| 446 |
return {"error": f"Unknown zone_id: {zone_id}"}
|
| 447 |
|
| 448 |
city = zone.city
|
| 449 |
+
seasons = HOT_SEASONS.get(city, {})
|
| 450 |
|
| 451 |
in_rainy_season = False
|
| 452 |
season_name = "dry season"
|
src/ingestion/era5_fetcher.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ERA5 reanalysis data fetcher via Google ARCO Zarr store.
|
| 3 |
+
|
| 4 |
+
Fetches 2m temperature, dewpoint, wind (u/v), precipitation, and
|
| 5 |
+
solar radiation from the public ERA5 ARCO archive on GCS. Hourly
|
| 6 |
+
data is aggregated to daily readings per zone. Results are cached
|
| 7 |
+
as Parquet files under data/era5_cache/.
|
| 8 |
+
|
| 9 |
+
Data source:
|
| 10 |
+
gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3
|
| 11 |
+
(0.25° resolution, hourly, anonymous access)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import logging
|
| 18 |
+
import math
|
| 19 |
+
import time
|
| 20 |
+
from datetime import date, timedelta
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import xarray as xr
|
| 25 |
+
|
| 26 |
+
from config import UrbanZone
|
| 27 |
+
from .models import DailyReading
|
| 28 |
+
|
| 29 |
+
log = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Constants
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
ARCO_ZARR_URL = "gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3"
|
| 36 |
+
CACHE_DIR = Path(__file__).resolve().parents[2] / "data" / "era5_cache"
|
| 37 |
+
CACHE_STALE_SECONDS = 86_400 # 24 hours
|
| 38 |
+
|
| 39 |
+
# ERA5 has ~5-7 day processing lag
|
| 40 |
+
ERA5_LAG_DAYS = 7
|
| 41 |
+
|
| 42 |
+
# Variable name mapping: canonical name -> possible ARCO store names
|
| 43 |
+
# The first match found in ds.data_vars wins.
|
| 44 |
+
_VAR_CANDIDATES = {
|
| 45 |
+
"t2m": ["2m_temperature", "t2m", "2t", "temperature_2m"],
|
| 46 |
+
"d2m": ["2m_dewpoint_temperature", "d2m", "2d", "dewpoint_temperature_2m"],
|
| 47 |
+
"u10": ["10m_u_component_of_wind", "u10", "10u"],
|
| 48 |
+
"v10": ["10m_v_component_of_wind", "v10", "10v"],
|
| 49 |
+
"tp": ["total_precipitation", "tp"],
|
| 50 |
+
"ssrd": ["surface_solar_radiation_downwards", "ssrd"],
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
# Helpers
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
def _resolve_var(ds: xr.Dataset, canonical: str) -> str | None:
|
| 58 |
+
"""Find the actual variable name in the dataset for a canonical key."""
|
| 59 |
+
for candidate in _VAR_CANDIDATES[canonical]:
|
| 60 |
+
if candidate in ds.data_vars:
|
| 61 |
+
return candidate
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _resolve_all_vars(ds: xr.Dataset) -> dict[str, str]:
|
| 66 |
+
"""Resolve all canonical variable names. Raises if any required var is missing."""
|
| 67 |
+
resolved: dict[str, str] = {}
|
| 68 |
+
missing: list[str] = []
|
| 69 |
+
for canonical in _VAR_CANDIDATES:
|
| 70 |
+
actual = _resolve_var(ds, canonical)
|
| 71 |
+
if actual is None:
|
| 72 |
+
missing.append(canonical)
|
| 73 |
+
else:
|
| 74 |
+
resolved[canonical] = actual
|
| 75 |
+
if missing:
|
| 76 |
+
available = sorted(ds.data_vars)
|
| 77 |
+
raise KeyError(
|
| 78 |
+
f"ERA5 ARCO store missing variables: {missing}. "
|
| 79 |
+
f"Available: {available[:30]}"
|
| 80 |
+
)
|
| 81 |
+
return resolved
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _magnus_rh(temp_c: np.ndarray, dewpoint_c: np.ndarray) -> np.ndarray:
|
| 85 |
+
"""Relative humidity (%) via the Magnus formula."""
|
| 86 |
+
a, b = 17.625, 243.04
|
| 87 |
+
rh = 100.0 * np.exp(a * dewpoint_c / (b + dewpoint_c)) / np.exp(a * temp_c / (b + temp_c))
|
| 88 |
+
return np.clip(rh, 0.0, 100.0)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _cache_path(zone_id: str) -> Path:
|
| 92 |
+
return CACHE_DIR / f"{zone_id}.parquet"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _cache_is_fresh(path: Path, start: date, end: date) -> bool:
|
| 96 |
+
"""Check if cached Parquet covers the requested range and is recent."""
|
| 97 |
+
if not path.exists():
|
| 98 |
+
return False
|
| 99 |
+
# Stale if file older than 24h
|
| 100 |
+
age = time.time() - path.stat().st_mtime
|
| 101 |
+
if age > CACHE_STALE_SECONDS:
|
| 102 |
+
return False
|
| 103 |
+
# Check date coverage
|
| 104 |
+
try:
|
| 105 |
+
import pyarrow.parquet as pq
|
| 106 |
+
table = pq.read_table(path)
|
| 107 |
+
df = table.to_pandas()
|
| 108 |
+
if df.empty:
|
| 109 |
+
return False
|
| 110 |
+
cached_min = df["date"].min()
|
| 111 |
+
cached_max = df["date"].max()
|
| 112 |
+
return cached_min <= start.isoformat() and cached_max >= end.isoformat()
|
| 113 |
+
except Exception:
|
| 114 |
+
return False
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _write_cache(zone_id: str, readings: list[DailyReading]) -> None:
|
| 118 |
+
"""Persist readings as Parquet."""
|
| 119 |
+
if not readings:
|
| 120 |
+
return
|
| 121 |
+
try:
|
| 122 |
+
import pyarrow as pa
|
| 123 |
+
import pyarrow.parquet as pq
|
| 124 |
+
|
| 125 |
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 126 |
+
rows = []
|
| 127 |
+
for r in readings:
|
| 128 |
+
rows.append({
|
| 129 |
+
"zone_id": r.zone_id,
|
| 130 |
+
"date": r.date,
|
| 131 |
+
"precip_mm": r.precip_mm,
|
| 132 |
+
"temp_mean_c": r.temp_mean_c,
|
| 133 |
+
"temp_max_c": r.temp_max_c,
|
| 134 |
+
"temp_min_c": r.temp_min_c,
|
| 135 |
+
"humidity_pct": r.humidity_pct,
|
| 136 |
+
"wind_speed_ms": r.wind_speed_ms,
|
| 137 |
+
"source": r.source,
|
| 138 |
+
"data_quality": r.data_quality,
|
| 139 |
+
})
|
| 140 |
+
table = pa.Table.from_pylist(rows)
|
| 141 |
+
pq.write_table(table, _cache_path(zone_id))
|
| 142 |
+
except Exception as exc:
|
| 143 |
+
log.warning("ERA5 cache write failed for %s: %s", zone_id, exc)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _read_cache(zone_id: str, start: date, end: date) -> list[DailyReading] | None:
|
| 147 |
+
"""Read cached readings if available and fresh."""
|
| 148 |
+
path = _cache_path(zone_id)
|
| 149 |
+
if not _cache_is_fresh(path, start, end):
|
| 150 |
+
return None
|
| 151 |
+
try:
|
| 152 |
+
import pyarrow.parquet as pq
|
| 153 |
+
table = pq.read_table(path)
|
| 154 |
+
df = table.to_pandas()
|
| 155 |
+
# Filter to requested range
|
| 156 |
+
df = df[(df["date"] >= start.isoformat()) & (df["date"] <= end.isoformat())]
|
| 157 |
+
readings = []
|
| 158 |
+
for _, row in df.iterrows():
|
| 159 |
+
readings.append(DailyReading(
|
| 160 |
+
zone_id=row["zone_id"],
|
| 161 |
+
date=row["date"],
|
| 162 |
+
precip_mm=row.get("precip_mm"),
|
| 163 |
+
temp_mean_c=row.get("temp_mean_c"),
|
| 164 |
+
temp_max_c=row.get("temp_max_c"),
|
| 165 |
+
temp_min_c=row.get("temp_min_c"),
|
| 166 |
+
humidity_pct=row.get("humidity_pct"),
|
| 167 |
+
wind_speed_ms=row.get("wind_speed_ms"),
|
| 168 |
+
source=row.get("source", "era5"),
|
| 169 |
+
data_quality=row.get("data_quality", 0.0),
|
| 170 |
+
))
|
| 171 |
+
log.info("ERA5 cache hit for zone %s: %d readings", zone_id, len(readings))
|
| 172 |
+
return readings
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
log.warning("ERA5 cache read failed for %s: %s", zone_id, exc)
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
# Core fetch + aggregation
|
| 180 |
+
# ---------------------------------------------------------------------------
|
| 181 |
+
|
| 182 |
+
def _open_arco_store() -> xr.Dataset:
|
| 183 |
+
"""Open the ARCO ERA5 Zarr store (lazy, no data loaded)."""
|
| 184 |
+
import gcsfs
|
| 185 |
+
fs = gcsfs.GCSFileSystem(token="anon")
|
| 186 |
+
store = fs.get_mapper(ARCO_ZARR_URL)
|
| 187 |
+
ds = xr.open_zarr(store, consolidated=True)
|
| 188 |
+
log.info(
|
| 189 |
+
"ERA5 ARCO store opened — %d variables, dims: %s",
|
| 190 |
+
len(ds.data_vars), dict(ds.sizes),
|
| 191 |
+
)
|
| 192 |
+
return ds
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _fetch_zone_data(
|
| 196 |
+
ds: xr.Dataset,
|
| 197 |
+
var_names: dict[str, str],
|
| 198 |
+
zone: UrbanZone,
|
| 199 |
+
start: date,
|
| 200 |
+
end: date,
|
| 201 |
+
) -> list[DailyReading]:
|
| 202 |
+
"""Extract and aggregate hourly ERA5 data for one zone."""
|
| 203 |
+
|
| 204 |
+
lat = zone.latitude
|
| 205 |
+
lon = zone.longitude
|
| 206 |
+
# ERA5 ARCO uses 0-360 longitude; East African coords (30-40°E) are
|
| 207 |
+
# already positive, but handle negative longitudes just in case.
|
| 208 |
+
if lon < 0:
|
| 209 |
+
lon += 360.0
|
| 210 |
+
|
| 211 |
+
# Time range as strings (ARCO store uses naive numpy datetime64)
|
| 212 |
+
t_start = str(start) # "YYYY-MM-DD"
|
| 213 |
+
t_end = str(end)
|
| 214 |
+
|
| 215 |
+
# Select nearest grid point and time slice for all vars at once
|
| 216 |
+
try:
|
| 217 |
+
point = ds[list(var_names.values())].sel(
|
| 218 |
+
latitude=lat,
|
| 219 |
+
longitude=lon,
|
| 220 |
+
method="nearest",
|
| 221 |
+
).sel(time=slice(t_start, t_end))
|
| 222 |
+
|
| 223 |
+
# Load only the selected slice into memory
|
| 224 |
+
point = point.load()
|
| 225 |
+
except Exception as exc:
|
| 226 |
+
log.error("ERA5 data extraction failed for zone %s: %s", zone.zone_id, exc)
|
| 227 |
+
return []
|
| 228 |
+
|
| 229 |
+
# Convert to daily
|
| 230 |
+
readings: list[DailyReading] = []
|
| 231 |
+
|
| 232 |
+
# Get the raw arrays
|
| 233 |
+
t2m_name = var_names["t2m"]
|
| 234 |
+
d2m_name = var_names["d2m"]
|
| 235 |
+
u10_name = var_names["u10"]
|
| 236 |
+
v10_name = var_names["v10"]
|
| 237 |
+
tp_name = var_names["tp"]
|
| 238 |
+
ssrd_name = var_names["ssrd"]
|
| 239 |
+
|
| 240 |
+
# Group by day
|
| 241 |
+
daily = point.resample(time="1D")
|
| 242 |
+
|
| 243 |
+
for day_label, day_group in daily:
|
| 244 |
+
if day_group.sizes.get("time", 0) == 0:
|
| 245 |
+
continue
|
| 246 |
+
|
| 247 |
+
day_str = str(day_label.date()) if hasattr(day_label, 'date') else str(day_label)[:10]
|
| 248 |
+
|
| 249 |
+
try:
|
| 250 |
+
# Temperature: Kelvin -> Celsius
|
| 251 |
+
t2m_vals = day_group[t2m_name].values - 273.15
|
| 252 |
+
d2m_vals = day_group[d2m_name].values - 273.15
|
| 253 |
+
|
| 254 |
+
# Skip days where all temperature data is NaN (beyond ERA5 lag)
|
| 255 |
+
if np.all(np.isnan(t2m_vals)):
|
| 256 |
+
continue
|
| 257 |
+
|
| 258 |
+
temp_max = float(np.nanmax(t2m_vals))
|
| 259 |
+
temp_mean = float(np.nanmean(t2m_vals))
|
| 260 |
+
temp_min = float(np.nanmin(t2m_vals))
|
| 261 |
+
|
| 262 |
+
# Relative humidity from Magnus formula
|
| 263 |
+
rh_vals = _magnus_rh(t2m_vals, d2m_vals)
|
| 264 |
+
humidity = float(np.nanmean(rh_vals))
|
| 265 |
+
|
| 266 |
+
# Wind speed from u and v components
|
| 267 |
+
u_vals = day_group[u10_name].values
|
| 268 |
+
v_vals = day_group[v10_name].values
|
| 269 |
+
wind_speed = float(np.nanmean(np.sqrt(u_vals**2 + v_vals**2)))
|
| 270 |
+
|
| 271 |
+
# Precipitation: cumulative hourly in meters -> sum in mm
|
| 272 |
+
tp_vals = day_group[tp_name].values
|
| 273 |
+
# ERA5 precip can have tiny negative artifacts — clip to 0
|
| 274 |
+
tp_vals = np.clip(tp_vals, 0, None)
|
| 275 |
+
precip_mm = float(np.nansum(tp_vals) * 1000.0)
|
| 276 |
+
|
| 277 |
+
# Solar radiation: cumulative hourly J/m² -> average W/m²
|
| 278 |
+
ssrd_vals = day_group[ssrd_name].values
|
| 279 |
+
ssrd_vals = np.clip(ssrd_vals, 0, None)
|
| 280 |
+
# Each hourly value is J/m² accumulated over 1 hour (3600s)
|
| 281 |
+
# Convert to W/m² = J/m² / 3600s, then take daily mean
|
| 282 |
+
solar_wm2 = float(np.nanmean(ssrd_vals / 3600.0))
|
| 283 |
+
|
| 284 |
+
# Count valid fields for quality score
|
| 285 |
+
fields = [temp_max, temp_mean, temp_min, humidity, wind_speed, precip_mm, solar_wm2]
|
| 286 |
+
present = sum(1 for f in fields if f is not None and not math.isnan(f))
|
| 287 |
+
quality = present / len(fields)
|
| 288 |
+
|
| 289 |
+
readings.append(DailyReading(
|
| 290 |
+
zone_id=zone.zone_id,
|
| 291 |
+
date=day_str,
|
| 292 |
+
precip_mm=round(precip_mm, 2),
|
| 293 |
+
temp_mean_c=round(temp_mean, 2),
|
| 294 |
+
temp_max_c=round(temp_max, 2),
|
| 295 |
+
temp_min_c=round(temp_min, 2),
|
| 296 |
+
humidity_pct=round(humidity, 1),
|
| 297 |
+
wind_speed_ms=round(wind_speed, 2),
|
| 298 |
+
source="era5",
|
| 299 |
+
data_quality=round(quality, 2),
|
| 300 |
+
))
|
| 301 |
+
except Exception as exc:
|
| 302 |
+
log.warning("ERA5 daily aggregation failed for %s on %s: %s",
|
| 303 |
+
zone.zone_id, day_str, exc)
|
| 304 |
+
continue
|
| 305 |
+
|
| 306 |
+
log.info(
|
| 307 |
+
"ERA5: zone %s — %d days aggregated (%.1f-%.1f°C range)",
|
| 308 |
+
zone.zone_id,
|
| 309 |
+
len(readings),
|
| 310 |
+
min(r.temp_mean_c for r in readings) if readings else 0,
|
| 311 |
+
max(r.temp_max_c for r in readings) if readings else 0,
|
| 312 |
+
)
|
| 313 |
+
return readings
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# ---------------------------------------------------------------------------
|
| 317 |
+
# Public API
|
| 318 |
+
# ---------------------------------------------------------------------------
|
| 319 |
+
|
| 320 |
+
async def fetch_era5_zones(
|
| 321 |
+
zones: list[UrbanZone],
|
| 322 |
+
days_back: int = 90,
|
| 323 |
+
) -> dict[str, list[DailyReading]]:
|
| 324 |
+
"""Fetch ERA5 data for all zones, return dict of zone_id -> readings.
|
| 325 |
+
|
| 326 |
+
Opens the ARCO Zarr store once and extracts data for every zone.
|
| 327 |
+
Uses Parquet caching to avoid redundant network calls.
|
| 328 |
+
"""
|
| 329 |
+
end = date.today() - timedelta(days=ERA5_LAG_DAYS)
|
| 330 |
+
start = end - timedelta(days=days_back - 1)
|
| 331 |
+
|
| 332 |
+
# Check cache first for each zone
|
| 333 |
+
result: dict[str, list[DailyReading]] = {}
|
| 334 |
+
zones_to_fetch: list[UrbanZone] = []
|
| 335 |
+
|
| 336 |
+
for zone in zones:
|
| 337 |
+
cached = _read_cache(zone.zone_id, start, end)
|
| 338 |
+
if cached is not None:
|
| 339 |
+
result[zone.zone_id] = cached
|
| 340 |
+
else:
|
| 341 |
+
zones_to_fetch.append(zone)
|
| 342 |
+
|
| 343 |
+
if not zones_to_fetch:
|
| 344 |
+
log.info("ERA5: all %d zones served from cache", len(zones))
|
| 345 |
+
return result
|
| 346 |
+
|
| 347 |
+
# Open store and fetch remaining zones
|
| 348 |
+
try:
|
| 349 |
+
loop = asyncio.get_event_loop()
|
| 350 |
+
ds, var_names = await loop.run_in_executor(None, _open_and_resolve)
|
| 351 |
+
|
| 352 |
+
for zone in zones_to_fetch:
|
| 353 |
+
try:
|
| 354 |
+
readings = await loop.run_in_executor(
|
| 355 |
+
None, _fetch_zone_data, ds, var_names, zone, start, end,
|
| 356 |
+
)
|
| 357 |
+
result[zone.zone_id] = readings
|
| 358 |
+
_write_cache(zone.zone_id, readings)
|
| 359 |
+
except Exception as exc:
|
| 360 |
+
log.warning("ERA5 fetch failed for zone %s: %s", zone.zone_id, exc)
|
| 361 |
+
result[zone.zone_id] = []
|
| 362 |
+
|
| 363 |
+
except Exception as exc:
|
| 364 |
+
log.error("ERA5 ARCO store access failed: %s", exc)
|
| 365 |
+
# Return whatever we got from cache, empty for the rest
|
| 366 |
+
for zone in zones_to_fetch:
|
| 367 |
+
result.setdefault(zone.zone_id, [])
|
| 368 |
+
|
| 369 |
+
total_readings = sum(len(v) for v in result.values())
|
| 370 |
+
zones_with_data = sum(1 for v in result.values() if v)
|
| 371 |
+
log.info(
|
| 372 |
+
"ERA5 batch complete: %d/%d zones with data, %d total readings",
|
| 373 |
+
zones_with_data, len(zones), total_readings,
|
| 374 |
+
)
|
| 375 |
+
return result
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _open_and_resolve() -> tuple[xr.Dataset, dict[str, str]]:
|
| 379 |
+
"""Open the store and resolve variable names (blocking, for executor)."""
|
| 380 |
+
ds = _open_arco_store()
|
| 381 |
+
var_names = _resolve_all_vars(ds)
|
| 382 |
+
log.info("ERA5 variable mapping: %s", var_names)
|
| 383 |
+
return ds, var_names
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def fetch_era5_sync(
|
| 387 |
+
zones: list[UrbanZone],
|
| 388 |
+
days_back: int = 90,
|
| 389 |
+
) -> dict[str, list[DailyReading]]:
|
| 390 |
+
"""Synchronous wrapper around fetch_era5_zones."""
|
| 391 |
+
try:
|
| 392 |
+
loop = asyncio.get_running_loop()
|
| 393 |
+
except RuntimeError:
|
| 394 |
+
loop = None
|
| 395 |
+
|
| 396 |
+
if loop and loop.is_running():
|
| 397 |
+
# Already inside an event loop — create a new one in a thread
|
| 398 |
+
import concurrent.futures
|
| 399 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
| 400 |
+
future = pool.submit(asyncio.run, fetch_era5_zones(zones, days_back))
|
| 401 |
+
return future.result()
|
| 402 |
+
else:
|
| 403 |
+
return asyncio.run(fetch_era5_zones(zones, days_back))
|
src/ingestion/pipeline_ingest.py
CHANGED
|
@@ -18,7 +18,6 @@ from typing import Any
|
|
| 18 |
|
| 19 |
from config import UrbanZone, ZONES
|
| 20 |
|
| 21 |
-
from .chirps import fetch_all_zones_chirps
|
| 22 |
from .models import DailyReading, IngestedData
|
| 23 |
from .nasa_power import fetch_all_zones_nasa_power
|
| 24 |
|
|
@@ -229,11 +228,7 @@ async def run_ingestion(
|
|
| 229 |
if not skip_nasa
|
| 230 |
else _empty_result(zones)
|
| 231 |
)
|
| 232 |
-
chirps_task = (
|
| 233 |
-
fetch_all_zones_chirps(zones, start_date, end_date)
|
| 234 |
-
if not skip_chirps
|
| 235 |
-
else _empty_result(zones)
|
| 236 |
-
)
|
| 237 |
|
| 238 |
nasa_results, chirps_results = await asyncio.gather(
|
| 239 |
nasa_task, chirps_task, return_exceptions=True,
|
|
@@ -309,3 +304,11 @@ def ingest_sync(
|
|
| 309 |
) -> list[IngestedData]:
|
| 310 |
"""Synchronous wrapper around run_ingestion for non-async callers."""
|
| 311 |
return asyncio.run(run_ingestion(zones, start_date, end_date))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
from config import UrbanZone, ZONES
|
| 20 |
|
|
|
|
| 21 |
from .models import DailyReading, IngestedData
|
| 22 |
from .nasa_power import fetch_all_zones_nasa_power
|
| 23 |
|
|
|
|
| 228 |
if not skip_nasa
|
| 229 |
else _empty_result(zones)
|
| 230 |
)
|
| 231 |
+
chirps_task = _empty_result(zones) # CHIRPS not yet implemented for heat
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
nasa_results, chirps_results = await asyncio.gather(
|
| 234 |
nasa_task, chirps_task, return_exceptions=True,
|
|
|
|
| 304 |
) -> list[IngestedData]:
|
| 305 |
"""Synchronous wrapper around run_ingestion for non-async callers."""
|
| 306 |
return asyncio.run(run_ingestion(zones, start_date, end_date))
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
async def ingest_all_zones(zones, days_back=90):
|
| 310 |
+
"""Convenience wrapper matching the pipeline's expected interface."""
|
| 311 |
+
from datetime import date, timedelta
|
| 312 |
+
end = date.today()
|
| 313 |
+
start = end - timedelta(days=days_back)
|
| 314 |
+
return await run_ingestion(zones, start_date=start, end_date=end)
|
src/pipeline.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
Climate Risk Index Engine — Main Pipeline Orchestrator
|
| 3 |
|
| 4 |
-
6-step pipeline: INGEST → HEAL →
|
| 5 |
|
| 6 |
-
Mirrors the architecture of Weather AI 2 but for urban flood parametric insurance.
|
| 7 |
Each step has independent fallbacks — no cascading failures.
|
| 8 |
"""
|
| 9 |
|
|
@@ -14,14 +13,15 @@ import uuid
|
|
| 14 |
from dataclasses import dataclass, field
|
| 15 |
from datetime import datetime, timedelta
|
| 16 |
|
| 17 |
-
from config import ZONES, ZONE_MAP, PIPELINE_STEPS,
|
| 18 |
|
| 19 |
from src.ingestion.pipeline_ingest import ingest_all_zones
|
| 20 |
from src.ingestion.models import IngestedData, DailyReading
|
| 21 |
from src.healing.healer import HealingAgent, RuleBasedFallback, HealedData
|
| 22 |
-
from src.indexing.
|
| 23 |
-
from src.indexing.
|
| 24 |
-
from src.
|
|
|
|
| 25 |
from src.calibration.basis_risk import assess_all_zones
|
| 26 |
from src.explanation.explainer import TriggerExplainer, TemplateExplainer
|
| 27 |
from src.notification.sender import create_sender
|
|
@@ -53,16 +53,16 @@ class PipelineRunResult:
|
|
| 53 |
duration_s: float
|
| 54 |
|
| 55 |
|
| 56 |
-
class
|
| 57 |
"""
|
| 58 |
-
End-to-end urban
|
| 59 |
|
| 60 |
-
Step 1 (INGEST): Fetch
|
| 61 |
Step 2 (HEAL): AI agent validates and repairs data anomalies
|
| 62 |
-
Step 3 (
|
| 63 |
-
Step 4 (
|
| 64 |
-
Step 5 (EXPLAIN): Generate
|
| 65 |
-
Step 6 (NOTIFY): Deliver alerts to
|
| 66 |
"""
|
| 67 |
|
| 68 |
def __init__(
|
|
@@ -79,10 +79,14 @@ class FloodRiskPipeline:
|
|
| 79 |
self.delivery_channel = delivery_channel
|
| 80 |
self.db = db
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
# Pipeline state
|
| 83 |
self._ingested: dict[str, IngestedData] = {}
|
| 84 |
self._healed: dict[str, HealedData] = {}
|
| 85 |
-
self.
|
| 86 |
self._triggers: list = []
|
| 87 |
self._basis_risk: dict = {}
|
| 88 |
self._explanations: list = []
|
|
@@ -109,12 +113,12 @@ class FloodRiskPipeline:
|
|
| 109 |
steps.append(step2)
|
| 110 |
total_cost += step2.details.get("cost_usd", 0)
|
| 111 |
|
| 112 |
-
# Step 3:
|
| 113 |
-
step3 = await self.
|
| 114 |
steps.append(step3)
|
| 115 |
|
| 116 |
-
# Step 4:
|
| 117 |
-
step4 = await self.
|
| 118 |
steps.append(step4)
|
| 119 |
|
| 120 |
# Step 5: EXPLAIN (only if triggers found)
|
|
@@ -203,8 +207,7 @@ class FloodRiskPipeline:
|
|
| 203 |
self._healed[zone_id] = healed
|
| 204 |
errors.append(f"{zone_id}: {e}")
|
| 205 |
|
| 206 |
-
|
| 207 |
-
est_cost = total_tokens * 0.005 / 1000 # rough average
|
| 208 |
|
| 209 |
ok_count = len(self._healed)
|
| 210 |
return StepResult(
|
|
@@ -229,126 +232,128 @@ class FloodRiskPipeline:
|
|
| 229 |
errors=[str(e)],
|
| 230 |
)
|
| 231 |
|
| 232 |
-
# ── Step 3:
|
| 233 |
|
| 234 |
-
async def
|
| 235 |
t0 = time.time()
|
| 236 |
errors = []
|
| 237 |
-
all_triggers = []
|
| 238 |
|
| 239 |
try:
|
| 240 |
for zone_id, healed in self._healed.items():
|
| 241 |
zone = ZONE_MAP.get(zone_id)
|
| 242 |
-
if not zone:
|
| 243 |
-
continue
|
| 244 |
-
|
| 245 |
-
readings = healed.readings
|
| 246 |
-
if not readings:
|
| 247 |
-
continue
|
| 248 |
-
|
| 249 |
-
# Extract daily precipitation series
|
| 250 |
-
daily_precip = []
|
| 251 |
-
daily_dates = []
|
| 252 |
-
for r in readings:
|
| 253 |
-
p = getattr(r, "precip_mm", None) or getattr(r, "healed_precip", None)
|
| 254 |
-
d = getattr(r, "date", None)
|
| 255 |
-
if p is not None and d is not None:
|
| 256 |
-
daily_precip.append(float(p))
|
| 257 |
-
daily_dates.append(str(d))
|
| 258 |
-
|
| 259 |
-
if len(daily_precip) < 30:
|
| 260 |
-
errors.append(f"{zone_id}: insufficient data ({len(daily_precip)} days)")
|
| 261 |
continue
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
zone=zone,
|
| 279 |
-
)
|
| 280 |
-
|
| 281 |
-
# Trigger detection
|
| 282 |
-
triggers = detect_triggers(
|
| 283 |
-
zone_id=zone_id,
|
| 284 |
-
zone_name=zone.name,
|
| 285 |
-
city=zone.city,
|
| 286 |
-
daily_precip=daily_precip,
|
| 287 |
-
daily_dates=daily_dates,
|
| 288 |
-
spi_latest=spi_results.get("spi_1_latest", 0),
|
| 289 |
-
api_5day=api_results.get("api_5day_latest", 0),
|
| 290 |
-
flood_risk_score=risk_scores.get("latest_score", 0),
|
| 291 |
-
settlement_type=zone.settlement_type,
|
| 292 |
-
)
|
| 293 |
-
|
| 294 |
-
self._indices[zone_id] = {
|
| 295 |
-
"spi": spi_results,
|
| 296 |
-
"antecedent": api_results,
|
| 297 |
-
"flood_risk": risk_scores,
|
| 298 |
-
"triggers": triggers,
|
| 299 |
}
|
| 300 |
-
all_triggers.extend(triggers)
|
| 301 |
-
|
| 302 |
-
self._triggers = all_triggers
|
| 303 |
|
| 304 |
return StepResult(
|
| 305 |
-
step="
|
| 306 |
duration_s=time.time() - t0,
|
| 307 |
-
records_processed=len(self.
|
| 308 |
-
errors=errors,
|
| 309 |
details={
|
| 310 |
-
"
|
| 311 |
-
"
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
for level in ["critical", "warning", "watch"]
|
| 315 |
-
},
|
| 316 |
},
|
| 317 |
)
|
| 318 |
except Exception as e:
|
| 319 |
-
logger.exception(f"
|
| 320 |
return StepResult(
|
| 321 |
-
step="
|
| 322 |
errors=[str(e)],
|
| 323 |
)
|
| 324 |
|
| 325 |
-
# ── Step 4:
|
| 326 |
|
| 327 |
-
async def
|
| 328 |
t0 = time.time()
|
|
|
|
|
|
|
|
|
|
| 329 |
try:
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
-
|
| 333 |
-
r["overall_score"] for r in self._basis_risk.values()
|
| 334 |
-
) / max(1, len(self._basis_risk))
|
| 335 |
|
| 336 |
return StepResult(
|
| 337 |
-
step="
|
| 338 |
duration_s=time.time() - t0,
|
| 339 |
-
records_processed=len(self.
|
| 340 |
details={
|
| 341 |
-
"
|
| 342 |
-
"
|
| 343 |
-
"
|
| 344 |
-
1 for
|
| 345 |
-
|
|
|
|
| 346 |
},
|
| 347 |
)
|
| 348 |
except Exception as e:
|
| 349 |
-
logger.exception(f"
|
| 350 |
return StepResult(
|
| 351 |
-
step="
|
| 352 |
errors=[str(e)],
|
| 353 |
)
|
| 354 |
|
|
@@ -421,8 +426,6 @@ class FloodRiskPipeline:
|
|
| 421 |
failed_count = 0
|
| 422 |
|
| 423 |
for explanation in self._explanations:
|
| 424 |
-
# In production, would look up enrolled policyholders per zone
|
| 425 |
-
# For demo, send to console
|
| 426 |
message = (
|
| 427 |
f"[{explanation.trigger_level.upper()}] "
|
| 428 |
f"{explanation.zone_name}, {explanation.city}\n"
|
|
@@ -510,8 +513,8 @@ class FloodRiskPipeline:
|
|
| 510 |
return self._healed
|
| 511 |
|
| 512 |
@property
|
| 513 |
-
def
|
| 514 |
-
return self.
|
| 515 |
|
| 516 |
@property
|
| 517 |
def triggers(self):
|
|
@@ -532,5 +535,5 @@ class FloodRiskPipeline:
|
|
| 532 |
|
| 533 |
def run_pipeline_sync(**kwargs) -> PipelineRunResult:
|
| 534 |
"""Synchronous wrapper for the pipeline."""
|
| 535 |
-
pipeline =
|
| 536 |
return asyncio.run(pipeline.run())
|
|
|
|
| 1 |
"""
|
| 2 |
Climate Risk Index Engine — Main Pipeline Orchestrator
|
| 3 |
|
| 4 |
+
6-step pipeline: INGEST → HEAL → DOWNSCALE → PREDICT → EXPLAIN → NOTIFY
|
| 5 |
|
|
|
|
| 6 |
Each step has independent fallbacks — no cascading failures.
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 13 |
from dataclasses import dataclass, field
|
| 14 |
from datetime import datetime, timedelta
|
| 15 |
|
| 16 |
+
from config import ZONES, ZONE_MAP, PIPELINE_STEPS, HEAT_THRESHOLDS, PAYOUT_PER_EVENT_USD
|
| 17 |
|
| 18 |
from src.ingestion.pipeline_ingest import ingest_all_zones
|
| 19 |
from src.ingestion.models import IngestedData, DailyReading
|
| 20 |
from src.healing.healer import HealingAgent, RuleBasedFallback, HealedData
|
| 21 |
+
from src.indexing.heat_risk import compute_heat_risk, _detect_heat_triggers as detect_triggers
|
| 22 |
+
from src.indexing.heat_index import calculate_wbgt, calculate_heat_index
|
| 23 |
+
from src.downscaling.uhi_model import UHICorrector
|
| 24 |
+
from src.prediction.heat_forecast import HeatWavePredictor
|
| 25 |
from src.calibration.basis_risk import assess_all_zones
|
| 26 |
from src.explanation.explainer import TriggerExplainer, TemplateExplainer
|
| 27 |
from src.notification.sender import create_sender
|
|
|
|
| 53 |
duration_s: float
|
| 54 |
|
| 55 |
|
| 56 |
+
class HeatRiskPipeline:
|
| 57 |
"""
|
| 58 |
+
End-to-end urban heat risk pipeline.
|
| 59 |
|
| 60 |
+
Step 1 (INGEST): Fetch climate data from ERA5-Land / NASA POWER
|
| 61 |
Step 2 (HEAL): AI agent validates and repairs data anomalies
|
| 62 |
+
Step 3 (DOWNSCALE): ML Urban Heat Island correction (XGBoost)
|
| 63 |
+
Step 4 (PREDICT): XGBoost + LSTM heat wave trigger probability (7-day)
|
| 64 |
+
Step 5 (EXPLAIN): Generate bilingual trigger explanations (RAG + Claude)
|
| 65 |
+
Step 6 (NOTIFY): Deliver alerts to workers via SMS/WhatsApp/console
|
| 66 |
"""
|
| 67 |
|
| 68 |
def __init__(
|
|
|
|
| 79 |
self.delivery_channel = delivery_channel
|
| 80 |
self.db = db
|
| 81 |
|
| 82 |
+
# ML models
|
| 83 |
+
self._uhi_corrector = UHICorrector()
|
| 84 |
+
self._predictor = HeatWavePredictor()
|
| 85 |
+
|
| 86 |
# Pipeline state
|
| 87 |
self._ingested: dict[str, IngestedData] = {}
|
| 88 |
self._healed: dict[str, HealedData] = {}
|
| 89 |
+
self._heat_data: dict[str, dict] = {}
|
| 90 |
self._triggers: list = []
|
| 91 |
self._basis_risk: dict = {}
|
| 92 |
self._explanations: list = []
|
|
|
|
| 113 |
steps.append(step2)
|
| 114 |
total_cost += step2.details.get("cost_usd", 0)
|
| 115 |
|
| 116 |
+
# Step 3: DOWNSCALE (UHI correction)
|
| 117 |
+
step3 = await self._step_downscale(run_id)
|
| 118 |
steps.append(step3)
|
| 119 |
|
| 120 |
+
# Step 4: PREDICT (heat wave trigger probability)
|
| 121 |
+
step4 = await self._step_predict(run_id)
|
| 122 |
steps.append(step4)
|
| 123 |
|
| 124 |
# Step 5: EXPLAIN (only if triggers found)
|
|
|
|
| 207 |
self._healed[zone_id] = healed
|
| 208 |
errors.append(f"{zone_id}: {e}")
|
| 209 |
|
| 210 |
+
est_cost = total_tokens * 0.005 / 1000
|
|
|
|
| 211 |
|
| 212 |
ok_count = len(self._healed)
|
| 213 |
return StepResult(
|
|
|
|
| 232 |
errors=[str(e)],
|
| 233 |
)
|
| 234 |
|
| 235 |
+
# ── Step 3: DOWNSCALE (UHI Correction) ─────────────────────────────────
|
| 236 |
|
| 237 |
+
async def _step_downscale(self, run_id: str) -> StepResult:
|
| 238 |
t0 = time.time()
|
| 239 |
errors = []
|
|
|
|
| 240 |
|
| 241 |
try:
|
| 242 |
for zone_id, healed in self._healed.items():
|
| 243 |
zone = ZONE_MAP.get(zone_id)
|
| 244 |
+
if not zone or not healed.readings:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
continue
|
| 246 |
|
| 247 |
+
corrected_temps = []
|
| 248 |
+
uhi_deltas = []
|
| 249 |
+
for reading in healed.readings:
|
| 250 |
+
grid_temp = getattr(reading, "temp_max_c", None) or getattr(reading, "healed_temp", 30.0)
|
| 251 |
+
month = getattr(reading, "date", datetime.utcnow()).month if hasattr(reading, "date") else 1
|
| 252 |
+
corrected, delta, _ = self._uhi_corrector.correct_temperature(
|
| 253 |
+
zone, float(grid_temp), hour=14, month=month
|
| 254 |
+
)
|
| 255 |
+
corrected_temps.append(corrected)
|
| 256 |
+
uhi_deltas.append(delta)
|
| 257 |
+
|
| 258 |
+
self._heat_data[zone_id] = {
|
| 259 |
+
"corrected_temps": corrected_temps,
|
| 260 |
+
"uhi_deltas": uhi_deltas,
|
| 261 |
+
"mean_uhi_delta": sum(uhi_deltas) / len(uhi_deltas) if uhi_deltas else 0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
}
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
return StepResult(
|
| 265 |
+
step="downscale", status="ok",
|
| 266 |
duration_s=time.time() - t0,
|
| 267 |
+
records_processed=sum(len(d["corrected_temps"]) for d in self._heat_data.values()),
|
|
|
|
| 268 |
details={
|
| 269 |
+
"zones_corrected": len(self._heat_data),
|
| 270 |
+
"mean_uhi_delta": round(
|
| 271 |
+
sum(d["mean_uhi_delta"] for d in self._heat_data.values()) / max(1, len(self._heat_data)), 2
|
| 272 |
+
),
|
|
|
|
|
|
|
| 273 |
},
|
| 274 |
)
|
| 275 |
except Exception as e:
|
| 276 |
+
logger.exception(f"Downscale step failed: {e}")
|
| 277 |
return StepResult(
|
| 278 |
+
step="downscale", status="failed", duration_s=time.time() - t0,
|
| 279 |
errors=[str(e)],
|
| 280 |
)
|
| 281 |
|
| 282 |
+
# ── Step 4: PREDICT ─────────────────────────────────────────��──────────
|
| 283 |
|
| 284 |
+
async def _step_predict(self, run_id: str) -> StepResult:
|
| 285 |
t0 = time.time()
|
| 286 |
+
errors = []
|
| 287 |
+
all_triggers = []
|
| 288 |
+
|
| 289 |
try:
|
| 290 |
+
for zone_id, healed in self._healed.items():
|
| 291 |
+
zone = ZONE_MAP.get(zone_id)
|
| 292 |
+
if not zone:
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
readings = healed.readings
|
| 296 |
+
if not readings:
|
| 297 |
+
continue
|
| 298 |
+
|
| 299 |
+
# Extract temperature series from healed data
|
| 300 |
+
temps = []
|
| 301 |
+
humidities = []
|
| 302 |
+
wbgts = []
|
| 303 |
+
for r in readings:
|
| 304 |
+
t = getattr(r, "temp_max_c", None) or getattr(r, "healed_temp", None)
|
| 305 |
+
h = getattr(r, "humidity_pct", None) or 65.0
|
| 306 |
+
if t is not None:
|
| 307 |
+
temps.append(float(t))
|
| 308 |
+
humidities.append(float(h))
|
| 309 |
+
wbgts.append(calculate_wbgt(float(t), float(h)))
|
| 310 |
+
|
| 311 |
+
# Apply UHI correction if available
|
| 312 |
+
heat = self._heat_data.get(zone_id, {})
|
| 313 |
+
corrected = heat.get("corrected_temps", temps)
|
| 314 |
+
|
| 315 |
+
# Get prediction
|
| 316 |
+
if len(corrected) >= 7:
|
| 317 |
+
prob, conf, tier = self._predictor.predict(
|
| 318 |
+
zone, corrected[-30:], humidities[-30:], wbgts[-30:]
|
| 319 |
+
)
|
| 320 |
+
else:
|
| 321 |
+
prob, conf, tier = 0.1, 0.3, "climatology"
|
| 322 |
+
|
| 323 |
+
self._heat_data.setdefault(zone_id, {}).update({
|
| 324 |
+
"trigger_probability": prob,
|
| 325 |
+
"prediction_confidence": conf,
|
| 326 |
+
"model_tier": tier,
|
| 327 |
+
"temps": corrected,
|
| 328 |
+
"humidities": humidities,
|
| 329 |
+
"wbgts": wbgts,
|
| 330 |
+
})
|
| 331 |
+
|
| 332 |
+
# Detect triggers from heat risk module
|
| 333 |
+
if len(corrected) >= 7:
|
| 334 |
+
result = compute_heat_risk(zone, corrected, humidities)
|
| 335 |
+
zone_triggers = detect_triggers(zone, result)
|
| 336 |
+
all_triggers.extend(zone_triggers)
|
| 337 |
|
| 338 |
+
self._triggers = all_triggers
|
|
|
|
|
|
|
| 339 |
|
| 340 |
return StepResult(
|
| 341 |
+
step="predict", status="ok",
|
| 342 |
duration_s=time.time() - t0,
|
| 343 |
+
records_processed=len(self._heat_data),
|
| 344 |
details={
|
| 345 |
+
"zones_predicted": len(self._heat_data),
|
| 346 |
+
"triggers_found": len(all_triggers),
|
| 347 |
+
"trigger_levels": {
|
| 348 |
+
level: sum(1 for t in all_triggers if t.trigger_level == level)
|
| 349 |
+
for level in ["critical", "warning", "watch"]
|
| 350 |
+
},
|
| 351 |
},
|
| 352 |
)
|
| 353 |
except Exception as e:
|
| 354 |
+
logger.exception(f"Prediction step failed: {e}")
|
| 355 |
return StepResult(
|
| 356 |
+
step="predict", status="failed", duration_s=time.time() - t0,
|
| 357 |
errors=[str(e)],
|
| 358 |
)
|
| 359 |
|
|
|
|
| 426 |
failed_count = 0
|
| 427 |
|
| 428 |
for explanation in self._explanations:
|
|
|
|
|
|
|
| 429 |
message = (
|
| 430 |
f"[{explanation.trigger_level.upper()}] "
|
| 431 |
f"{explanation.zone_name}, {explanation.city}\n"
|
|
|
|
| 513 |
return self._healed
|
| 514 |
|
| 515 |
@property
|
| 516 |
+
def heat_data(self):
|
| 517 |
+
return self._heat_data
|
| 518 |
|
| 519 |
@property
|
| 520 |
def triggers(self):
|
|
|
|
| 535 |
|
| 536 |
def run_pipeline_sync(**kwargs) -> PipelineRunResult:
|
| 537 |
"""Synchronous wrapper for the pipeline."""
|
| 538 |
+
pipeline = HeatRiskPipeline(**kwargs)
|
| 539 |
return asyncio.run(pipeline.run())
|
src/prediction/heat_forecast.py
CHANGED
|
@@ -25,6 +25,12 @@ try:
|
|
| 25 |
except ImportError:
|
| 26 |
xgb = None
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
# City-specific temperature thresholds for trigger definition (deg C)
|
| 29 |
# These are the "warning" tier thresholds adjusted per city
|
| 30 |
CITY_THRESHOLDS = {
|
|
@@ -112,6 +118,16 @@ class HeatWavePredictor:
|
|
| 112 |
self._rolling_errors: deque = deque(maxlen=3)
|
| 113 |
self._load_or_train()
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
# ------------------------------------------------------------------
|
| 116 |
# Public API
|
| 117 |
# ------------------------------------------------------------------
|
|
@@ -135,19 +151,46 @@ class HeatWavePredictor:
|
|
| 135 |
|
| 136 |
Returns:
|
| 137 |
(probability, confidence, model_tier)
|
| 138 |
-
model_tier is one of: "
|
|
|
|
| 139 |
"""
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
try:
|
| 142 |
features = self._build_features(
|
| 143 |
zone, recent_temps, recent_humidity, recent_wbgt, hour
|
| 144 |
)
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
except Exception:
|
| 149 |
pass
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
# Persistence fallback: if recent conditions are above threshold,
|
| 152 |
# assume they continue
|
| 153 |
try:
|
|
@@ -177,6 +220,27 @@ class HeatWavePredictor:
|
|
| 177 |
confidence = 0.30
|
| 178 |
return round(prob, 4), round(confidence, 3), "climatology"
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def update_rolling_error(self, predicted_prob: float, actual: bool) -> None:
|
| 181 |
"""Track prediction accuracy for the rolling_error feature."""
|
| 182 |
error = abs(predicted_prob - (1.0 if actual else 0.0))
|
|
@@ -505,3 +569,32 @@ class HeatWavePredictor:
|
|
| 505 |
self.model.load_model(str(self.model_path))
|
| 506 |
else:
|
| 507 |
self.train()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
except ImportError:
|
| 26 |
xgb = None
|
| 27 |
|
| 28 |
+
try:
|
| 29 |
+
from src.prediction.lstm_model import LSTMPredictor
|
| 30 |
+
_LSTM_AVAILABLE = True
|
| 31 |
+
except Exception:
|
| 32 |
+
_LSTM_AVAILABLE = False
|
| 33 |
+
|
| 34 |
# City-specific temperature thresholds for trigger definition (deg C)
|
| 35 |
# These are the "warning" tier thresholds adjusted per city
|
| 36 |
CITY_THRESHOLDS = {
|
|
|
|
| 118 |
self._rolling_errors: deque = deque(maxlen=3)
|
| 119 |
self._load_or_train()
|
| 120 |
|
| 121 |
+
# Try loading LSTM for ensemble; auto-train on synthetic data if missing
|
| 122 |
+
self._lstm: object | None = None
|
| 123 |
+
if _LSTM_AVAILABLE:
|
| 124 |
+
try:
|
| 125 |
+
self._lstm = LSTMPredictor()
|
| 126 |
+
except FileNotFoundError:
|
| 127 |
+
self._train_lstm_synthetic()
|
| 128 |
+
except Exception:
|
| 129 |
+
self._lstm = None
|
| 130 |
+
|
| 131 |
# ------------------------------------------------------------------
|
| 132 |
# Public API
|
| 133 |
# ------------------------------------------------------------------
|
|
|
|
| 151 |
|
| 152 |
Returns:
|
| 153 |
(probability, confidence, model_tier)
|
| 154 |
+
model_tier is one of: "ensemble", "full_model", "lstm_only",
|
| 155 |
+
"persistence", "climatology"
|
| 156 |
"""
|
| 157 |
+
xgb_prob, xgb_conf, xgb_ok = None, None, False
|
| 158 |
+
lstm_prob, lstm_conf, lstm_ok = None, None, False
|
| 159 |
+
|
| 160 |
+
# -- XGBoost prediction --
|
| 161 |
try:
|
| 162 |
features = self._build_features(
|
| 163 |
zone, recent_temps, recent_humidity, recent_wbgt, hour
|
| 164 |
)
|
| 165 |
+
xgb_prob = float(self.model.predict_proba(features)[0, 1])
|
| 166 |
+
xgb_conf = self._estimate_confidence(recent_temps, "full_model")
|
| 167 |
+
xgb_ok = True
|
| 168 |
except Exception:
|
| 169 |
pass
|
| 170 |
|
| 171 |
+
# -- LSTM prediction --
|
| 172 |
+
if self._lstm is not None:
|
| 173 |
+
try:
|
| 174 |
+
lstm_days = self._build_lstm_days(
|
| 175 |
+
recent_temps, recent_humidity, recent_wbgt
|
| 176 |
+
)
|
| 177 |
+
lstm_prob, lstm_conf = self._lstm.predict(lstm_days)
|
| 178 |
+
lstm_ok = True
|
| 179 |
+
except Exception:
|
| 180 |
+
pass
|
| 181 |
+
|
| 182 |
+
# -- Ensemble --
|
| 183 |
+
if xgb_ok and lstm_ok:
|
| 184 |
+
prob = 0.5 * xgb_prob + 0.5 * lstm_prob
|
| 185 |
+
confidence = (xgb_conf + lstm_conf) / 2.0
|
| 186 |
+
return round(prob, 4), round(confidence, 3), "ensemble"
|
| 187 |
+
|
| 188 |
+
if xgb_ok:
|
| 189 |
+
return round(xgb_prob, 4), round(xgb_conf, 3), "full_model"
|
| 190 |
+
|
| 191 |
+
if lstm_ok:
|
| 192 |
+
return round(lstm_prob, 4), round(lstm_conf, 3), "lstm_only"
|
| 193 |
+
|
| 194 |
# Persistence fallback: if recent conditions are above threshold,
|
| 195 |
# assume they continue
|
| 196 |
try:
|
|
|
|
| 220 |
confidence = 0.30
|
| 221 |
return round(prob, 4), round(confidence, 3), "climatology"
|
| 222 |
|
| 223 |
+
@staticmethod
|
| 224 |
+
def _build_lstm_days(
|
| 225 |
+
recent_temps: list[float],
|
| 226 |
+
recent_humidity: list[float],
|
| 227 |
+
recent_wbgt: list[float],
|
| 228 |
+
) -> list[dict]:
|
| 229 |
+
"""Convert raw arrays into the list-of-dicts format the LSTM expects.
|
| 230 |
+
|
| 231 |
+
The LSTM predictor computes WBGT, heat index, and temp anomaly
|
| 232 |
+
internally, so we only need to pass the raw observations.
|
| 233 |
+
"""
|
| 234 |
+
n = min(len(recent_temps), len(recent_humidity), len(recent_wbgt))
|
| 235 |
+
days = []
|
| 236 |
+
for i in range(n):
|
| 237 |
+
days.append({
|
| 238 |
+
"temp_max_c": recent_temps[i],
|
| 239 |
+
"humidity_pct": recent_humidity[i],
|
| 240 |
+
"wind_speed_ms": 3.0,
|
| 241 |
+
})
|
| 242 |
+
return days
|
| 243 |
+
|
| 244 |
def update_rolling_error(self, predicted_prob: float, actual: bool) -> None:
|
| 245 |
"""Track prediction accuracy for the rolling_error feature."""
|
| 246 |
error = abs(predicted_prob - (1.0 if actual else 0.0))
|
|
|
|
| 569 |
self.model.load_model(str(self.model_path))
|
| 570 |
else:
|
| 571 |
self.train()
|
| 572 |
+
|
| 573 |
+
def _train_lstm_synthetic(self) -> None:
|
| 574 |
+
"""Auto-train LSTM on synthetic data when no model file exists."""
|
| 575 |
+
try:
|
| 576 |
+
from src.prediction.lstm_model import (
|
| 577 |
+
LSTMTrainer,
|
| 578 |
+
generate_synthetic_zone_data,
|
| 579 |
+
)
|
| 580 |
+
from config import ZONES
|
| 581 |
+
|
| 582 |
+
import logging
|
| 583 |
+
|
| 584 |
+
logger = logging.getLogger(__name__)
|
| 585 |
+
logger.info("LSTM model not found -- training on synthetic data")
|
| 586 |
+
|
| 587 |
+
zone_data = generate_synthetic_zone_data(ZONES, n_days=730, seed=42)
|
| 588 |
+
trainer = LSTMTrainer(epochs=50, patience=5)
|
| 589 |
+
trainer.train(zone_data)
|
| 590 |
+
|
| 591 |
+
# Reload the predictor now that the model file exists
|
| 592 |
+
self._lstm = LSTMPredictor()
|
| 593 |
+
logger.info("LSTM auto-trained and loaded successfully")
|
| 594 |
+
except Exception as exc:
|
| 595 |
+
import logging
|
| 596 |
+
|
| 597 |
+
logging.getLogger(__name__).warning(
|
| 598 |
+
"LSTM auto-training failed: %s", exc
|
| 599 |
+
)
|
| 600 |
+
self._lstm = None
|
src/prediction/lstm_model.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LSTM neural heat wave predictor for parametric insurance triggers.
|
| 3 |
+
|
| 4 |
+
2-layer LSTM that learns temporal patterns in 14-day climate sequences
|
| 5 |
+
to predict heat wave trigger probability in the next 7 days.
|
| 6 |
+
Ensembled with the existing XGBoost classifier in heat_forecast.py.
|
| 7 |
+
|
| 8 |
+
Architecture:
|
| 9 |
+
Input: (batch, 14, 6) -- 14 days x 6 climate features
|
| 10 |
+
LSTM: 2 layers, hidden_size=64, dropout=0.2
|
| 11 |
+
Output: scalar sigmoid probability
|
| 12 |
+
|
| 13 |
+
Features per timestep:
|
| 14 |
+
0: temp_max_c -- daily max temperature (normalized)
|
| 15 |
+
1: humidity_pct -- relative humidity (normalized)
|
| 16 |
+
2: wind_speed_ms -- wind speed (normalized)
|
| 17 |
+
3: wbgt_c -- wet-bulb globe temperature (normalized)
|
| 18 |
+
4: heat_index_c -- apparent temperature (normalized)
|
| 19 |
+
5: temp_anomaly -- temp minus 7-day rolling mean (normalized)
|
| 20 |
+
|
| 21 |
+
References:
|
| 22 |
+
- Perkins-Kirkpatrick & Lewis (2020) heat wave definitions
|
| 23 |
+
- WHO/ILO occupational heat stress thresholds
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
import torch
|
| 35 |
+
import torch.nn as nn
|
| 36 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 37 |
+
|
| 38 |
+
TORCH_AVAILABLE = True
|
| 39 |
+
except ImportError:
|
| 40 |
+
TORCH_AVAILABLE = False
|
| 41 |
+
|
| 42 |
+
from src.indexing.heat_index import calculate_wbgt, calculate_heat_index
|
| 43 |
+
from src.prediction.heat_forecast import CITY_THRESHOLDS, CITY_CLIMATE
|
| 44 |
+
|
| 45 |
+
FEATURE_NAMES = [
|
| 46 |
+
"temp_max_c", "humidity_pct", "wind_speed_ms",
|
| 47 |
+
"wbgt_c", "heat_index_c", "temp_anomaly",
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
NUM_FEATURES = len(FEATURE_NAMES)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _resolve_path(rel: str) -> Path:
|
| 54 |
+
"""Resolve a path relative to the project root."""
|
| 55 |
+
return Path(__file__).resolve().parents[2] / rel
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ======================================================================
|
| 59 |
+
# Model
|
| 60 |
+
# ======================================================================
|
| 61 |
+
|
| 62 |
+
if TORCH_AVAILABLE:
|
| 63 |
+
|
| 64 |
+
class HeatLSTM(nn.Module):
|
| 65 |
+
"""2-layer LSTM for 7-day heat wave trigger prediction."""
|
| 66 |
+
|
| 67 |
+
def __init__(
|
| 68 |
+
self,
|
| 69 |
+
input_size: int = NUM_FEATURES,
|
| 70 |
+
hidden_size: int = 64,
|
| 71 |
+
num_layers: int = 2,
|
| 72 |
+
dropout: float = 0.2,
|
| 73 |
+
):
|
| 74 |
+
super().__init__()
|
| 75 |
+
self.lstm = nn.LSTM(
|
| 76 |
+
input_size, hidden_size, num_layers,
|
| 77 |
+
batch_first=True, dropout=dropout,
|
| 78 |
+
)
|
| 79 |
+
self.fc = nn.Linear(hidden_size, 1)
|
| 80 |
+
|
| 81 |
+
def forward(self, x):
|
| 82 |
+
# x: (batch, seq_len, input_size)
|
| 83 |
+
out, _ = self.lstm(x)
|
| 84 |
+
out = self.fc(out[:, -1, :]) # last timestep
|
| 85 |
+
return torch.sigmoid(out).squeeze(-1)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ======================================================================
|
| 89 |
+
# Derived feature computation
|
| 90 |
+
# ======================================================================
|
| 91 |
+
|
| 92 |
+
def _compute_temp_anomaly(temps: list[float], index: int) -> float:
|
| 93 |
+
"""Compute temp minus 7-day rolling mean at the given index."""
|
| 94 |
+
start = max(0, index - 6)
|
| 95 |
+
window = temps[start:index + 1]
|
| 96 |
+
if not window:
|
| 97 |
+
return 0.0
|
| 98 |
+
return temps[index] - float(np.mean(window))
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ======================================================================
|
| 102 |
+
# Synthetic data generation
|
| 103 |
+
# ======================================================================
|
| 104 |
+
|
| 105 |
+
def _generate_temp_series(climate: dict, n_days: int, rng) -> list[float]:
|
| 106 |
+
"""Daily max temperatures with AR(1) autocorrelation."""
|
| 107 |
+
mean, amp, phase = climate["temp_mean"], climate["temp_amp"], climate["phase_doy"]
|
| 108 |
+
lo, hi = climate["temp_range"]
|
| 109 |
+
temps, noise = [], 0.0
|
| 110 |
+
for d in range(n_days):
|
| 111 |
+
seasonal = mean + amp * np.cos(2 * np.pi * (d - phase) / 365.0)
|
| 112 |
+
noise = 0.7 * noise + rng.normal(0, 1.2)
|
| 113 |
+
temps.append(float(np.clip(seasonal + noise, lo - 1.0, hi + 2.0)))
|
| 114 |
+
return temps
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _generate_humidity_series(climate: dict, n_days: int, rng) -> list[float]:
|
| 118 |
+
"""Daily humidity with seasonal cycle and noise."""
|
| 119 |
+
mean, amp = climate["humidity_mean"], climate["humidity_amp"]
|
| 120 |
+
phase = climate.get("phase_doy", 45)
|
| 121 |
+
humidity, noise = [], 0.0
|
| 122 |
+
for d in range(n_days):
|
| 123 |
+
seasonal = mean - amp * np.cos(2 * np.pi * (d - phase) / 365.0)
|
| 124 |
+
noise = 0.5 * noise + rng.normal(0, 4.0)
|
| 125 |
+
humidity.append(float(np.clip(seasonal + noise, 30.0, 98.0)))
|
| 126 |
+
return humidity
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _generate_wind_series(n_days: int, rng) -> list[float]:
|
| 130 |
+
"""Synthetic wind speed series (m/s)."""
|
| 131 |
+
winds, noise = [], 0.0
|
| 132 |
+
for _ in range(n_days):
|
| 133 |
+
noise = 0.4 * noise + rng.normal(0, 0.8)
|
| 134 |
+
winds.append(float(np.clip(3.5 + noise, 0.5, 12.0)))
|
| 135 |
+
return winds
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _label_triggers(temps: list[float], threshold: float, n_days: int) -> list[int]:
|
| 139 |
+
"""Label each day: 1 if 2+ consecutive days above threshold in next 7 days."""
|
| 140 |
+
labels = [0] * n_days
|
| 141 |
+
for day in range(n_days - 7):
|
| 142 |
+
window = temps[day + 1: day + 8]
|
| 143 |
+
consec = 0
|
| 144 |
+
triggered = False
|
| 145 |
+
for t in window:
|
| 146 |
+
if t > threshold:
|
| 147 |
+
consec += 1
|
| 148 |
+
if consec >= 2:
|
| 149 |
+
triggered = True
|
| 150 |
+
break
|
| 151 |
+
else:
|
| 152 |
+
consec = 0
|
| 153 |
+
labels[day] = 1 if triggered else 0
|
| 154 |
+
return labels
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def generate_synthetic_zone_data(
|
| 158 |
+
zones: list, n_days: int = 730, seed: int = 42,
|
| 159 |
+
) -> dict[str, list[dict]]:
|
| 160 |
+
"""Generate synthetic daily climate data for all zones.
|
| 161 |
+
|
| 162 |
+
Returns:
|
| 163 |
+
dict mapping zone_id -> list of daily dicts with keys:
|
| 164 |
+
temp_max_c, humidity_pct, wind_speed_ms, city
|
| 165 |
+
"""
|
| 166 |
+
rng = np.random.default_rng(seed)
|
| 167 |
+
zone_data: dict[str, list[dict]] = {}
|
| 168 |
+
|
| 169 |
+
for zone in zones:
|
| 170 |
+
city = zone.city
|
| 171 |
+
climate = CITY_CLIMATE.get(city, CITY_CLIMATE["Nairobi"])
|
| 172 |
+
|
| 173 |
+
temps = _generate_temp_series(climate, n_days, rng)
|
| 174 |
+
humidity = _generate_humidity_series(climate, n_days, rng)
|
| 175 |
+
winds = _generate_wind_series(n_days, rng)
|
| 176 |
+
|
| 177 |
+
days = []
|
| 178 |
+
for i in range(n_days):
|
| 179 |
+
days.append({
|
| 180 |
+
"temp_max_c": temps[i],
|
| 181 |
+
"humidity_pct": humidity[i],
|
| 182 |
+
"wind_speed_ms": winds[i],
|
| 183 |
+
"city": city,
|
| 184 |
+
})
|
| 185 |
+
zone_data[zone.zone_id] = days
|
| 186 |
+
|
| 187 |
+
return zone_data
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ======================================================================
|
| 191 |
+
# Trainer
|
| 192 |
+
# ======================================================================
|
| 193 |
+
|
| 194 |
+
class LSTMTrainer:
|
| 195 |
+
"""Train the HeatLSTM on historical or synthetic climate data."""
|
| 196 |
+
|
| 197 |
+
def __init__(
|
| 198 |
+
self,
|
| 199 |
+
model: object | None = None,
|
| 200 |
+
lr: float = 0.001,
|
| 201 |
+
epochs: int = 50,
|
| 202 |
+
patience: int = 5,
|
| 203 |
+
seq_len: int = 14,
|
| 204 |
+
forecast_horizon: int = 7,
|
| 205 |
+
):
|
| 206 |
+
if not TORCH_AVAILABLE:
|
| 207 |
+
raise ImportError("torch is required. pip install torch")
|
| 208 |
+
self._custom_model = model
|
| 209 |
+
self.lr = lr
|
| 210 |
+
self.epochs = epochs
|
| 211 |
+
self.patience = patience
|
| 212 |
+
self.seq_len = seq_len
|
| 213 |
+
self.forecast_horizon = forecast_horizon
|
| 214 |
+
self.model_path = _resolve_path("models/heat_lstm.pt")
|
| 215 |
+
self.norm_path = _resolve_path("models/lstm_norm.json")
|
| 216 |
+
|
| 217 |
+
def prepare_data(
|
| 218 |
+
self, zone_readings: dict[str, list],
|
| 219 |
+
) -> tuple:
|
| 220 |
+
"""Convert zone readings to training tensors.
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
zone_readings: dict of zone_id -> list of daily readings.
|
| 224 |
+
Each reading needs: temp_max_c, humidity_pct, wind_speed_ms
|
| 225 |
+
Optional: date, city
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
(X_train, y_train, X_val, y_val) as torch tensors
|
| 229 |
+
"""
|
| 230 |
+
all_seqs: list[np.ndarray] = []
|
| 231 |
+
all_labels: list[int] = []
|
| 232 |
+
|
| 233 |
+
for zone_id, days in zone_readings.items():
|
| 234 |
+
n = len(days)
|
| 235 |
+
if n < self.seq_len + self.forecast_horizon + 1:
|
| 236 |
+
continue
|
| 237 |
+
|
| 238 |
+
city = days[0].get("city", "Nairobi")
|
| 239 |
+
threshold = CITY_THRESHOLDS.get(city, 33.0)
|
| 240 |
+
|
| 241 |
+
# Extract raw temps for labeling and anomaly computation
|
| 242 |
+
temps = [d["temp_max_c"] for d in days]
|
| 243 |
+
labels = _label_triggers(temps, threshold, n)
|
| 244 |
+
|
| 245 |
+
# Compute derived features for all days
|
| 246 |
+
derived = []
|
| 247 |
+
for i, d in enumerate(days):
|
| 248 |
+
t = d["temp_max_c"]
|
| 249 |
+
h = d["humidity_pct"]
|
| 250 |
+
w = d["wind_speed_ms"]
|
| 251 |
+
wbgt = calculate_wbgt(t, h)
|
| 252 |
+
hi = calculate_heat_index(t, h)
|
| 253 |
+
anomaly = _compute_temp_anomaly(temps, i)
|
| 254 |
+
derived.append([t, h, w, wbgt, hi, anomaly])
|
| 255 |
+
|
| 256 |
+
# Create sliding windows
|
| 257 |
+
for i in range(n - self.seq_len - self.forecast_horizon):
|
| 258 |
+
seq = np.array(
|
| 259 |
+
derived[i: i + self.seq_len], dtype=np.float32,
|
| 260 |
+
)
|
| 261 |
+
all_seqs.append(seq)
|
| 262 |
+
all_labels.append(labels[i + self.seq_len - 1])
|
| 263 |
+
|
| 264 |
+
X = np.stack(all_seqs) # (N, seq_len, 6)
|
| 265 |
+
y = np.array(all_labels, dtype=np.float32)
|
| 266 |
+
|
| 267 |
+
# Temporal split: first 75% train, last 25% validation
|
| 268 |
+
split = int(len(X) * 0.75)
|
| 269 |
+
X_train_np, X_val_np = X[:split], X[split:]
|
| 270 |
+
y_train_np, y_val_np = y[:split], y[split:]
|
| 271 |
+
|
| 272 |
+
# Compute normalization (z-score per feature) from training set
|
| 273 |
+
flat = X_train_np.reshape(-1, NUM_FEATURES)
|
| 274 |
+
feat_mean = flat.mean(axis=0).tolist()
|
| 275 |
+
feat_std = flat.std(axis=0).tolist()
|
| 276 |
+
feat_std = [max(s, 1e-6) for s in feat_std]
|
| 277 |
+
|
| 278 |
+
# Save normalization params
|
| 279 |
+
norm = {"mean": feat_mean, "std": feat_std}
|
| 280 |
+
self.norm_path.parent.mkdir(parents=True, exist_ok=True)
|
| 281 |
+
with open(self.norm_path, "w") as f:
|
| 282 |
+
json.dump(norm, f, indent=2)
|
| 283 |
+
|
| 284 |
+
# Normalize
|
| 285 |
+
mean_arr = np.array(feat_mean, dtype=np.float32)
|
| 286 |
+
std_arr = np.array(feat_std, dtype=np.float32)
|
| 287 |
+
X_train_np = (X_train_np - mean_arr) / std_arr
|
| 288 |
+
X_val_np = (X_val_np - mean_arr) / std_arr
|
| 289 |
+
|
| 290 |
+
X_train = torch.from_numpy(X_train_np)
|
| 291 |
+
y_train = torch.from_numpy(y_train_np)
|
| 292 |
+
X_val = torch.from_numpy(X_val_np)
|
| 293 |
+
y_val = torch.from_numpy(y_val_np)
|
| 294 |
+
|
| 295 |
+
return X_train, y_train, X_val, y_val
|
| 296 |
+
|
| 297 |
+
def train(self, zone_readings: dict[str, list]) -> dict:
|
| 298 |
+
"""Train the LSTM and return metrics."""
|
| 299 |
+
torch.manual_seed(42)
|
| 300 |
+
np.random.seed(42)
|
| 301 |
+
|
| 302 |
+
X_train, y_train, X_val, y_val = self.prepare_data(zone_readings)
|
| 303 |
+
|
| 304 |
+
# DataLoaders
|
| 305 |
+
train_ds = TensorDataset(X_train, y_train)
|
| 306 |
+
val_ds = TensorDataset(X_val, y_val)
|
| 307 |
+
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True)
|
| 308 |
+
val_loader = DataLoader(val_ds, batch_size=256, shuffle=False)
|
| 309 |
+
|
| 310 |
+
# Model, loss, optimizer
|
| 311 |
+
model = self._custom_model if self._custom_model is not None else HeatLSTM()
|
| 312 |
+
criterion = nn.BCELoss()
|
| 313 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=self.lr)
|
| 314 |
+
|
| 315 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 316 |
+
print(f" Model params: {total_params:,}")
|
| 317 |
+
|
| 318 |
+
# Training loop with early stopping
|
| 319 |
+
best_val_loss = float("inf")
|
| 320 |
+
patience_counter = 0
|
| 321 |
+
best_state = None
|
| 322 |
+
best_metrics: dict = {}
|
| 323 |
+
|
| 324 |
+
for epoch in range(self.epochs):
|
| 325 |
+
# Train
|
| 326 |
+
model.train()
|
| 327 |
+
train_loss, train_total = 0.0, 0
|
| 328 |
+
for xb, yb in train_loader:
|
| 329 |
+
optimizer.zero_grad()
|
| 330 |
+
preds = model(xb)
|
| 331 |
+
loss = criterion(preds, yb)
|
| 332 |
+
loss.backward()
|
| 333 |
+
optimizer.step()
|
| 334 |
+
train_loss += loss.item() * len(xb)
|
| 335 |
+
train_total += len(xb)
|
| 336 |
+
|
| 337 |
+
# Validate
|
| 338 |
+
model.eval()
|
| 339 |
+
val_loss, val_total = 0.0, 0
|
| 340 |
+
all_val_preds, all_val_labels = [], []
|
| 341 |
+
with torch.no_grad():
|
| 342 |
+
for xb, yb in val_loader:
|
| 343 |
+
preds = model(xb)
|
| 344 |
+
loss = criterion(preds, yb)
|
| 345 |
+
val_loss += loss.item() * len(xb)
|
| 346 |
+
val_total += len(xb)
|
| 347 |
+
all_val_preds.extend(preds.numpy().tolist())
|
| 348 |
+
all_val_labels.extend(yb.numpy().tolist())
|
| 349 |
+
|
| 350 |
+
avg_train_loss = train_loss / max(train_total, 1)
|
| 351 |
+
avg_val_loss = val_loss / max(val_total, 1)
|
| 352 |
+
val_auroc = _compute_auroc(all_val_labels, all_val_preds)
|
| 353 |
+
|
| 354 |
+
if (epoch + 1) % 5 == 0 or epoch == 0:
|
| 355 |
+
print(
|
| 356 |
+
f" Epoch {epoch + 1:>2}: "
|
| 357 |
+
f"train_loss={avg_train_loss:.4f} | "
|
| 358 |
+
f"val_loss={avg_val_loss:.4f} val_auroc={val_auroc:.3f}"
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Early stopping
|
| 362 |
+
if avg_val_loss < best_val_loss:
|
| 363 |
+
best_val_loss = avg_val_loss
|
| 364 |
+
patience_counter = 0
|
| 365 |
+
best_state = {k: v.clone() for k, v in model.state_dict().items()}
|
| 366 |
+
best_metrics = {
|
| 367 |
+
"train_loss": round(avg_train_loss, 4),
|
| 368 |
+
"val_loss": round(avg_val_loss, 4),
|
| 369 |
+
"val_auroc": round(val_auroc, 4),
|
| 370 |
+
"epochs_trained": epoch + 1,
|
| 371 |
+
"samples": {
|
| 372 |
+
"train": len(X_train),
|
| 373 |
+
"val": len(X_val),
|
| 374 |
+
},
|
| 375 |
+
}
|
| 376 |
+
else:
|
| 377 |
+
patience_counter += 1
|
| 378 |
+
if patience_counter >= self.patience:
|
| 379 |
+
print(f" Early stopping at epoch {epoch + 1}")
|
| 380 |
+
break
|
| 381 |
+
|
| 382 |
+
# Save best model
|
| 383 |
+
if best_state is not None:
|
| 384 |
+
model.load_state_dict(best_state)
|
| 385 |
+
self.model_path.parent.mkdir(parents=True, exist_ok=True)
|
| 386 |
+
torch.save(model.state_dict(), self.model_path)
|
| 387 |
+
|
| 388 |
+
file_size = self.model_path.stat().st_size
|
| 389 |
+
print(f" Saved model to {self.model_path} ({file_size / 1024:.1f} KB)")
|
| 390 |
+
|
| 391 |
+
return best_metrics
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# ======================================================================
|
| 395 |
+
# Predictor (inference)
|
| 396 |
+
# ======================================================================
|
| 397 |
+
|
| 398 |
+
class LSTMPredictor:
|
| 399 |
+
"""Load trained HeatLSTM and predict trigger probability."""
|
| 400 |
+
|
| 401 |
+
def __init__(
|
| 402 |
+
self,
|
| 403 |
+
model_path: str = "models/heat_lstm.pt",
|
| 404 |
+
norm_path: str = "models/lstm_norm.json",
|
| 405 |
+
):
|
| 406 |
+
if not TORCH_AVAILABLE:
|
| 407 |
+
raise ImportError("torch is required. pip install torch")
|
| 408 |
+
self.model_path = _resolve_path(model_path)
|
| 409 |
+
self.norm_path = _resolve_path(norm_path)
|
| 410 |
+
self.model: HeatLSTM | None = None
|
| 411 |
+
self._norm: dict | None = None
|
| 412 |
+
self._load_model()
|
| 413 |
+
|
| 414 |
+
def _load_model(self) -> None:
|
| 415 |
+
"""Load model weights and normalization params from disk."""
|
| 416 |
+
mp = self.model_path
|
| 417 |
+
np_ = self.norm_path
|
| 418 |
+
|
| 419 |
+
if not mp.exists():
|
| 420 |
+
raise FileNotFoundError(
|
| 421 |
+
f"LSTM model not found at {mp}. "
|
| 422 |
+
"Run scripts/train_lstm.py first."
|
| 423 |
+
)
|
| 424 |
+
if not np_.exists():
|
| 425 |
+
raise FileNotFoundError(
|
| 426 |
+
f"LSTM normalization params not found at {np_}. "
|
| 427 |
+
"Run scripts/train_lstm.py first."
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
self.model = HeatLSTM()
|
| 431 |
+
self.model.load_state_dict(
|
| 432 |
+
torch.load(mp, map_location="cpu", weights_only=True)
|
| 433 |
+
)
|
| 434 |
+
self.model.eval()
|
| 435 |
+
|
| 436 |
+
with open(np_) as f:
|
| 437 |
+
self._norm = json.load(f)
|
| 438 |
+
|
| 439 |
+
def predict(self, recent_14_days: list[dict]) -> tuple[float, float]:
|
| 440 |
+
"""Predict trigger probability from last 14 days of data.
|
| 441 |
+
|
| 442 |
+
Args:
|
| 443 |
+
recent_14_days: list of dicts with keys:
|
| 444 |
+
temp_max_c, humidity_pct, wind_speed_ms
|
| 445 |
+
(WBGT, heat index, and temp anomaly are computed internally)
|
| 446 |
+
|
| 447 |
+
Returns:
|
| 448 |
+
(probability, confidence) where:
|
| 449 |
+
- probability: 0-1 trigger probability
|
| 450 |
+
- confidence: 0-1 based on MC dropout (5 forward passes)
|
| 451 |
+
"""
|
| 452 |
+
if len(recent_14_days) < 14:
|
| 453 |
+
pad = [recent_14_days[0]] * (14 - len(recent_14_days))
|
| 454 |
+
recent_14_days = pad + recent_14_days
|
| 455 |
+
|
| 456 |
+
days = recent_14_days[-14:]
|
| 457 |
+
|
| 458 |
+
# Extract temps for anomaly computation
|
| 459 |
+
temps = [d.get("temp_max_c", d.get("temp_c", 30.0)) for d in days]
|
| 460 |
+
|
| 461 |
+
# Build feature array: compute derived features
|
| 462 |
+
seq = []
|
| 463 |
+
for i, d in enumerate(days):
|
| 464 |
+
t = d.get("temp_max_c", d.get("temp_c", 30.0))
|
| 465 |
+
h = d.get("humidity_pct", 65.0)
|
| 466 |
+
w = d.get("wind_speed_ms", 3.0)
|
| 467 |
+
wbgt = d.get("wbgt_c", calculate_wbgt(t, h))
|
| 468 |
+
hi = d.get("heat_index_c", calculate_heat_index(t, h))
|
| 469 |
+
anomaly = _compute_temp_anomaly(temps, i)
|
| 470 |
+
seq.append([t, h, w, wbgt, hi, anomaly])
|
| 471 |
+
|
| 472 |
+
x = np.array(seq, dtype=np.float32)
|
| 473 |
+
|
| 474 |
+
# Normalize using saved params
|
| 475 |
+
mean = np.array(self._norm["mean"], dtype=np.float32)
|
| 476 |
+
std = np.array(self._norm["std"], dtype=np.float32)
|
| 477 |
+
x = (x - mean) / std
|
| 478 |
+
|
| 479 |
+
x_tensor = torch.from_numpy(x).unsqueeze(0) # (1, 14, 6)
|
| 480 |
+
|
| 481 |
+
# MC Dropout: 5 forward passes batched with dropout enabled
|
| 482 |
+
self.model.train()
|
| 483 |
+
x_batch = x_tensor.expand(5, -1, -1) # (5, 14, 6)
|
| 484 |
+
with torch.no_grad():
|
| 485 |
+
preds = self.model(x_batch).numpy()
|
| 486 |
+
self.model.eval()
|
| 487 |
+
|
| 488 |
+
probability = float(np.mean(preds))
|
| 489 |
+
std_val = float(np.std(preds))
|
| 490 |
+
confidence = max(0.3, min(0.95, 1.0 - std_val * 3))
|
| 491 |
+
|
| 492 |
+
probability = float(np.clip(probability, 0.0, 1.0))
|
| 493 |
+
|
| 494 |
+
return probability, confidence
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
# ======================================================================
|
| 498 |
+
# Utilities
|
| 499 |
+
# ======================================================================
|
| 500 |
+
|
| 501 |
+
def _compute_auroc(labels: list[float], preds: list[float]) -> float:
|
| 502 |
+
"""Compute AUROC using sklearn if available, else trapezoidal fallback."""
|
| 503 |
+
if len(set(labels)) < 2:
|
| 504 |
+
return 0.5
|
| 505 |
+
|
| 506 |
+
try:
|
| 507 |
+
from sklearn.metrics import roc_auc_score
|
| 508 |
+
return float(roc_auc_score(labels, preds))
|
| 509 |
+
except ImportError:
|
| 510 |
+
pass
|
| 511 |
+
|
| 512 |
+
# Fallback: trapezoidal AUROC
|
| 513 |
+
pairs = sorted(zip(preds, labels), key=lambda x: -x[0])
|
| 514 |
+
tp, fp = 0, 0
|
| 515 |
+
tp_prev, fp_prev = 0, 0
|
| 516 |
+
auc = 0.0
|
| 517 |
+
n_pos = sum(labels)
|
| 518 |
+
n_neg = len(labels) - n_pos
|
| 519 |
+
|
| 520 |
+
if n_pos == 0 or n_neg == 0:
|
| 521 |
+
return 0.5
|
| 522 |
+
|
| 523 |
+
prev_score = None
|
| 524 |
+
for score, label in pairs:
|
| 525 |
+
if score != prev_score and prev_score is not None:
|
| 526 |
+
auc += (fp - fp_prev) * (tp + tp_prev) / 2.0
|
| 527 |
+
tp_prev, fp_prev = tp, fp
|
| 528 |
+
if label == 1.0:
|
| 529 |
+
tp += 1
|
| 530 |
+
else:
|
| 531 |
+
fp += 1
|
| 532 |
+
prev_score = score
|
| 533 |
+
|
| 534 |
+
auc += (fp - fp_prev) * (tp + tp_prev) / 2.0
|
| 535 |
+
return auc / (n_pos * n_neg)
|
src/store.py
CHANGED
|
@@ -12,11 +12,10 @@ from __future__ import annotations
|
|
| 12 |
import logging
|
| 13 |
import random
|
| 14 |
import threading
|
| 15 |
-
from
|
| 16 |
-
from datetime import datetime, timedelta
|
| 17 |
from typing import Any
|
| 18 |
|
| 19 |
-
from config import ZONES, ZONE_MAP, CITIES,
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
|
@@ -35,17 +34,13 @@ class PipelineStore:
|
|
| 35 |
self.stats: dict[str, Any] = {}
|
| 36 |
self._lock = threading.Lock()
|
| 37 |
|
| 38 |
-
# ------------------------------------------------------------------
|
| 39 |
-
# Public API
|
| 40 |
-
# ------------------------------------------------------------------
|
| 41 |
-
|
| 42 |
def update_from_pipeline(self, pipeline, run_result=None):
|
| 43 |
"""Convert pipeline state into the same dict shapes that
|
| 44 |
``_generate_demo_data()`` in api.py produces, so the dashboard
|
| 45 |
works identically with real or synthetic data.
|
| 46 |
|
| 47 |
Args:
|
| 48 |
-
pipeline: A ``
|
| 49 |
has completed.
|
| 50 |
run_result: Optional ``PipelineRunResult`` returned by
|
| 51 |
``pipeline.run()``.
|
|
@@ -54,21 +49,18 @@ class PipelineStore:
|
|
| 54 |
try:
|
| 55 |
now = datetime.utcnow()
|
| 56 |
zones = self._build_zones(pipeline, now)
|
| 57 |
-
indices = self._build_indices(pipeline, zones, now)
|
| 58 |
triggers = self._build_triggers(pipeline, zones, now)
|
| 59 |
basis_risk = self._build_basis_risk(pipeline)
|
| 60 |
notifications = self._build_notifications(pipeline, triggers, now)
|
| 61 |
pipeline_run = self._build_pipeline_run(run_result) if run_result else None
|
| 62 |
|
| 63 |
self.zones = zones
|
| 64 |
-
self.indices = indices
|
| 65 |
self.triggers = triggers
|
| 66 |
self.basis_risk = basis_risk
|
| 67 |
self.notifications = notifications
|
| 68 |
|
| 69 |
if pipeline_run:
|
| 70 |
self.pipeline_runs.insert(0, pipeline_run)
|
| 71 |
-
# Keep at most 50 runs
|
| 72 |
self.pipeline_runs = self.pipeline_runs[:50]
|
| 73 |
|
| 74 |
self.stats = self._build_stats(
|
|
@@ -87,51 +79,41 @@ class PipelineStore:
|
|
| 87 |
# ------------------------------------------------------------------
|
| 88 |
|
| 89 |
def _build_zones(self, pipeline, now: datetime) -> list[dict]:
|
| 90 |
-
"""Build zone dicts from pipeline
|
| 91 |
zones: list[dict] = []
|
| 92 |
rng = random.Random(42)
|
| 93 |
|
| 94 |
for z in ZONES:
|
| 95 |
zid = z.zone_id
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
# Determine risk level from triggers
|
| 110 |
-
zone_triggers = [
|
| 111 |
-
t for t in pipeline._triggers if t.zone_id == zid
|
| 112 |
-
]
|
| 113 |
if zone_triggers:
|
| 114 |
-
# Highest trigger level wins
|
| 115 |
levels_priority = {"critical": 0, "warning": 1, "watch": 2}
|
| 116 |
-
best = min(
|
| 117 |
-
zone_triggers,
|
| 118 |
-
key=lambda t: levels_priority.get(t.trigger_level, 9),
|
| 119 |
-
)
|
| 120 |
risk_level = best.trigger_level
|
| 121 |
else:
|
| 122 |
risk_level = "normal"
|
| 123 |
|
| 124 |
-
# Data quality from healed data
|
| 125 |
healed = pipeline._healed.get(zid)
|
| 126 |
data_quality = healed.quality_score if healed else 0.85
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
enrolled = rng.randint(30, 150)
|
| 133 |
-
else:
|
| 134 |
-
enrolled = rng.randint(100, 500)
|
| 135 |
|
| 136 |
zones.append({
|
| 137 |
"zone_id": zid,
|
|
@@ -141,102 +123,35 @@ class PipelineStore:
|
|
| 141 |
"latitude": z.latitude,
|
| 142 |
"longitude": z.longitude,
|
| 143 |
"elevation_m": z.elevation_m,
|
| 144 |
-
"area_km2": z.area_km2,
|
| 145 |
-
"population_est": z.population_est,
|
| 146 |
"settlement_type": z.settlement_type,
|
| 147 |
-
"
|
| 148 |
-
"
|
| 149 |
-
"
|
| 150 |
"risk_level": risk_level,
|
| 151 |
-
"
|
| 152 |
-
"
|
| 153 |
-
"
|
| 154 |
-
"
|
| 155 |
-
"
|
| 156 |
-
"
|
|
|
|
|
|
|
|
|
|
| 157 |
"data_quality": round(data_quality, 2),
|
| 158 |
"last_updated": now.isoformat(),
|
| 159 |
})
|
| 160 |
|
| 161 |
return zones
|
| 162 |
|
| 163 |
-
def _build_indices(
|
| 164 |
-
self, pipeline, zones: list[dict], now: datetime,
|
| 165 |
-
) -> list[dict]:
|
| 166 |
-
"""Build per-zone index dicts with monthly SPI history."""
|
| 167 |
-
indices: list[dict] = []
|
| 168 |
-
rng = random.Random(43)
|
| 169 |
-
|
| 170 |
-
for z_data in zones:
|
| 171 |
-
zid = z_data["zone_id"]
|
| 172 |
-
idx = pipeline._indices.get(zid, {})
|
| 173 |
-
spi_data = idx.get("spi", {})
|
| 174 |
-
|
| 175 |
-
# Build monthly history from SPI results if available
|
| 176 |
-
monthly_spi = []
|
| 177 |
-
spi_monthly_values = spi_data.get("monthly_values", [])
|
| 178 |
-
if spi_monthly_values:
|
| 179 |
-
for entry in spi_monthly_values[-12:]:
|
| 180 |
-
monthly_spi.append({
|
| 181 |
-
"month": entry.get("month", ""),
|
| 182 |
-
"spi_1": round(entry.get("spi_1", 0), 2),
|
| 183 |
-
"spi_3": round(entry.get("spi_3", 0), 2),
|
| 184 |
-
"precip_mm": round(entry.get("precip_mm", 0), 1),
|
| 185 |
-
})
|
| 186 |
-
else:
|
| 187 |
-
# Fallback: generate from current SPI value
|
| 188 |
-
for m_offset in range(11, -1, -1):
|
| 189 |
-
month_num = ((now.month - 1 - m_offset) % 12) + 1
|
| 190 |
-
year = now.year if m_offset < now.month else now.year - 1
|
| 191 |
-
month_date = f"{year}-{month_num:02d}-01"
|
| 192 |
-
zone_obj = ZONE_MAP[zid]
|
| 193 |
-
in_season = month_num in zone_obj.rainy_seasons
|
| 194 |
-
|
| 195 |
-
if m_offset == 0:
|
| 196 |
-
spi_val = z_data["spi_1month"]
|
| 197 |
-
elif in_season:
|
| 198 |
-
spi_val = rng.uniform(-0.5, 1.5)
|
| 199 |
-
else:
|
| 200 |
-
spi_val = rng.uniform(-1.0, 0.5)
|
| 201 |
-
|
| 202 |
-
monthly_spi.append({
|
| 203 |
-
"month": month_date,
|
| 204 |
-
"spi_1": round(spi_val, 2),
|
| 205 |
-
"spi_3": round(spi_val * rng.uniform(0.7, 1.1), 2),
|
| 206 |
-
"precip_mm": round(
|
| 207 |
-
max(0, 80 + spi_val * 40 + rng.uniform(-20, 20)), 1,
|
| 208 |
-
),
|
| 209 |
-
})
|
| 210 |
-
|
| 211 |
-
indices.append({
|
| 212 |
-
"zone_id": zid,
|
| 213 |
-
"zone_name": z_data["name"],
|
| 214 |
-
"city": z_data["city"],
|
| 215 |
-
"risk_level": z_data["risk_level"],
|
| 216 |
-
"spi_current": z_data["spi_1month"],
|
| 217 |
-
"flood_risk_score": z_data["flood_risk_score"],
|
| 218 |
-
"daily_precip_mm": z_data["daily_precip_mm"],
|
| 219 |
-
"api_5day_mm": z_data["api_5day_mm"],
|
| 220 |
-
"ndvi_anomaly": z_data["ndvi_anomaly"],
|
| 221 |
-
"monthly_history": monthly_spi,
|
| 222 |
-
})
|
| 223 |
-
|
| 224 |
-
return indices
|
| 225 |
-
|
| 226 |
def _build_triggers(
|
| 227 |
self, pipeline, zones: list[dict], now: datetime,
|
| 228 |
) -> list[dict]:
|
| 229 |
-
"""Convert
|
| 230 |
-
into API-compatible dicts."""
|
| 231 |
triggers: list[dict] = []
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
# Build a lookup for enrolled policies by zone_id
|
| 235 |
-
enrolled_map = {z["zone_id"]: z["enrolled_policies"] for z in zones}
|
| 236 |
|
| 237 |
-
for
|
| 238 |
-
|
| 239 |
-
per_policy = payout_tier.get(te.settlement_type, 0)
|
| 240 |
enrolled = enrolled_map.get(te.zone_id, 0)
|
| 241 |
|
| 242 |
triggers.append({
|
|
@@ -246,22 +161,22 @@ class PipelineStore:
|
|
| 246 |
"city": te.city,
|
| 247 |
"trigger_level": te.trigger_level,
|
| 248 |
"trigger_date": te.trigger_date,
|
| 249 |
-
"
|
| 250 |
-
"
|
| 251 |
-
"
|
| 252 |
-
"
|
|
|
|
| 253 |
"settlement_type": te.settlement_type,
|
| 254 |
-
"
|
| 255 |
-
"
|
| 256 |
-
"
|
| 257 |
"status": te.status,
|
| 258 |
})
|
| 259 |
|
| 260 |
return triggers
|
| 261 |
|
| 262 |
def _build_basis_risk(self, pipeline) -> list[dict]:
|
| 263 |
-
"""Convert
|
| 264 |
-
into API-compatible list of dicts."""
|
| 265 |
results: list[dict] = []
|
| 266 |
|
| 267 |
for zone_id, report in pipeline._basis_risk.items():
|
|
@@ -269,9 +184,7 @@ class PipelineStore:
|
|
| 269 |
if zone is None:
|
| 270 |
continue
|
| 271 |
|
| 272 |
-
# report can be a BasisRiskReport dataclass or a dict
|
| 273 |
if hasattr(report, "overall_score"):
|
| 274 |
-
# dataclass
|
| 275 |
rec_text = "; ".join(report.recommendations) if report.recommendations else "Current calibration adequate"
|
| 276 |
results.append({
|
| 277 |
"zone_id": zone_id,
|
|
@@ -282,11 +195,10 @@ class PipelineStore:
|
|
| 282 |
"false_negative_rate": round(report.false_negative_rate, 3),
|
| 283 |
"correlation": round(report.correlation, 3),
|
| 284 |
"settlement_type": report.settlement_type,
|
| 285 |
-
"
|
| 286 |
"recommendation": rec_text,
|
| 287 |
})
|
| 288 |
else:
|
| 289 |
-
# already a dict
|
| 290 |
results.append({
|
| 291 |
"zone_id": zone_id,
|
| 292 |
"zone_name": zone.name,
|
|
@@ -296,7 +208,7 @@ class PipelineStore:
|
|
| 296 |
"false_negative_rate": round(report.get("false_negative_rate", 0), 3),
|
| 297 |
"correlation": round(report.get("correlation", 0), 3),
|
| 298 |
"settlement_type": zone.settlement_type,
|
| 299 |
-
"
|
| 300 |
"recommendation": report.get("recommendation", "Current calibration adequate"),
|
| 301 |
})
|
| 302 |
|
|
@@ -305,41 +217,31 @@ class PipelineStore:
|
|
| 305 |
def _build_notifications(
|
| 306 |
self, pipeline, triggers: list[dict], now: datetime,
|
| 307 |
) -> list[dict]:
|
| 308 |
-
"""Convert pipeline
|
| 309 |
-
API-compatible notification dicts."""
|
| 310 |
notifications: list[dict] = []
|
| 311 |
-
rng = random.Random(45)
|
| 312 |
-
|
| 313 |
-
# Match explanations with their trigger data
|
| 314 |
trigger_map = {t["zone_id"]: t for t in triggers}
|
| 315 |
|
| 316 |
for i, explanation in enumerate(pipeline._explanations, start=1):
|
| 317 |
zone_id = explanation.zone_id
|
| 318 |
trigger = trigger_map.get(zone_id, {})
|
| 319 |
-
|
| 320 |
-
trigger_level = explanation.trigger_level
|
| 321 |
zone = ZONE_MAP.get(zone_id)
|
| 322 |
zone_name = zone.name if zone else zone_id
|
| 323 |
city = zone.city if zone else ""
|
| 324 |
-
|
| 325 |
-
per_policy = trigger.get("estimated_payout_per_policy", 0)
|
| 326 |
-
enrolled = trigger.get("enrolled_policies", 0)
|
| 327 |
|
| 328 |
-
# Delivery result from pipeline._notifications
|
| 329 |
delivery = pipeline._notifications[i - 1] if i <= len(pipeline._notifications) else None
|
| 330 |
|
| 331 |
-
# English notification
|
| 332 |
notifications.append({
|
| 333 |
"id": f"NOT-{2*i - 1:04d}",
|
| 334 |
"zone_id": zone_id,
|
| 335 |
"zone_name": zone_name,
|
| 336 |
"city": city,
|
| 337 |
-
"trigger_level": trigger_level,
|
| 338 |
"channel": delivery.channel if delivery else "console",
|
| 339 |
"language": "en",
|
| 340 |
"recipient_count": enrolled,
|
| 341 |
"message_preview": (
|
| 342 |
-
f"
|
| 343 |
f"{zone_name}, {city}. "
|
| 344 |
f"{explanation.english_text[:120]}"
|
| 345 |
),
|
|
@@ -348,18 +250,17 @@ class PipelineStore:
|
|
| 348 |
"cost_estimate": round(delivery.cost_estimate, 2) if delivery else 0.0,
|
| 349 |
})
|
| 350 |
|
| 351 |
-
# Swahili notification
|
| 352 |
notifications.append({
|
| 353 |
"id": f"NOT-{2*i:04d}",
|
| 354 |
"zone_id": zone_id,
|
| 355 |
"zone_name": zone_name,
|
| 356 |
"city": city,
|
| 357 |
-
"trigger_level": trigger_level,
|
| 358 |
"channel": "sms",
|
| 359 |
"language": "sw",
|
| 360 |
"recipient_count": enrolled,
|
| 361 |
"message_preview": (
|
| 362 |
-
f"TAHADHARI YA
|
| 363 |
f"{zone_name}, {city}. "
|
| 364 |
f"{explanation.swahili_text[:120]}"
|
| 365 |
),
|
|
@@ -371,8 +272,6 @@ class PipelineStore:
|
|
| 371 |
return notifications
|
| 372 |
|
| 373 |
def _build_pipeline_run(self, run_result) -> dict:
|
| 374 |
-
"""Convert a ``PipelineRunResult`` into the same dict shape as
|
| 375 |
-
the synthetic pipeline_runs entries."""
|
| 376 |
return {
|
| 377 |
"run_id": run_result.run_id,
|
| 378 |
"started_at": run_result.started_at,
|
|
@@ -400,7 +299,6 @@ class PipelineStore:
|
|
| 400 |
pipeline_runs: list[dict],
|
| 401 |
now: datetime,
|
| 402 |
) -> dict:
|
| 403 |
-
"""Build aggregate stats dict."""
|
| 404 |
total_runs = len(pipeline_runs)
|
| 405 |
successful = sum(1 for r in pipeline_runs if r["status"] == "ok")
|
| 406 |
total_cost = sum(r.get("total_cost_usd", 0) for r in pipeline_runs)
|
|
@@ -412,11 +310,11 @@ class PipelineStore:
|
|
| 412 |
"zones_monitored": len(ZONES),
|
| 413 |
"cities": len(CITIES),
|
| 414 |
"active_triggers": len([t for t in triggers if t.get("status") == "active"]),
|
| 415 |
-
"total_enrolled": sum(z["
|
| 416 |
"total_cost_usd": round(total_cost, 2),
|
| 417 |
"avg_cost_per_run_usd": round(total_cost / max(1, total_runs), 4),
|
| 418 |
"last_run": pipeline_runs[0]["started_at"] if pipeline_runs else None,
|
| 419 |
-
"data_sources": ["
|
| 420 |
}
|
| 421 |
|
| 422 |
|
|
|
|
| 12 |
import logging
|
| 13 |
import random
|
| 14 |
import threading
|
| 15 |
+
from datetime import datetime
|
|
|
|
| 16 |
from typing import Any
|
| 17 |
|
| 18 |
+
from config import ZONES, ZONE_MAP, CITIES, PAYOUT_PER_EVENT_USD
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
|
|
|
| 34 |
self.stats: dict[str, Any] = {}
|
| 35 |
self._lock = threading.Lock()
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def update_from_pipeline(self, pipeline, run_result=None):
|
| 38 |
"""Convert pipeline state into the same dict shapes that
|
| 39 |
``_generate_demo_data()`` in api.py produces, so the dashboard
|
| 40 |
works identically with real or synthetic data.
|
| 41 |
|
| 42 |
Args:
|
| 43 |
+
pipeline: A ``HeatRiskPipeline`` instance after ``.run()``
|
| 44 |
has completed.
|
| 45 |
run_result: Optional ``PipelineRunResult`` returned by
|
| 46 |
``pipeline.run()``.
|
|
|
|
| 49 |
try:
|
| 50 |
now = datetime.utcnow()
|
| 51 |
zones = self._build_zones(pipeline, now)
|
|
|
|
| 52 |
triggers = self._build_triggers(pipeline, zones, now)
|
| 53 |
basis_risk = self._build_basis_risk(pipeline)
|
| 54 |
notifications = self._build_notifications(pipeline, triggers, now)
|
| 55 |
pipeline_run = self._build_pipeline_run(run_result) if run_result else None
|
| 56 |
|
| 57 |
self.zones = zones
|
|
|
|
| 58 |
self.triggers = triggers
|
| 59 |
self.basis_risk = basis_risk
|
| 60 |
self.notifications = notifications
|
| 61 |
|
| 62 |
if pipeline_run:
|
| 63 |
self.pipeline_runs.insert(0, pipeline_run)
|
|
|
|
| 64 |
self.pipeline_runs = self.pipeline_runs[:50]
|
| 65 |
|
| 66 |
self.stats = self._build_stats(
|
|
|
|
| 79 |
# ------------------------------------------------------------------
|
| 80 |
|
| 81 |
def _build_zones(self, pipeline, now: datetime) -> list[dict]:
|
| 82 |
+
"""Build zone dicts from pipeline heat data."""
|
| 83 |
zones: list[dict] = []
|
| 84 |
rng = random.Random(42)
|
| 85 |
|
| 86 |
for z in ZONES:
|
| 87 |
zid = z.zone_id
|
| 88 |
|
| 89 |
+
heat = pipeline._heat_data.get(zid, {})
|
| 90 |
+
corrected_temps = heat.get("corrected_temps", [])
|
| 91 |
+
uhi_deltas = heat.get("uhi_deltas", [])
|
| 92 |
+
|
| 93 |
+
current_temp = corrected_temps[-1] if corrected_temps else 30.0
|
| 94 |
+
max_temp = max(corrected_temps) if corrected_temps else 33.0
|
| 95 |
+
mean_uhi = sum(uhi_deltas) / len(uhi_deltas) if uhi_deltas else 2.0
|
| 96 |
+
|
| 97 |
+
trigger_prob = heat.get("trigger_probability", 0.1)
|
| 98 |
+
pred_conf = heat.get("prediction_confidence", 0.3)
|
| 99 |
+
model_tier = heat.get("model_tier", "climatology")
|
| 100 |
+
|
| 101 |
+
# Determine risk level from triggers
|
| 102 |
+
zone_triggers = [t for t in pipeline._triggers if t.zone_id == zid]
|
|
|
|
|
|
|
| 103 |
if zone_triggers:
|
|
|
|
| 104 |
levels_priority = {"critical": 0, "warning": 1, "watch": 2}
|
| 105 |
+
best = min(zone_triggers, key=lambda t: levels_priority.get(t.trigger_level, 9))
|
|
|
|
|
|
|
|
|
|
| 106 |
risk_level = best.trigger_level
|
| 107 |
else:
|
| 108 |
risk_level = "normal"
|
| 109 |
|
|
|
|
| 110 |
healed = pipeline._healed.get(zid)
|
| 111 |
data_quality = healed.quality_score if healed else 0.85
|
| 112 |
|
| 113 |
+
enrolled = rng.randint(
|
| 114 |
+
200 if z.settlement_type == "informal" else 100,
|
| 115 |
+
800 if z.settlement_type == "informal" else 500,
|
| 116 |
+
)
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
zones.append({
|
| 119 |
"zone_id": zid,
|
|
|
|
| 123 |
"latitude": z.latitude,
|
| 124 |
"longitude": z.longitude,
|
| 125 |
"elevation_m": z.elevation_m,
|
|
|
|
|
|
|
| 126 |
"settlement_type": z.settlement_type,
|
| 127 |
+
"worker_population_est": z.worker_population_est,
|
| 128 |
+
"outdoor_exposure_pct": z.outdoor_exposure_pct,
|
| 129 |
+
"heat_vulnerability": z.heat_vulnerability,
|
| 130 |
"risk_level": risk_level,
|
| 131 |
+
"current_temp_c": round(current_temp, 1),
|
| 132 |
+
"max_temp_c": round(max_temp, 1),
|
| 133 |
+
"grid_temp_c": round(current_temp - mean_uhi, 1),
|
| 134 |
+
"uhi_delta_c": round(mean_uhi, 1),
|
| 135 |
+
"corrected_temp_c": round(current_temp, 1),
|
| 136 |
+
"trigger_probability_7d": round(trigger_prob, 3),
|
| 137 |
+
"prediction_confidence": round(pred_conf, 3),
|
| 138 |
+
"model_tier": model_tier,
|
| 139 |
+
"enrolled_workers": enrolled,
|
| 140 |
"data_quality": round(data_quality, 2),
|
| 141 |
"last_updated": now.isoformat(),
|
| 142 |
})
|
| 143 |
|
| 144 |
return zones
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
def _build_triggers(
|
| 147 |
self, pipeline, zones: list[dict], now: datetime,
|
| 148 |
) -> list[dict]:
|
| 149 |
+
"""Convert pipeline triggers into API-compatible dicts."""
|
|
|
|
| 150 |
triggers: list[dict] = []
|
| 151 |
+
enrolled_map = {z["zone_id"]: z["enrolled_workers"] for z in zones}
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
+
for te in pipeline._triggers:
|
| 154 |
+
payout = PAYOUT_PER_EVENT_USD.get(te.trigger_level, 5)
|
|
|
|
| 155 |
enrolled = enrolled_map.get(te.zone_id, 0)
|
| 156 |
|
| 157 |
triggers.append({
|
|
|
|
| 161 |
"city": te.city,
|
| 162 |
"trigger_level": te.trigger_level,
|
| 163 |
"trigger_date": te.trigger_date,
|
| 164 |
+
"heat_risk_score": round(getattr(te, "heat_risk_score", 0), 1),
|
| 165 |
+
"max_temp_c": round(getattr(te, "max_temp_c", 0), 1),
|
| 166 |
+
"max_wbgt_c": round(getattr(te, "max_wbgt_c", 0), 1),
|
| 167 |
+
"consecutive_days": getattr(te, "consecutive_days", 0),
|
| 168 |
+
"total_days_above": getattr(te, "total_days_above", 0),
|
| 169 |
"settlement_type": te.settlement_type,
|
| 170 |
+
"payout_per_worker_usd": payout,
|
| 171 |
+
"enrolled_workers": enrolled,
|
| 172 |
+
"total_payout_usd": payout * enrolled,
|
| 173 |
"status": te.status,
|
| 174 |
})
|
| 175 |
|
| 176 |
return triggers
|
| 177 |
|
| 178 |
def _build_basis_risk(self, pipeline) -> list[dict]:
|
| 179 |
+
"""Convert pipeline basis risk into API-compatible list."""
|
|
|
|
| 180 |
results: list[dict] = []
|
| 181 |
|
| 182 |
for zone_id, report in pipeline._basis_risk.items():
|
|
|
|
| 184 |
if zone is None:
|
| 185 |
continue
|
| 186 |
|
|
|
|
| 187 |
if hasattr(report, "overall_score"):
|
|
|
|
| 188 |
rec_text = "; ".join(report.recommendations) if report.recommendations else "Current calibration adequate"
|
| 189 |
results.append({
|
| 190 |
"zone_id": zone_id,
|
|
|
|
| 195 |
"false_negative_rate": round(report.false_negative_rate, 3),
|
| 196 |
"correlation": round(report.correlation, 3),
|
| 197 |
"settlement_type": report.settlement_type,
|
| 198 |
+
"heat_vulnerability": zone.heat_vulnerability,
|
| 199 |
"recommendation": rec_text,
|
| 200 |
})
|
| 201 |
else:
|
|
|
|
| 202 |
results.append({
|
| 203 |
"zone_id": zone_id,
|
| 204 |
"zone_name": zone.name,
|
|
|
|
| 208 |
"false_negative_rate": round(report.get("false_negative_rate", 0), 3),
|
| 209 |
"correlation": round(report.get("correlation", 0), 3),
|
| 210 |
"settlement_type": zone.settlement_type,
|
| 211 |
+
"heat_vulnerability": zone.heat_vulnerability,
|
| 212 |
"recommendation": report.get("recommendation", "Current calibration adequate"),
|
| 213 |
})
|
| 214 |
|
|
|
|
| 217 |
def _build_notifications(
|
| 218 |
self, pipeline, triggers: list[dict], now: datetime,
|
| 219 |
) -> list[dict]:
|
| 220 |
+
"""Convert pipeline explanations + notifications into API dicts."""
|
|
|
|
| 221 |
notifications: list[dict] = []
|
|
|
|
|
|
|
|
|
|
| 222 |
trigger_map = {t["zone_id"]: t for t in triggers}
|
| 223 |
|
| 224 |
for i, explanation in enumerate(pipeline._explanations, start=1):
|
| 225 |
zone_id = explanation.zone_id
|
| 226 |
trigger = trigger_map.get(zone_id, {})
|
|
|
|
|
|
|
| 227 |
zone = ZONE_MAP.get(zone_id)
|
| 228 |
zone_name = zone.name if zone else zone_id
|
| 229 |
city = zone.city if zone else ""
|
| 230 |
+
enrolled = trigger.get("enrolled_workers", 0)
|
|
|
|
|
|
|
| 231 |
|
|
|
|
| 232 |
delivery = pipeline._notifications[i - 1] if i <= len(pipeline._notifications) else None
|
| 233 |
|
|
|
|
| 234 |
notifications.append({
|
| 235 |
"id": f"NOT-{2*i - 1:04d}",
|
| 236 |
"zone_id": zone_id,
|
| 237 |
"zone_name": zone_name,
|
| 238 |
"city": city,
|
| 239 |
+
"trigger_level": explanation.trigger_level,
|
| 240 |
"channel": delivery.channel if delivery else "console",
|
| 241 |
"language": "en",
|
| 242 |
"recipient_count": enrolled,
|
| 243 |
"message_preview": (
|
| 244 |
+
f"HEAT ALERT [{explanation.trigger_level.upper()}]: "
|
| 245 |
f"{zone_name}, {city}. "
|
| 246 |
f"{explanation.english_text[:120]}"
|
| 247 |
),
|
|
|
|
| 250 |
"cost_estimate": round(delivery.cost_estimate, 2) if delivery else 0.0,
|
| 251 |
})
|
| 252 |
|
|
|
|
| 253 |
notifications.append({
|
| 254 |
"id": f"NOT-{2*i:04d}",
|
| 255 |
"zone_id": zone_id,
|
| 256 |
"zone_name": zone_name,
|
| 257 |
"city": city,
|
| 258 |
+
"trigger_level": explanation.trigger_level,
|
| 259 |
"channel": "sms",
|
| 260 |
"language": "sw",
|
| 261 |
"recipient_count": enrolled,
|
| 262 |
"message_preview": (
|
| 263 |
+
f"TAHADHARI YA JOTO [{explanation.trigger_level.upper()}]: "
|
| 264 |
f"{zone_name}, {city}. "
|
| 265 |
f"{explanation.swahili_text[:120]}"
|
| 266 |
),
|
|
|
|
| 272 |
return notifications
|
| 273 |
|
| 274 |
def _build_pipeline_run(self, run_result) -> dict:
|
|
|
|
|
|
|
| 275 |
return {
|
| 276 |
"run_id": run_result.run_id,
|
| 277 |
"started_at": run_result.started_at,
|
|
|
|
| 299 |
pipeline_runs: list[dict],
|
| 300 |
now: datetime,
|
| 301 |
) -> dict:
|
|
|
|
| 302 |
total_runs = len(pipeline_runs)
|
| 303 |
successful = sum(1 for r in pipeline_runs if r["status"] == "ok")
|
| 304 |
total_cost = sum(r.get("total_cost_usd", 0) for r in pipeline_runs)
|
|
|
|
| 310 |
"zones_monitored": len(ZONES),
|
| 311 |
"cities": len(CITIES),
|
| 312 |
"active_triggers": len([t for t in triggers if t.get("status") == "active"]),
|
| 313 |
+
"total_enrolled": sum(z["enrolled_workers"] for z in zones),
|
| 314 |
"total_cost_usd": round(total_cost, 2),
|
| 315 |
"avg_cost_per_run_usd": round(total_cost / max(1, total_runs), 4),
|
| 316 |
"last_run": pipeline_runs[0]["started_at"] if pipeline_runs else None,
|
| 317 |
+
"data_sources": ["ERA5-Land", "NASA POWER"],
|
| 318 |
}
|
| 319 |
|
| 320 |
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared test configuration for the Climate Risk Engine evaluation suite."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
# Ensure the project root is importable
|
| 7 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 9 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
tests/eval_healing.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate healing agent fault detection on injected faults."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import pytest
|
| 5 |
+
from src.healing.healer import RuleBasedFallback
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _create_clean_reading(zone_id, date, temp=30.0, humidity=70.0, precip=5.0):
|
| 9 |
+
"""Create a clean reading dict matching the format RuleBasedFallback expects."""
|
| 10 |
+
return {
|
| 11 |
+
"zone_id": zone_id,
|
| 12 |
+
"date": date,
|
| 13 |
+
"temp_mean_c": temp - 3,
|
| 14 |
+
"temp_max_c": temp,
|
| 15 |
+
"temp_min_c": temp - 6,
|
| 16 |
+
"humidity_pct": humidity,
|
| 17 |
+
"wind_speed_ms": 3.0,
|
| 18 |
+
"precip_mm": precip,
|
| 19 |
+
"precip_chirps_mm": precip,
|
| 20 |
+
"precip_nasa_mm": precip + 1.0,
|
| 21 |
+
"source": "test",
|
| 22 |
+
"data_quality": 1.0,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _inject_fault(base, overrides):
|
| 27 |
+
"""Return a copy of base reading with specific field overrides."""
|
| 28 |
+
copy = dict(base)
|
| 29 |
+
copy.update(overrides)
|
| 30 |
+
return copy
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
FAULT_CASES = [
|
| 34 |
+
("impossible_temp", {"temp_mean_c": 65.0}),
|
| 35 |
+
("negative_precip", {"precip_mm": -10.0, "precip_chirps_mm": -10.0}),
|
| 36 |
+
("extreme_precip", {"precip_mm": 600.0, "precip_chirps_mm": 600.0, "precip_nasa_mm": 600.0}),
|
| 37 |
+
("missing_precip", {"precip_mm": None, "precip_chirps_mm": 8.0, "precip_nasa_mm": 7.0}),
|
| 38 |
+
("temp_typo", {"temp_mean_c": 320.0}),
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_fault_detection():
|
| 43 |
+
"""Rule-based healer should detect injected faults."""
|
| 44 |
+
healer = RuleBasedFallback()
|
| 45 |
+
results = {}
|
| 46 |
+
|
| 47 |
+
for fault_name, fault_overrides in FAULT_CASES:
|
| 48 |
+
# Create a batch with 9 clean + 1 faulty reading
|
| 49 |
+
readings = [_create_clean_reading("NBO-KIB", f"2024-01-{i + 1:02d}") for i in range(9)]
|
| 50 |
+
faulty = _inject_fault(
|
| 51 |
+
_create_clean_reading("NBO-KIB", "2024-01-10"),
|
| 52 |
+
fault_overrides,
|
| 53 |
+
)
|
| 54 |
+
readings.append(faulty)
|
| 55 |
+
|
| 56 |
+
healed_data = healer.heal_batch(readings)
|
| 57 |
+
|
| 58 |
+
# The faulty reading should have been flagged/corrected
|
| 59 |
+
faulty_assessments = [a for a in healed_data.assessments if a.date == "2024-01-10"]
|
| 60 |
+
clean_assessments = [a for a in healed_data.assessments if a.date != "2024-01-10"]
|
| 61 |
+
|
| 62 |
+
faulty_detected = False
|
| 63 |
+
if faulty_assessments:
|
| 64 |
+
faulty_detected = faulty_assessments[0].assessment in ("corrected", "filled", "flagged")
|
| 65 |
+
|
| 66 |
+
clean_correct = sum(1 for a in clean_assessments if a.assessment == "good")
|
| 67 |
+
|
| 68 |
+
results[fault_name] = {
|
| 69 |
+
"detected": faulty_detected,
|
| 70 |
+
"assessment": faulty_assessments[0].assessment if faulty_assessments else "missing",
|
| 71 |
+
"clean_correct": clean_correct,
|
| 72 |
+
"clean_total": len(clean_assessments),
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
os.makedirs("tests/eval_results", exist_ok=True)
|
| 76 |
+
with open("tests/eval_results/healing_eval.json", "w") as f:
|
| 77 |
+
json.dump(results, f, indent=2)
|
| 78 |
+
|
| 79 |
+
# At least 3/5 faults should be detected
|
| 80 |
+
detected = sum(1 for r in results.values() if r["detected"])
|
| 81 |
+
assert detected >= 3, f"Only {detected}/5 faults detected: {results}"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_clean_data_passes():
|
| 85 |
+
"""Clean data should pass through without false positives."""
|
| 86 |
+
healer = RuleBasedFallback()
|
| 87 |
+
readings = [_create_clean_reading("NBO-KIB", f"2024-01-{i + 1:02d}") for i in range(30)]
|
| 88 |
+
healed_data = healer.heal_batch(readings)
|
| 89 |
+
|
| 90 |
+
good_count = sum(1 for a in healed_data.assessments if a.assessment == "good")
|
| 91 |
+
assert good_count >= 25, f"Only {good_count}/30 clean readings marked as good (too many false positives)"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_quality_scores_monotonic():
|
| 95 |
+
"""Quality scores should be: good > corrected > filled > flagged."""
|
| 96 |
+
healer = RuleBasedFallback()
|
| 97 |
+
|
| 98 |
+
# good reading
|
| 99 |
+
good_r = _create_clean_reading("NBO-KIB", "2024-01-01")
|
| 100 |
+
good_healed, good_assess = healer.heal_reading(good_r)
|
| 101 |
+
|
| 102 |
+
# corrected reading (negative precip)
|
| 103 |
+
corrected_r = _inject_fault(
|
| 104 |
+
_create_clean_reading("NBO-KIB", "2024-01-02"),
|
| 105 |
+
{"precip_mm": -5.0, "precip_chirps_mm": -5.0},
|
| 106 |
+
)
|
| 107 |
+
corrected_healed, corrected_assess = healer.heal_reading(corrected_r)
|
| 108 |
+
|
| 109 |
+
# filled reading (missing precip, one source available)
|
| 110 |
+
filled_r = _inject_fault(
|
| 111 |
+
_create_clean_reading("NBO-KIB", "2024-01-03"),
|
| 112 |
+
{"precip_mm": None, "precip_chirps_mm": 3.0, "precip_nasa_mm": None},
|
| 113 |
+
)
|
| 114 |
+
filled_healed, filled_assess = healer.heal_reading(filled_r)
|
| 115 |
+
|
| 116 |
+
assert good_healed.quality_score > corrected_healed.quality_score, (
|
| 117 |
+
f"Good ({good_healed.quality_score}) should > corrected ({corrected_healed.quality_score})"
|
| 118 |
+
)
|
| 119 |
+
assert corrected_healed.quality_score > filled_healed.quality_score, (
|
| 120 |
+
f"Corrected ({corrected_healed.quality_score}) should > filled ({filled_healed.quality_score})"
|
| 121 |
+
)
|
tests/eval_heat_predictor.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate heat wave prediction models."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pytest
|
| 6 |
+
from sklearn.metrics import roc_auc_score, precision_score, recall_score
|
| 7 |
+
from src.prediction.heat_forecast import HeatWavePredictor, CITY_THRESHOLDS
|
| 8 |
+
from config import ZONES
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _generate_test_data(zone, n_days=365, seed=123):
|
| 12 |
+
"""Generate synthetic test data with known trigger labels."""
|
| 13 |
+
rng = np.random.RandomState(seed)
|
| 14 |
+
# Use the city threshold from the model's own config
|
| 15 |
+
threshold = CITY_THRESHOLDS.get(zone.city, 33.0)
|
| 16 |
+
|
| 17 |
+
temps = []
|
| 18 |
+
humidities = []
|
| 19 |
+
wbgts = []
|
| 20 |
+
|
| 21 |
+
# Seasonal pattern + noise
|
| 22 |
+
for day in range(n_days):
|
| 23 |
+
seasonal = 3 * np.sin(2 * np.pi * day / 365)
|
| 24 |
+
t = threshold - 2 + seasonal + rng.randn() * 3
|
| 25 |
+
h = 65 + rng.randn() * 10
|
| 26 |
+
w = 0.7 * t + 0.3 * h * 0.3 - 10 # simplified WBGT
|
| 27 |
+
temps.append(t)
|
| 28 |
+
humidities.append(max(20, min(100, h)))
|
| 29 |
+
wbgts.append(w)
|
| 30 |
+
|
| 31 |
+
# Generate ground-truth labels (trigger if 2+ consecutive days above threshold in next 7)
|
| 32 |
+
labels = []
|
| 33 |
+
for i in range(n_days - 7):
|
| 34 |
+
future = temps[i + 1:i + 8]
|
| 35 |
+
consecutive = 0
|
| 36 |
+
max_consec = 0
|
| 37 |
+
for t in future:
|
| 38 |
+
if t >= threshold:
|
| 39 |
+
consecutive += 1
|
| 40 |
+
max_consec = max(max_consec, consecutive)
|
| 41 |
+
else:
|
| 42 |
+
consecutive = 0
|
| 43 |
+
labels.append(1 if max_consec >= 2 else 0)
|
| 44 |
+
|
| 45 |
+
return temps, humidities, wbgts, labels
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_predictor_output_valid():
|
| 49 |
+
"""Predictions should be valid probabilities with confidence."""
|
| 50 |
+
predictor = HeatWavePredictor()
|
| 51 |
+
zone = ZONES[0]
|
| 52 |
+
temps = [30 + np.random.randn() * 3 for _ in range(30)]
|
| 53 |
+
humidity = [70 + np.random.randn() * 5 for _ in range(30)]
|
| 54 |
+
wbgt = [28 + np.random.randn() * 2 for _ in range(30)]
|
| 55 |
+
|
| 56 |
+
prob, conf, tier = predictor.predict(zone, temps, humidity, wbgt)
|
| 57 |
+
assert 0 <= prob <= 1, f"Probability {prob} out of [0,1]"
|
| 58 |
+
assert 0 <= conf <= 1, f"Confidence {conf} out of [0,1]"
|
| 59 |
+
assert tier in ("ensemble", "full_model", "lstm_only", "persistence", "climatology")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_predictor_tier_fallback():
|
| 63 |
+
"""Test that minimal data degrades to a fallback tier with lower confidence."""
|
| 64 |
+
predictor = HeatWavePredictor()
|
| 65 |
+
zone = ZONES[0]
|
| 66 |
+
|
| 67 |
+
# Full data -> should get full_model, ensemble, or lstm_only
|
| 68 |
+
full_temps = [30 + np.random.randn() * 3 for _ in range(90)]
|
| 69 |
+
full_hum = [70 + np.random.randn() * 5 for _ in range(90)]
|
| 70 |
+
full_wbgt = [28 + np.random.randn() * 2 for _ in range(90)]
|
| 71 |
+
prob, conf, tier = predictor.predict(zone, full_temps, full_hum, full_wbgt)
|
| 72 |
+
assert tier in ("ensemble", "full_model", "lstm_only")
|
| 73 |
+
|
| 74 |
+
# Minimal data -> should fall back to persistence or climatology
|
| 75 |
+
min_temps = [30, 31, 32]
|
| 76 |
+
min_hum = [70, 70, 70]
|
| 77 |
+
min_wbgt = [28, 28, 28]
|
| 78 |
+
prob2, conf2, tier2 = predictor.predict(zone, min_temps, min_hum, min_wbgt)
|
| 79 |
+
assert tier2 in ("persistence", "climatology", "ensemble", "full_model", "lstm_only")
|
| 80 |
+
# Less data should generally mean equal or less confidence
|
| 81 |
+
assert conf2 <= conf + 0.1, f"Minimal-data confidence ({conf2}) should not greatly exceed full-data ({conf})"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_predictor_discrimination():
|
| 85 |
+
"""Model should assign higher probability to hot sequences."""
|
| 86 |
+
predictor = HeatWavePredictor()
|
| 87 |
+
zone = ZONES[0]
|
| 88 |
+
|
| 89 |
+
# Hot sequence (should trigger)
|
| 90 |
+
hot = [36 + i * 0.2 for i in range(30)]
|
| 91 |
+
hot_hum = [80] * 30
|
| 92 |
+
hot_wbgt = [32 + i * 0.1 for i in range(30)]
|
| 93 |
+
|
| 94 |
+
# Cool sequence (should not trigger)
|
| 95 |
+
cool = [22 + np.sin(i / 5) for i in range(30)]
|
| 96 |
+
cool_hum = [50] * 30
|
| 97 |
+
cool_wbgt = [20 + np.sin(i / 5) for i in range(30)]
|
| 98 |
+
|
| 99 |
+
p_hot, _, _ = predictor.predict(zone, hot, hot_hum, hot_wbgt)
|
| 100 |
+
p_cool, _, _ = predictor.predict(zone, cool, cool_hum, cool_wbgt)
|
| 101 |
+
|
| 102 |
+
assert p_hot > p_cool, f"Hot prob ({p_hot:.3f}) should > cool prob ({p_cool:.3f})"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_predictor_metrics():
|
| 106 |
+
"""Compute AUROC and calibration on synthetic held-out data."""
|
| 107 |
+
predictor = HeatWavePredictor()
|
| 108 |
+
results = {}
|
| 109 |
+
|
| 110 |
+
# Sample one zone per city
|
| 111 |
+
seen_cities = set()
|
| 112 |
+
sample_zones = []
|
| 113 |
+
for z in ZONES:
|
| 114 |
+
if z.city not in seen_cities:
|
| 115 |
+
sample_zones.append(z)
|
| 116 |
+
seen_cities.add(z.city)
|
| 117 |
+
|
| 118 |
+
for zone in sample_zones:
|
| 119 |
+
temps, humidities, wbgts, labels = _generate_test_data(zone)
|
| 120 |
+
|
| 121 |
+
predictions = []
|
| 122 |
+
for i in range(30, len(labels)):
|
| 123 |
+
prob, _, tier = predictor.predict(
|
| 124 |
+
zone, temps[i - 30:i], humidities[i - 30:i], wbgts[i - 30:i]
|
| 125 |
+
)
|
| 126 |
+
predictions.append(prob)
|
| 127 |
+
|
| 128 |
+
# Align labels with predictions
|
| 129 |
+
y_true = labels[30:30 + len(predictions)]
|
| 130 |
+
y_pred = predictions[:len(y_true)]
|
| 131 |
+
|
| 132 |
+
if len(set(y_true)) > 1: # need both classes for AUROC
|
| 133 |
+
auroc = roc_auc_score(y_true, y_pred)
|
| 134 |
+
binary = [1 if p > 0.5 else 0 for p in y_pred]
|
| 135 |
+
precision = precision_score(y_true, binary, zero_division=0)
|
| 136 |
+
recall = recall_score(y_true, binary, zero_division=0)
|
| 137 |
+
else:
|
| 138 |
+
auroc = float('nan')
|
| 139 |
+
precision = float('nan')
|
| 140 |
+
recall = float('nan')
|
| 141 |
+
|
| 142 |
+
results[zone.zone_id] = {
|
| 143 |
+
"city": zone.city,
|
| 144 |
+
"auroc": round(auroc, 3) if not np.isnan(auroc) else None,
|
| 145 |
+
"precision": round(precision, 3) if not np.isnan(precision) else None,
|
| 146 |
+
"recall": round(recall, 3) if not np.isnan(recall) else None,
|
| 147 |
+
"n_samples": len(y_true),
|
| 148 |
+
"positive_rate": round(sum(y_true) / len(y_true), 3) if y_true else 0,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
os.makedirs("tests/eval_results", exist_ok=True)
|
| 152 |
+
with open("tests/eval_results/heat_predictor_eval.json", "w") as f:
|
| 153 |
+
json.dump(results, f, indent=2)
|
| 154 |
+
|
| 155 |
+
# At least one zone should have AUROC > 0.5 (better than random)
|
| 156 |
+
valid_aurocs = [r["auroc"] for r in results.values() if r["auroc"] is not None]
|
| 157 |
+
assert any(a > 0.5 for a in valid_aurocs), f"No zone has AUROC > 0.5: {valid_aurocs}"
|
tests/eval_pipeline.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end pipeline evaluation: schema, CRUD, and API tests."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import pytest
|
| 5 |
+
from src.database.crud import PgConnection, InMemoryStore
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_inmemory_crud_roundtrip():
|
| 9 |
+
"""Test database CRUD with InMemoryStore."""
|
| 10 |
+
store = InMemoryStore()
|
| 11 |
+
|
| 12 |
+
# Insert a zone
|
| 13 |
+
zone_data = {
|
| 14 |
+
"zone_id": "NBO-KIB",
|
| 15 |
+
"name": "Kibera",
|
| 16 |
+
"city": "Nairobi",
|
| 17 |
+
"country": "Kenya",
|
| 18 |
+
"latitude": -1.3133,
|
| 19 |
+
"longitude": 36.7876,
|
| 20 |
+
"elevation_m": 1720,
|
| 21 |
+
"settlement_type": "informal",
|
| 22 |
+
"heat_vulnerability": "moderate",
|
| 23 |
+
}
|
| 24 |
+
row_id = store.insert_zone("NBO-KIB", zone_data)
|
| 25 |
+
assert row_id > 0
|
| 26 |
+
|
| 27 |
+
# Query it back
|
| 28 |
+
fetched = store.get_zone("NBO-KIB")
|
| 29 |
+
assert fetched is not None
|
| 30 |
+
assert fetched["zone_id"] == "NBO-KIB"
|
| 31 |
+
assert fetched["city"] == "Nairobi"
|
| 32 |
+
assert fetched["settlement_type"] == "informal"
|
| 33 |
+
|
| 34 |
+
# Insert a heat index record
|
| 35 |
+
hi_id = store.insert_heat_index("NBO-KIB", "2024-01-15", {
|
| 36 |
+
"grid_temp_c": 28.0,
|
| 37 |
+
"uhi_delta_c": 3.5,
|
| 38 |
+
"corrected_temp_c": 31.5,
|
| 39 |
+
"wbgt_c": 29.0,
|
| 40 |
+
"heat_risk_score": 65.0,
|
| 41 |
+
"risk_level": "warning",
|
| 42 |
+
})
|
| 43 |
+
assert hi_id > 0
|
| 44 |
+
|
| 45 |
+
# Query heat indices
|
| 46 |
+
records = store.get_recent_heat_indices("NBO-KIB", limit=10)
|
| 47 |
+
assert len(records) == 1
|
| 48 |
+
assert records[0]["corrected_temp_c"] == 31.5
|
| 49 |
+
|
| 50 |
+
# Insert a prediction
|
| 51 |
+
pred_id = store.insert_prediction("NBO-KIB", "2024-01-15", {
|
| 52 |
+
"trigger_probability_7d": 0.72,
|
| 53 |
+
"prediction_confidence": 0.85,
|
| 54 |
+
"model_tier": "ensemble",
|
| 55 |
+
})
|
| 56 |
+
assert pred_id > 0
|
| 57 |
+
|
| 58 |
+
preds = store.get_recent_predictions("NBO-KIB", limit=10)
|
| 59 |
+
assert len(preds) == 1
|
| 60 |
+
assert preds[0]["trigger_probability_7d"] == 0.72
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_schema_ddl():
|
| 64 |
+
"""Verify schema DDL is valid SQL and has the expected tables."""
|
| 65 |
+
from src.database.schema import get_full_ddl, get_table_names
|
| 66 |
+
|
| 67 |
+
ddl = get_full_ddl()
|
| 68 |
+
tables = get_table_names()
|
| 69 |
+
|
| 70 |
+
assert len(tables) == 11
|
| 71 |
+
assert "zones" in tables
|
| 72 |
+
assert "heat_indices" in tables
|
| 73 |
+
assert "predictions" in tables
|
| 74 |
+
assert "trigger_events" in tables
|
| 75 |
+
assert "basis_risk" in tables
|
| 76 |
+
assert "explanations" in tables
|
| 77 |
+
assert "notifications" in tables
|
| 78 |
+
assert "pipeline_runs" in tables
|
| 79 |
+
|
| 80 |
+
# Verify no flood references (this is a heat engine)
|
| 81 |
+
assert "flood" not in ddl.lower(), "Schema should not reference flood"
|
| 82 |
+
assert "spi" not in ddl.lower(), "Schema should not reference SPI"
|
| 83 |
+
|
| 84 |
+
# Verify heat-specific constructs are present
|
| 85 |
+
assert "wbgt" in ddl.lower(), "Schema should reference WBGT"
|
| 86 |
+
assert "heat_risk_score" in ddl.lower(), "Schema should reference heat_risk_score"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_api_health():
|
| 90 |
+
"""Verify API starts and serves health endpoint."""
|
| 91 |
+
from fastapi.testclient import TestClient
|
| 92 |
+
from src.api import app
|
| 93 |
+
|
| 94 |
+
client = TestClient(app)
|
| 95 |
+
response = client.get("/health")
|
| 96 |
+
assert response.status_code == 200
|
| 97 |
+
data = response.json()
|
| 98 |
+
assert data["status"] == "ok"
|
| 99 |
+
assert "heat" in data.get("service", "").lower() or "risk" in data.get("service", "").lower()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_api_zones():
|
| 103 |
+
"""Verify zones endpoint returns data for all 20 zones."""
|
| 104 |
+
from fastapi.testclient import TestClient
|
| 105 |
+
from src.api import app
|
| 106 |
+
|
| 107 |
+
client = TestClient(app)
|
| 108 |
+
response = client.get("/api/zones")
|
| 109 |
+
assert response.status_code == 200
|
| 110 |
+
body = response.json()
|
| 111 |
+
zones = body.get("zones", body) if isinstance(body, dict) else body
|
| 112 |
+
assert len(zones) == 20
|
| 113 |
+
|
| 114 |
+
# Verify heat-specific fields exist on each zone
|
| 115 |
+
z = zones[0]
|
| 116 |
+
assert "settlement_type" in z, "Zone should have settlement_type"
|
| 117 |
+
assert "heat_vulnerability" in z or "risk_level" in z, "Zone should have heat vulnerability info"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_api_triggers():
|
| 121 |
+
"""Verify triggers endpoint returns structured data."""
|
| 122 |
+
from fastapi.testclient import TestClient
|
| 123 |
+
from src.api import app
|
| 124 |
+
|
| 125 |
+
client = TestClient(app)
|
| 126 |
+
response = client.get("/api/triggers")
|
| 127 |
+
assert response.status_code == 200
|
| 128 |
+
body = response.json()
|
| 129 |
+
assert "triggers" in body
|
| 130 |
+
assert "total" in body
|
| 131 |
+
# Trigger levels should be heat-specific
|
| 132 |
+
if body["triggers"]:
|
| 133 |
+
t = body["triggers"][0]
|
| 134 |
+
assert t.get("trigger_level") in ("critical", "warning", "watch")
|
tests/eval_rag.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate RAG retrieval quality with golden queries."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
GOLDEN_QUERIES = [
|
| 7 |
+
{
|
| 8 |
+
"query": "what should workers do in extreme heat",
|
| 9 |
+
"expected_themes": ["shade", "water", "rest", "break"],
|
| 10 |
+
"category": "safety",
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"query": "Jangwani informal settlement heat risk",
|
| 14 |
+
"expected_themes": ["Jangwani", "informal", "tin roof"],
|
| 15 |
+
"zone_id": "DAR-JAN",
|
| 16 |
+
"category": "zone",
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"query": "how does parametric insurance payout automatically",
|
| 20 |
+
"expected_themes": ["parametric", "automatic", "trigger", "payout"],
|
| 21 |
+
"category": "insurance",
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"query": "emergency phone numbers Nairobi",
|
| 25 |
+
"expected_themes": ["Red Cross", "ambulance", "999", "Nairobi"],
|
| 26 |
+
"category": "emergency",
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"query": "critical heat alert actions stop work immediately",
|
| 30 |
+
"expected_themes": ["critical", "stop", "emergency", "medical"],
|
| 31 |
+
"category": "actions",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"query": "Kibera corrugated tin roofs urban heat",
|
| 35 |
+
"expected_themes": ["Kibera", "tin", "heat"],
|
| 36 |
+
"zone_id": "NBO-KIB",
|
| 37 |
+
"category": "zone",
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"query": "heat stroke dehydration symptoms workers",
|
| 41 |
+
"expected_themes": ["heat", "water", "hydration"],
|
| 42 |
+
"category": "safety",
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"query": "Swahili translation heat warning joto",
|
| 46 |
+
"expected_themes": ["joto", "Swahili", "tahadhari"],
|
| 47 |
+
"category": "language",
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"query": "basis risk what if trigger wrong",
|
| 51 |
+
"expected_themes": ["basis risk", "false"],
|
| 52 |
+
"category": "insurance",
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"query": "warning level heat alert two consecutive days",
|
| 56 |
+
"expected_themes": ["warning", "consecutive", "reduce"],
|
| 57 |
+
"category": "actions",
|
| 58 |
+
},
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@pytest.fixture
|
| 63 |
+
def retriever():
|
| 64 |
+
try:
|
| 65 |
+
from src.explanation.rag_provider import HybridRetriever
|
| 66 |
+
return HybridRetriever()
|
| 67 |
+
except Exception as e:
|
| 68 |
+
pytest.skip(f"RAG index not built: {e}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_retrieval_returns_results(retriever):
|
| 72 |
+
"""Each golden query should return at least 1 result."""
|
| 73 |
+
for case in GOLDEN_QUERIES:
|
| 74 |
+
docs = retriever.retrieve(case["query"], zone_id=case.get("zone_id"), top_k=5)
|
| 75 |
+
assert len(docs) > 0, f"No results for: {case['query']}"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_retrieval_theme_coverage(retriever):
|
| 79 |
+
"""Retrieved docs should contain expected themes."""
|
| 80 |
+
results = []
|
| 81 |
+
for case in GOLDEN_QUERIES:
|
| 82 |
+
docs = retriever.retrieve(case["query"], zone_id=case.get("zone_id"), top_k=5)
|
| 83 |
+
combined = " ".join(docs).lower()
|
| 84 |
+
|
| 85 |
+
themes_found = [t for t in case["expected_themes"] if t.lower() in combined]
|
| 86 |
+
coverage = len(themes_found) / len(case["expected_themes"])
|
| 87 |
+
|
| 88 |
+
results.append({
|
| 89 |
+
"query": case["query"],
|
| 90 |
+
"category": case["category"],
|
| 91 |
+
"themes_expected": case["expected_themes"],
|
| 92 |
+
"themes_found": themes_found,
|
| 93 |
+
"coverage": round(coverage, 2),
|
| 94 |
+
"docs_returned": len(docs),
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
os.makedirs("tests/eval_results", exist_ok=True)
|
| 98 |
+
with open("tests/eval_results/rag_eval.json", "w") as f:
|
| 99 |
+
json.dump(results, f, indent=2)
|
| 100 |
+
|
| 101 |
+
# At least 70% of queries should have >50% theme coverage
|
| 102 |
+
good = sum(1 for r in results if r["coverage"] >= 0.5)
|
| 103 |
+
assert good >= len(results) * 0.7, f"Only {good}/{len(results)} queries have 50%+ theme coverage"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_zone_boosting(retriever):
|
| 107 |
+
"""Zone-specific queries should rank the matching zone doc higher."""
|
| 108 |
+
docs_with_boost = retriever.retrieve(
|
| 109 |
+
"heat risk informal settlement", zone_id="DAR-JAN", top_k=5
|
| 110 |
+
)
|
| 111 |
+
docs_without_boost = retriever.retrieve(
|
| 112 |
+
"heat risk informal settlement", top_k=5
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# With zone boost, DAR-JAN content should appear earlier
|
| 116 |
+
def contains_jangwani(docs):
|
| 117 |
+
for i, d in enumerate(docs):
|
| 118 |
+
if "jangwani" in d.lower() or "dar-jan" in d.lower():
|
| 119 |
+
return i
|
| 120 |
+
return len(docs)
|
| 121 |
+
|
| 122 |
+
rank_with = contains_jangwani(docs_with_boost)
|
| 123 |
+
rank_without = contains_jangwani(docs_without_boost)
|
| 124 |
+
assert rank_with <= rank_without, "Zone boosting should improve rank of matching zone"
|
tests/eval_results/healing_eval.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"impossible_temp": {
|
| 3 |
+
"detected": true,
|
| 4 |
+
"assessment": "flagged",
|
| 5 |
+
"clean_correct": 9,
|
| 6 |
+
"clean_total": 9
|
| 7 |
+
},
|
| 8 |
+
"negative_precip": {
|
| 9 |
+
"detected": true,
|
| 10 |
+
"assessment": "corrected",
|
| 11 |
+
"clean_correct": 9,
|
| 12 |
+
"clean_total": 9
|
| 13 |
+
},
|
| 14 |
+
"extreme_precip": {
|
| 15 |
+
"detected": true,
|
| 16 |
+
"assessment": "corrected",
|
| 17 |
+
"clean_correct": 9,
|
| 18 |
+
"clean_total": 9
|
| 19 |
+
},
|
| 20 |
+
"missing_precip": {
|
| 21 |
+
"detected": true,
|
| 22 |
+
"assessment": "filled",
|
| 23 |
+
"clean_correct": 9,
|
| 24 |
+
"clean_total": 9
|
| 25 |
+
},
|
| 26 |
+
"temp_typo": {
|
| 27 |
+
"detected": true,
|
| 28 |
+
"assessment": "corrected",
|
| 29 |
+
"clean_correct": 9,
|
| 30 |
+
"clean_total": 9
|
| 31 |
+
}
|
| 32 |
+
}
|
tests/eval_results/heat_predictor_eval.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"NBO-KIB": {
|
| 3 |
+
"city": "Nairobi",
|
| 4 |
+
"auroc": 0.733,
|
| 5 |
+
"precision": 0.452,
|
| 6 |
+
"recall": 0.287,
|
| 7 |
+
"n_samples": 328,
|
| 8 |
+
"positive_rate": 0.351
|
| 9 |
+
},
|
| 10 |
+
"DAR-JAN": {
|
| 11 |
+
"city": "Dar es Salaam",
|
| 12 |
+
"auroc": 0.712,
|
| 13 |
+
"precision": 0.512,
|
| 14 |
+
"recall": 0.713,
|
| 15 |
+
"n_samples": 328,
|
| 16 |
+
"positive_rate": 0.351
|
| 17 |
+
},
|
| 18 |
+
"KLA-BWA": {
|
| 19 |
+
"city": "Kampala",
|
| 20 |
+
"auroc": 0.695,
|
| 21 |
+
"precision": 0.438,
|
| 22 |
+
"recall": 0.487,
|
| 23 |
+
"n_samples": 328,
|
| 24 |
+
"positive_rate": 0.351
|
| 25 |
+
},
|
| 26 |
+
"KGL-NYA": {
|
| 27 |
+
"city": "Kigali",
|
| 28 |
+
"auroc": 0.719,
|
| 29 |
+
"precision": 0.414,
|
| 30 |
+
"recall": 0.357,
|
| 31 |
+
"n_samples": 328,
|
| 32 |
+
"positive_rate": 0.351
|
| 33 |
+
}
|
| 34 |
+
}
|
tests/eval_results/rag_eval.json
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"query": "what should workers do in extreme heat",
|
| 4 |
+
"category": "safety",
|
| 5 |
+
"themes_expected": [
|
| 6 |
+
"shade",
|
| 7 |
+
"water",
|
| 8 |
+
"rest",
|
| 9 |
+
"break"
|
| 10 |
+
],
|
| 11 |
+
"themes_found": [
|
| 12 |
+
"shade",
|
| 13 |
+
"water",
|
| 14 |
+
"rest",
|
| 15 |
+
"break"
|
| 16 |
+
],
|
| 17 |
+
"coverage": 1.0,
|
| 18 |
+
"docs_returned": 5
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"query": "Jangwani informal settlement heat risk",
|
| 22 |
+
"category": "zone",
|
| 23 |
+
"themes_expected": [
|
| 24 |
+
"Jangwani",
|
| 25 |
+
"informal",
|
| 26 |
+
"tin roof"
|
| 27 |
+
],
|
| 28 |
+
"themes_found": [
|
| 29 |
+
"Jangwani",
|
| 30 |
+
"informal",
|
| 31 |
+
"tin roof"
|
| 32 |
+
],
|
| 33 |
+
"coverage": 1.0,
|
| 34 |
+
"docs_returned": 5
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"query": "how does parametric insurance payout automatically",
|
| 38 |
+
"category": "insurance",
|
| 39 |
+
"themes_expected": [
|
| 40 |
+
"parametric",
|
| 41 |
+
"automatic",
|
| 42 |
+
"trigger",
|
| 43 |
+
"payout"
|
| 44 |
+
],
|
| 45 |
+
"themes_found": [
|
| 46 |
+
"parametric",
|
| 47 |
+
"automatic",
|
| 48 |
+
"trigger",
|
| 49 |
+
"payout"
|
| 50 |
+
],
|
| 51 |
+
"coverage": 1.0,
|
| 52 |
+
"docs_returned": 5
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"query": "emergency phone numbers Nairobi",
|
| 56 |
+
"category": "emergency",
|
| 57 |
+
"themes_expected": [
|
| 58 |
+
"Red Cross",
|
| 59 |
+
"ambulance",
|
| 60 |
+
"999",
|
| 61 |
+
"Nairobi"
|
| 62 |
+
],
|
| 63 |
+
"themes_found": [
|
| 64 |
+
"Red Cross",
|
| 65 |
+
"ambulance",
|
| 66 |
+
"999",
|
| 67 |
+
"Nairobi"
|
| 68 |
+
],
|
| 69 |
+
"coverage": 1.0,
|
| 70 |
+
"docs_returned": 5
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"query": "critical heat alert actions stop work immediately",
|
| 74 |
+
"category": "actions",
|
| 75 |
+
"themes_expected": [
|
| 76 |
+
"critical",
|
| 77 |
+
"stop",
|
| 78 |
+
"emergency",
|
| 79 |
+
"medical"
|
| 80 |
+
],
|
| 81 |
+
"themes_found": [
|
| 82 |
+
"critical",
|
| 83 |
+
"stop",
|
| 84 |
+
"medical"
|
| 85 |
+
],
|
| 86 |
+
"coverage": 0.75,
|
| 87 |
+
"docs_returned": 5
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"query": "Kibera corrugated tin roofs urban heat",
|
| 91 |
+
"category": "zone",
|
| 92 |
+
"themes_expected": [
|
| 93 |
+
"Kibera",
|
| 94 |
+
"tin",
|
| 95 |
+
"heat"
|
| 96 |
+
],
|
| 97 |
+
"themes_found": [
|
| 98 |
+
"Kibera",
|
| 99 |
+
"tin",
|
| 100 |
+
"heat"
|
| 101 |
+
],
|
| 102 |
+
"coverage": 1.0,
|
| 103 |
+
"docs_returned": 5
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"query": "heat stroke dehydration symptoms workers",
|
| 107 |
+
"category": "safety",
|
| 108 |
+
"themes_expected": [
|
| 109 |
+
"heat",
|
| 110 |
+
"water",
|
| 111 |
+
"hydration"
|
| 112 |
+
],
|
| 113 |
+
"themes_found": [
|
| 114 |
+
"heat",
|
| 115 |
+
"water",
|
| 116 |
+
"hydration"
|
| 117 |
+
],
|
| 118 |
+
"coverage": 1.0,
|
| 119 |
+
"docs_returned": 5
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"query": "Swahili translation heat warning joto",
|
| 123 |
+
"category": "language",
|
| 124 |
+
"themes_expected": [
|
| 125 |
+
"joto",
|
| 126 |
+
"Swahili",
|
| 127 |
+
"tahadhari"
|
| 128 |
+
],
|
| 129 |
+
"themes_found": [
|
| 130 |
+
"joto",
|
| 131 |
+
"Swahili",
|
| 132 |
+
"tahadhari"
|
| 133 |
+
],
|
| 134 |
+
"coverage": 1.0,
|
| 135 |
+
"docs_returned": 1
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"query": "basis risk what if trigger wrong",
|
| 139 |
+
"category": "insurance",
|
| 140 |
+
"themes_expected": [
|
| 141 |
+
"basis risk",
|
| 142 |
+
"false"
|
| 143 |
+
],
|
| 144 |
+
"themes_found": [
|
| 145 |
+
"basis risk"
|
| 146 |
+
],
|
| 147 |
+
"coverage": 0.5,
|
| 148 |
+
"docs_returned": 5
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"query": "warning level heat alert two consecutive days",
|
| 152 |
+
"category": "actions",
|
| 153 |
+
"themes_expected": [
|
| 154 |
+
"warning",
|
| 155 |
+
"consecutive",
|
| 156 |
+
"reduce"
|
| 157 |
+
],
|
| 158 |
+
"themes_found": [
|
| 159 |
+
"warning",
|
| 160 |
+
"consecutive",
|
| 161 |
+
"reduce"
|
| 162 |
+
],
|
| 163 |
+
"coverage": 1.0,
|
| 164 |
+
"docs_returned": 5
|
| 165 |
+
}
|
| 166 |
+
]
|
tests/eval_results/uhi_eval.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"informal": {
|
| 3 |
+
"mean": 3.26,
|
| 4 |
+
"min": 1.78,
|
| 5 |
+
"max": 7.26
|
| 6 |
+
},
|
| 7 |
+
"mixed": {
|
| 8 |
+
"mean": 1.54,
|
| 9 |
+
"min": 0.74,
|
| 10 |
+
"max": 3.57
|
| 11 |
+
},
|
| 12 |
+
"formal": {
|
| 13 |
+
"mean": 0.63,
|
| 14 |
+
"min": 0.29,
|
| 15 |
+
"max": 1.51
|
| 16 |
+
},
|
| 17 |
+
"commercial": {
|
| 18 |
+
"mean": 0.93,
|
| 19 |
+
"min": 0.48,
|
| 20 |
+
"max": 1.46
|
| 21 |
+
}
|
| 22 |
+
}
|
tests/eval_uhi.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate UHI correction model against literature."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import pytest
|
| 5 |
+
from src.downscaling.uhi_model import UHICorrector
|
| 6 |
+
from config import ZONES, ZONE_MAP
|
| 7 |
+
|
| 8 |
+
# Literature ranges for tropical African cities (deg C above grid)
|
| 9 |
+
LITERATURE_RANGES = {
|
| 10 |
+
"informal": (3.0, 6.0),
|
| 11 |
+
"mixed": (1.0, 3.0),
|
| 12 |
+
"formal": (0.5, 1.5),
|
| 13 |
+
"commercial": (1.0, 2.0),
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_uhi_deltas_within_literature():
|
| 18 |
+
"""Mean UHI delta per settlement type should be within published ranges."""
|
| 19 |
+
corrector = UHICorrector()
|
| 20 |
+
deltas_by_type = {"informal": [], "mixed": [], "formal": [], "commercial": []}
|
| 21 |
+
|
| 22 |
+
for zone in ZONES:
|
| 23 |
+
for hour in [10, 14, 18, 22]: # sample different times
|
| 24 |
+
for month in [1, 4, 7, 10]: # sample different months
|
| 25 |
+
_, delta, _ = corrector.correct_temperature(zone, 30.0, hour, month)
|
| 26 |
+
deltas_by_type[zone.settlement_type].append(delta)
|
| 27 |
+
|
| 28 |
+
results = {}
|
| 29 |
+
for stype, (lo, hi) in LITERATURE_RANGES.items():
|
| 30 |
+
values = deltas_by_type[stype]
|
| 31 |
+
mean = sum(values) / len(values)
|
| 32 |
+
results[stype] = {"mean": round(mean, 2), "min": round(min(values), 2), "max": round(max(values), 2)}
|
| 33 |
+
# Allow 50% slack on both ends since the model was trained on synthetic data
|
| 34 |
+
assert lo * 0.5 <= mean <= hi * 1.5, (
|
| 35 |
+
f"{stype}: mean delta {mean:.2f} outside [{lo * 0.5}, {hi * 1.5}]"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Save results
|
| 39 |
+
os.makedirs("tests/eval_results", exist_ok=True)
|
| 40 |
+
with open("tests/eval_results/uhi_eval.json", "w") as f:
|
| 41 |
+
json.dump(results, f, indent=2)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_uhi_nocturnal_amplification():
|
| 45 |
+
"""UHI should be higher at night than during day (thermal mass effect)."""
|
| 46 |
+
corrector = UHICorrector()
|
| 47 |
+
for zone in ZONES[:5]: # sample 5 zones
|
| 48 |
+
_, delta_day, _ = corrector.correct_temperature(zone, 30.0, hour=14, month=1)
|
| 49 |
+
_, delta_night, _ = corrector.correct_temperature(zone, 25.0, hour=2, month=1)
|
| 50 |
+
# Night UHI should generally be >= day UHI for informal/mixed
|
| 51 |
+
if zone.settlement_type in ("informal", "mixed"):
|
| 52 |
+
assert delta_night >= delta_day * 0.7, (
|
| 53 |
+
f"{zone.zone_id}: night delta {delta_night:.1f} < 0.7 * day {delta_day:.1f}"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_uhi_confidence_range():
|
| 58 |
+
"""Confidence should be between 0 and 1."""
|
| 59 |
+
corrector = UHICorrector()
|
| 60 |
+
for zone in ZONES:
|
| 61 |
+
_, _, conf = corrector.correct_temperature(zone, 30.0, hour=14, month=1)
|
| 62 |
+
assert 0 <= conf <= 1, f"{zone.zone_id}: confidence {conf} out of range"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_uhi_settlement_ordering():
|
| 66 |
+
"""Informal settlements should have higher UHI deltas than formal ones."""
|
| 67 |
+
corrector = UHICorrector()
|
| 68 |
+
deltas_by_type = {"informal": [], "mixed": [], "formal": [], "commercial": []}
|
| 69 |
+
|
| 70 |
+
for zone in ZONES:
|
| 71 |
+
_, delta, _ = corrector.correct_temperature(zone, 30.0, hour=14, month=1)
|
| 72 |
+
deltas_by_type[zone.settlement_type].append(delta)
|
| 73 |
+
|
| 74 |
+
means = {k: sum(v) / len(v) for k, v in deltas_by_type.items() if v}
|
| 75 |
+
assert means["informal"] > means["formal"], (
|
| 76 |
+
f"Informal mean ({means['informal']:.2f}) should > formal ({means['formal']:.2f})"
|
| 77 |
+
)
|