jtlevine Claude Opus 4.6 (1M context) commited on
Commit
e2d3383
·
0 Parent(s):

Initial commit: Extreme Heat Risk Engine

Browse files

6-step ML pipeline for parametric heat insurance in East Africa.
Three XGBoost models (UHI correction, heat wave prediction, actuarial pricing),
Claude AI healing agent, interactive program designer with budget allocation.
20 urban zones across Nairobi, Dar es Salaam, Kampala, Kigali.

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

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +2 -0
  2. .gitattributes +4 -0
  3. .gitignore +12 -0
  4. Dockerfile +40 -0
  5. config.py +181 -0
  6. frontend/index.html +16 -0
  7. frontend/package-lock.json +0 -0
  8. frontend/package.json +30 -0
  9. frontend/postcss.config.js +6 -0
  10. frontend/public/vite.svg +5 -0
  11. frontend/src/App.tsx +143 -0
  12. frontend/src/components/Layout.tsx +17 -0
  13. frontend/src/components/LoadingState.tsx +33 -0
  14. frontend/src/components/MetricCard.tsx +77 -0
  15. frontend/src/components/Sidebar.tsx +73 -0
  16. frontend/src/components/StatusBadge.tsx +33 -0
  17. frontend/src/index.css +308 -0
  18. frontend/src/lib/api.ts +340 -0
  19. frontend/src/lib/tour.ts +198 -0
  20. frontend/src/main.tsx +25 -0
  21. frontend/src/pages/Dashboard.tsx +168 -0
  22. frontend/src/pages/HeatMonitor.tsx +334 -0
  23. frontend/src/pages/Notifications.tsx +106 -0
  24. frontend/src/pages/Pipeline.tsx +387 -0
  25. frontend/src/pages/ProgramDesigner.tsx +296 -0
  26. frontend/src/pages/Zones.tsx +244 -0
  27. frontend/src/vite-env.d.ts +9 -0
  28. frontend/tailwind.config.js +50 -0
  29. frontend/tsconfig.json +23 -0
  30. frontend/tsconfig.tsbuildinfo +1 -0
  31. frontend/vercel.json +8 -0
  32. frontend/vite.config.ts +19 -0
  33. models/heat_predictor_xgb.json +0 -0
  34. models/uhi_xgb.json +0 -0
  35. requirements.txt +9 -0
  36. run_pipeline.py +91 -0
  37. src/__init__.py +0 -0
  38. src/api.py +591 -0
  39. src/calibration/__init__.py +0 -0
  40. src/calibration/basis_risk.py +386 -0
  41. src/database/__init__.py +0 -0
  42. src/database/crud.py +651 -0
  43. src/database/schema.py +319 -0
  44. src/downscaling/__init__.py +0 -0
  45. src/downscaling/uhi_model.py +274 -0
  46. src/explanation/__init__.py +0 -0
  47. src/explanation/explainer.py +363 -0
  48. src/explanation/knowledge_base.py +340 -0
  49. src/healing/__init__.py +0 -0
  50. src/healing/healer.py +953 -0
.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ ANTHROPIC_API_KEY=
2
+ DATABASE_URL=
.gitattributes ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ *.pt filter=lfs diff=lfs merge=lfs -text
2
+ *.pkl filter=lfs diff=lfs merge=lfs -text
3
+ data/era5land_dar.json filter=lfs diff=lfs merge=lfs -text
4
+ *.faiss filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .env
5
+ .venv
6
+ venv/
7
+ node_modules/
8
+ dist/
9
+ .DS_Store
10
+ *.log
11
+ .cache/
12
+ .vercel/
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim AS builder
2
+
3
+ # Install Node.js for frontend build
4
+ RUN apt-get update && apt-get install -y --no-install-recommends curl && \
5
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
6
+ apt-get install -y --no-install-recommends nodejs && \
7
+ apt-get clean && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app/frontend
10
+ COPY frontend/package.json frontend/package-lock.json* ./
11
+ RUN npm ci --production=false
12
+ COPY frontend/ ./
13
+ RUN VITE_API_URL="" npm run build
14
+
15
+ # ── Production stage ──
16
+ FROM python:3.11-slim
17
+
18
+ WORKDIR /app
19
+
20
+ 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
+
35
+ EXPOSE 7860
36
+
37
+ HEALTHCHECK --interval=30s --timeout=10s --retries=5 --start-period=60s \
38
+ CMD curl -f http://localhost:7860/health || exit 1
39
+
40
+ CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "7860"]
config.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Climate Risk Index Engine — Configuration
3
+
4
+ Extreme heat parametric insurance for East African cities.
5
+ Zones are real neighborhoods with outdoor worker populations
6
+ vulnerable to heat stress.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ REGION_NAME = "East Africa"
12
+ TIMEZONE = "Africa/Nairobi"
13
+ LOCALE = "en-KE"
14
+ CURRENCY = "USD"
15
+ CURRENCY_SYMBOL = "$"
16
+
17
+ # Data source configuration
18
+ NASA_POWER_URL = "https://power.larc.nasa.gov/api/temporal/daily/point"
19
+ OVERPASS_URL = "https://overpass-api.de/api/interpreter"
20
+
21
+ # Heat thresholds for parametric triggers
22
+ HEAT_THRESHOLDS = {
23
+ "critical": {"temp_c": 37, "wbgt_c": 32, "consecutive_days": 3},
24
+ "warning": {"temp_c": 35, "wbgt_c": 30, "consecutive_days": 2},
25
+ "watch": {"temp_c": 33, "wbgt_c": 28, "consecutive_days": 1},
26
+ }
27
+
28
+ # Payout per event per worker (USD)
29
+ PAYOUT_PER_EVENT_USD = {
30
+ "critical": 15,
31
+ "warning": 10,
32
+ "watch": 5,
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class UrbanZone:
38
+ zone_id: str
39
+ name: str
40
+ city: str
41
+ country: str
42
+ latitude: float
43
+ longitude: float
44
+ elevation_m: float
45
+ area_km2: float
46
+ population_est: int
47
+ settlement_type: str # formal, informal, mixed, commercial
48
+ worker_population_est: int # estimated informal/outdoor workers
49
+ outdoor_exposure_pct: float # % of workers with outdoor exposure
50
+ heat_vulnerability: str # high, moderate, low
51
+ hot_months: list = field(default_factory=list) # months of peak heat
52
+ notes: str = ""
53
+
54
+
55
+ # Real urban zones in East Africa — same 20 zones, now with heat context
56
+ # Dar es Salaam: hottest + most humid (coastal, sea level, WBGT danger)
57
+ # Kampala: moderate heat (equatorial but 1140-1260m elevation, Lake Victoria moderates)
58
+ # Nairobi: cooler (1620-1780m highland elevation)
59
+ # Kigali: moderate (1420-1580m, "city of eternal spring")
60
+ ZONES: list[UrbanZone] = [
61
+ # -- Nairobi, Kenya (elevation 1620-1780m, relatively cool) --
62
+ UrbanZone("NBO-KIB", "Kibera", "Nairobi", "Kenya",
63
+ -1.3133, 36.7876, 1720, 2.5, 250000, "informal",
64
+ 45000, 0.72, "moderate",
65
+ [1, 2, 3, 10, 12], "Largest informal settlement. Tin roofs amplify heat. Many outdoor traders."),
66
+ UrbanZone("NBO-EAS", "Eastleigh", "Nairobi", "Kenya",
67
+ -1.2750, 36.8500, 1660, 3.2, 180000, "mixed",
68
+ 22000, 0.55, "moderate",
69
+ [1, 2, 3, 10, 12], "Dense commercial area. Street vendors and market porters exposed."),
70
+ UrbanZone("NBO-MAT", "Mathare", "Nairobi", "Kenya",
71
+ -1.2583, 36.8583, 1620, 1.8, 200000, "informal",
72
+ 38000, 0.70, "moderate",
73
+ [1, 2, 3, 10, 12], "Valley settlement. Poor ventilation in densely packed structures."),
74
+ UrbanZone("NBO-SBC", "South B/C", "Nairobi", "Kenya",
75
+ -1.3100, 36.8350, 1680, 4.5, 120000, "formal",
76
+ 8000, 0.30, "low",
77
+ [1, 2, 3, 10, 12], "Formal residential area. Mostly indoor workers."),
78
+ UrbanZone("NBO-WES", "Westlands", "Nairobi", "Kenya",
79
+ -1.2667, 36.8100, 1740, 5.0, 90000, "commercial",
80
+ 5000, 0.20, "low",
81
+ [1, 2, 3, 10, 12], "Commercial district. Air-conditioned offices predominate."),
82
+ UrbanZone("NBO-KAN", "Kangemi", "Nairobi", "Kenya",
83
+ -1.2600, 36.7450, 1780, 3.0, 150000, "mixed",
84
+ 18000, 0.58, "moderate",
85
+ [1, 2, 3, 10, 12], "Peri-urban. Construction workers and jua kali artisans."),
86
+
87
+ # -- Dar es Salaam, Tanzania (sea level, hottest + most humid) --
88
+ UrbanZone("DAR-JAN", "Jangwani", "Dar es Salaam", "Tanzania",
89
+ -6.8000, 39.2700, 12, 2.0, 80000, "informal",
90
+ 25000, 0.82, "high",
91
+ [1, 2, 3, 10, 11, 12], "Low-lying informal area. Tin roofs, no shade, extreme UHI. Outdoor labor dominant."),
92
+ UrbanZone("DAR-MSA", "Msasani", "Dar es Salaam", "Tanzania",
93
+ -6.7600, 39.2650, 18, 4.0, 95000, "mixed",
94
+ 15000, 0.55, "high",
95
+ [1, 2, 3, 10, 11, 12], "Coastal mixed area. Fish market workers, beach traders exposed."),
96
+ UrbanZone("DAR-KIN", "Kinondoni", "Dar es Salaam", "Tanzania",
97
+ -6.7700, 39.2400, 35, 6.5, 200000, "formal",
98
+ 18000, 0.45, "moderate",
99
+ [1, 2, 3, 10, 11, 12], "Mixed residential. Some tree cover but humidity remains high."),
100
+ UrbanZone("DAR-TEM", "Temeke", "Dar es Salaam", "Tanzania",
101
+ -6.8600, 39.2800, 22, 5.5, 170000, "mixed",
102
+ 30000, 0.75, "high",
103
+ [1, 2, 3, 10, 11, 12], "Industrial zone. Port workers, construction crews, outdoor factories."),
104
+ UrbanZone("DAR-KIG", "Kigamboni", "Dar es Salaam", "Tanzania",
105
+ -6.8700, 39.3200, 8, 7.0, 110000, "mixed",
106
+ 20000, 0.70, "high",
107
+ [1, 2, 3, 10, 11, 12], "Coastal peninsula. Fishing, salt panning, construction — all outdoor."),
108
+
109
+ # -- Kampala, Uganda (1140-1260m, Lake Victoria moderates) --
110
+ UrbanZone("KLA-BWA", "Bwaise", "Kampala", "Uganda",
111
+ 0.3500, 32.5650, 1140, 1.5, 120000, "informal",
112
+ 22000, 0.75, "moderate",
113
+ [1, 2, 3, 12], "Wetland settlement. High humidity from swamp. Outdoor traders predominate."),
114
+ UrbanZone("KLA-NAT", "Natete", "Kampala", "Uganda",
115
+ 0.3050, 32.5550, 1150, 2.0, 85000, "mixed",
116
+ 12000, 0.60, "moderate",
117
+ [1, 2, 3, 12], "Market area. Porters, boda-boda riders, street vendors."),
118
+ UrbanZone("KLA-NAK", "Nakivubo", "Kampala", "Uganda",
119
+ 0.3100, 32.5850, 1160, 1.8, 70000, "mixed",
120
+ 10000, 0.65, "moderate",
121
+ [1, 2, 3, 12], "Commercial corridor. Taxi park workers, hawkers exposed to heat."),
122
+ UrbanZone("KLA-LUB", "Lubaga", "Kampala", "Uganda",
123
+ 0.3000, 32.5600, 1220, 3.5, 100000, "formal",
124
+ 7000, 0.35, "low",
125
+ [1, 2, 3, 12], "Hillside residential. Some elevation relief from heat."),
126
+ UrbanZone("KLA-MAK", "Makindye", "Kampala", "Uganda",
127
+ 0.2900, 32.5950, 1260, 4.0, 95000, "formal",
128
+ 5500, 0.25, "low",
129
+ [1, 2, 3, 12], "Elevated residential. Better ventilation, more tree cover."),
130
+
131
+ # -- Kigali, Rwanda (1420-1580m, moderate) --
132
+ UrbanZone("KGL-NYA", "Nyabugogo", "Kigali", "Rwanda",
133
+ -1.9400, 30.0550, 1420, 1.2, 45000, "mixed",
134
+ 8000, 0.68, "moderate",
135
+ [1, 2, 8, 9], "Valley area. Bus terminal workers, market traders. Valley traps heat."),
136
+ UrbanZone("KGL-KIC", "Kicukiro", "Kigali", "Rwanda",
137
+ -1.9800, 30.0700, 1520, 4.0, 120000, "formal",
138
+ 6000, 0.30, "low",
139
+ [1, 2, 8, 9], "Residential district. Mostly indoor employment."),
140
+ UrbanZone("KGL-GAS", "Gasabo", "Kigali", "Rwanda",
141
+ -1.9300, 30.0900, 1580, 5.0, 150000, "formal",
142
+ 5000, 0.22, "low",
143
+ [1, 2, 8, 9], "Administrative district on higher ground. Cooler, shaded."),
144
+ UrbanZone("KGL-NYM", "Nyamirambo", "Kigali", "Rwanda",
145
+ -1.9700, 30.0400, 1480, 2.5, 80000, "mixed",
146
+ 9000, 0.60, "moderate",
147
+ [1, 2, 8, 9], "Dense residential. Hillside construction workers, market sellers."),
148
+ ]
149
+
150
+ ZONE_MAP: dict[str, UrbanZone] = {z.zone_id: z for z in ZONES}
151
+
152
+ # Cities for grouping
153
+ CITIES = ["Nairobi", "Dar es Salaam", "Kampala", "Kigali"]
154
+
155
+ # Hot season definitions (month numbers)
156
+ HOT_SEASONS = {
157
+ "Nairobi": {"dry_hot": [1, 2, 3], "secondary_hot": [10, 12]},
158
+ "Dar es Salaam": {"hot_humid": [1, 2, 3, 10, 11, 12]},
159
+ "Kampala": {"hot_dry": [1, 2, 3, 12]},
160
+ "Kigali": {"hot_dry": [1, 2, 8, 9]},
161
+ }
162
+
163
+ # NASA POWER parameters to fetch
164
+ NASA_POWER_PARAMS = [
165
+ "T2M", # Temperature at 2m (C)
166
+ "T2M_MAX", # Max temperature (C)
167
+ "T2M_MIN", # Min temperature (C)
168
+ "RH2M", # Relative humidity (%)
169
+ "WS2M", # Wind speed at 2m (m/s)
170
+ "ALLSKY_SFC_SW_DWN", # Solar radiation (for heat load)
171
+ ]
172
+
173
+ # Pipeline step names
174
+ PIPELINE_STEPS = [
175
+ "ingest",
176
+ "heal",
177
+ "index",
178
+ "calibrate",
179
+ "explain",
180
+ "notify",
181
+ ]
frontend/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Climate Risk Index Engine</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&display=swap" rel="stylesheet" />
11
+ </head>
12
+ <body class="bg-cream font-sans text-gray-800 antialiased">
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "climate-risk-engine",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@tanstack/react-query": "^5.62.0",
13
+ "lucide-react": "^0.468.0",
14
+ "react": "^18.3.1",
15
+ "react-dom": "^18.3.1",
16
+ "react-joyride": "^2.9.3",
17
+ "react-router-dom": "^7.1.1",
18
+ "recharts": "^2.15.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/react": "^18.3.18",
22
+ "@types/react-dom": "^18.3.5",
23
+ "@vitejs/plugin-react": "^4.3.4",
24
+ "autoprefixer": "^10.4.20",
25
+ "postcss": "^8.4.49",
26
+ "tailwindcss": "^3.4.17",
27
+ "typescript": "~5.6.2",
28
+ "vite": "^6.0.5"
29
+ }
30
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/public/vite.svg ADDED
frontend/src/App.tsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { Routes, Route, useNavigate, useSearchParams } from 'react-router-dom'
3
+ import Joyride, { type CallBackProps, STATUS, EVENTS, ACTIONS } from 'react-joyride'
4
+ import { Thermometer } from 'lucide-react'
5
+ import Sidebar from './components/Sidebar'
6
+ import Dashboard from './pages/Dashboard'
7
+ import Zones from './pages/Zones'
8
+ import HeatMonitor from './pages/HeatMonitor'
9
+ import ProgramDesigner from './pages/ProgramDesigner'
10
+ import Notifications from './pages/Notifications'
11
+ import Pipeline from './pages/Pipeline'
12
+ import { tourSteps, tourStyles } from './lib/tour'
13
+
14
+ // Map tour step indices to routes for page navigation
15
+ const stepRoutes: Record<number, string> = {
16
+ 0: '/', // hero
17
+ 1: '/', // stage cards
18
+ 2: '/', // metrics
19
+ 3: '/', // nav-zones link
20
+ 4: '/zones', // zones metrics
21
+ 5: '/zones', // nav-heat-monitor link
22
+ 6: '/heat-monitor', // heat monitor metrics
23
+ 7: '/heat-monitor', // heat monitor tabs
24
+ 8: '/heat-monitor', // nav-calibrate link
25
+ 9: '/calibrate', // calibrate controls
26
+ 10: '/calibrate', // calibrate results
27
+ 11: '/calibrate', // nav-alerts link
28
+ 12: '/alerts', // alerts feed
29
+ 13: '/', // final — nav-home
30
+ }
31
+
32
+ export default function App() {
33
+ const [searchParams] = useSearchParams()
34
+ const [runTour, setRunTour] = useState(false)
35
+ const [stepIndex, setStepIndex] = useState(0)
36
+ const navigate = useNavigate()
37
+
38
+ useEffect(() => {
39
+ const forced = searchParams.get('tour') === 'true'
40
+ const seen = localStorage.getItem('heat_tour_v2') === '1'
41
+ if (forced || !seen) {
42
+ const timer = setTimeout(() => {
43
+ setRunTour(true)
44
+ setStepIndex(0)
45
+ localStorage.setItem('heat_tour_v2', '1')
46
+ }, 1500)
47
+ return () => clearTimeout(timer)
48
+ }
49
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
50
+
51
+ // Relaunch from tour button
52
+ useEffect(() => {
53
+ function handleRelaunch() {
54
+ navigate('/')
55
+ setTimeout(() => {
56
+ setStepIndex(0)
57
+ setRunTour(true)
58
+ }, 400)
59
+ }
60
+ window.addEventListener('relaunch-tour', handleRelaunch)
61
+ return () => window.removeEventListener('relaunch-tour', handleRelaunch)
62
+ }, [navigate])
63
+
64
+ const handleJoyrideCallback = useCallback(
65
+ (data: CallBackProps) => {
66
+ const { status, action, index, type } = data
67
+
68
+ if (status === STATUS.FINISHED || status === STATUS.SKIPPED || action === ACTIONS.CLOSE) {
69
+ setRunTour(false)
70
+ setStepIndex(0)
71
+ return
72
+ }
73
+
74
+ if (type === EVENTS.STEP_AFTER) {
75
+ const nextIndex = action === ACTIONS.PREV ? index - 1 : index + 1
76
+ const nextRoute = stepRoutes[nextIndex]
77
+
78
+ if (nextRoute !== undefined) {
79
+ const currentRoute = stepRoutes[index]
80
+ if (nextRoute !== currentRoute) {
81
+ navigate(nextRoute)
82
+ // Give the page time to render before advancing
83
+ setTimeout(() => setStepIndex(nextIndex), 400)
84
+ return
85
+ }
86
+ }
87
+ setStepIndex(nextIndex)
88
+ }
89
+ },
90
+ [navigate]
91
+ )
92
+
93
+ return (
94
+ <div className="flex min-h-screen">
95
+ <Sidebar />
96
+
97
+ <main className="flex-1 ml-56">
98
+ <div className="flex items-center justify-end h-12 px-8">
99
+ <button
100
+ onClick={() => window.dispatchEvent(new Event('relaunch-tour'))}
101
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-sans font-medium text-warm-muted hover:text-[#1a1a1a] hover:bg-warm-header-bg transition-colors"
102
+ title="Take the guided tour"
103
+ >
104
+ <Thermometer size={14} />
105
+ Tour
106
+ </button>
107
+ </div>
108
+ <div className="px-8 pb-8">
109
+ <Routes>
110
+ <Route path="/" element={<Dashboard />} />
111
+ <Route path="/zones" element={<Zones />} />
112
+ <Route path="/heat-monitor" element={<HeatMonitor />} />
113
+ <Route path="/calibrate" element={<ProgramDesigner />} />
114
+ <Route path="/alerts" element={<Notifications />} />
115
+ <Route path="/pipeline" element={<Pipeline />} />
116
+ </Routes>
117
+ </div>
118
+ </main>
119
+
120
+ <Joyride
121
+ steps={tourSteps}
122
+ run={runTour}
123
+ stepIndex={stepIndex}
124
+ continuous
125
+ showSkipButton
126
+ showProgress
127
+ scrollToFirstStep
128
+ disableOverlayClose
129
+ spotlightClicks={false}
130
+ callback={handleJoyrideCallback}
131
+ styles={tourStyles}
132
+ floaterProps={{ disableAnimation: true }}
133
+ locale={{
134
+ back: 'Back',
135
+ close: 'Close',
136
+ last: 'Finish',
137
+ next: 'Next',
138
+ skip: 'Skip tour',
139
+ }}
140
+ />
141
+ </div>
142
+ )
143
+ }
frontend/src/components/Layout.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from 'react'
2
+ import Sidebar from './Sidebar'
3
+
4
+ interface Props {
5
+ children: ReactNode
6
+ }
7
+
8
+ export default function Layout({ children }: Props) {
9
+ return (
10
+ <div className="flex min-h-screen bg-cream">
11
+ <Sidebar />
12
+ <main className="flex-1 ml-56 p-8">
13
+ {children}
14
+ </main>
15
+ </div>
16
+ )
17
+ }
frontend/src/components/LoadingState.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface LoadingProps {
2
+ message?: string
3
+ }
4
+
5
+ export function LoadingSpinner({ message = 'Loading...' }: LoadingProps) {
6
+ return (
7
+ <div className="flex flex-col items-center justify-center py-16">
8
+ <div className="w-8 h-8 border-2 border-warm-border border-t-gold rounded-full animate-spin" />
9
+ <p className="mt-4 text-sm text-warm-muted font-sans">{message}</p>
10
+ </div>
11
+ )
12
+ }
13
+
14
+ interface ErrorProps {
15
+ message?: string
16
+ onRetry?: () => void
17
+ }
18
+
19
+ export function ErrorState({ message = 'Failed to load data', onRetry }: ErrorProps) {
20
+ return (
21
+ <div className="flex flex-col items-center justify-center py-16">
22
+ <div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-3">
23
+ <span className="text-error text-xl font-bold">!</span>
24
+ </div>
25
+ <p className="text-sm text-warm-body font-sans mb-3">{message}</p>
26
+ {onRetry && (
27
+ <button onClick={onRetry} className="btn-secondary text-xs">
28
+ Retry
29
+ </button>
30
+ )}
31
+ </div>
32
+ )
33
+ }
frontend/src/components/MetricCard.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ interface Props {
4
+ label: string
5
+ value: string | number | undefined | null
6
+ subtitle?: string
7
+ className?: string
8
+ }
9
+
10
+ function useCountUp(target: string | number | undefined | null, duration = 600) {
11
+ const [display, setDisplay] = useState<string>('--')
12
+ const hasAnimated = useRef(false)
13
+
14
+ useEffect(() => {
15
+ if (target === null || target === undefined) {
16
+ setDisplay('--')
17
+ return
18
+ }
19
+
20
+ const str = String(target)
21
+
22
+ if (hasAnimated.current) {
23
+ setDisplay(str)
24
+ return
25
+ }
26
+
27
+ const match = str.match(/(\d+)/)
28
+ if (!match) {
29
+ setDisplay(str)
30
+ hasAnimated.current = true
31
+ return
32
+ }
33
+
34
+ const num = parseInt(match[1], 10)
35
+ if (num === 0) {
36
+ setDisplay(str)
37
+ hasAnimated.current = true
38
+ return
39
+ }
40
+
41
+ const prefix = str.slice(0, match.index)
42
+ const suffix = str.slice((match.index ?? 0) + match[1].length)
43
+ const start = performance.now()
44
+
45
+ function tick(now: number) {
46
+ const elapsed = now - start
47
+ const progress = Math.min(elapsed / duration, 1)
48
+ const eased = 1 - Math.pow(1 - progress, 3)
49
+ const current = Math.round(num * eased)
50
+ setDisplay(`${prefix}${current}${suffix}`)
51
+
52
+ if (progress < 1) {
53
+ requestAnimationFrame(tick)
54
+ } else {
55
+ hasAnimated.current = true
56
+ }
57
+ }
58
+
59
+ requestAnimationFrame(tick)
60
+ }, [target, duration])
61
+
62
+ return display
63
+ }
64
+
65
+ export default function MetricCard({ label, value, subtitle, className = '' }: Props) {
66
+ const display = useCountUp(value)
67
+
68
+ return (
69
+ <div className={`metric-card ${className}`}>
70
+ <div className="metric-label">{label}</div>
71
+ <div className="metric-value">{display}</div>
72
+ {subtitle && (
73
+ <p className="text-xs text-warm-muted mt-1">{subtitle}</p>
74
+ )}
75
+ </div>
76
+ )
77
+ }
frontend/src/components/Sidebar.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NavLink } from 'react-router-dom'
2
+ import {
3
+ Home,
4
+ MapPin,
5
+ Thermometer,
6
+ SlidersHorizontal,
7
+ Bell,
8
+ Settings,
9
+ } from 'lucide-react'
10
+
11
+ const NAV_ITEMS = [
12
+ { to: '/', label: 'Home', icon: Home, tourId: 'nav-home' },
13
+ { to: '/zones', label: 'Zones', icon: MapPin, tourId: 'nav-zones' },
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: 'Pipeline', icon: Settings, tourId: 'nav-pipeline' },
18
+ ]
19
+
20
+ export default function Sidebar() {
21
+ return (
22
+ <aside
23
+ className="fixed top-0 left-0 z-50 h-full w-56 flex flex-col"
24
+ style={{ background: 'linear-gradient(180deg, #1a1a1a 0%, #222018 100%)' }}
25
+ >
26
+ {/* Brand */}
27
+ <div className="flex items-center h-16 px-5 border-b border-white/10">
28
+ <NavLink to="/" className="flex items-center gap-2.5 no-underline">
29
+ <div className="w-8 h-8 rounded-lg bg-gold flex items-center justify-center">
30
+ <Thermometer size={18} className="text-white" />
31
+ </div>
32
+ <div>
33
+ <h1 className="text-sm font-bold text-white leading-tight font-serif m-0">
34
+ Heat Risk
35
+ </h1>
36
+ <p className="text-[10px] text-[#e0dcd5] font-sans font-medium uppercase tracking-wider m-0">
37
+ Index Engine
38
+ </p>
39
+ </div>
40
+ </NavLink>
41
+ </div>
42
+
43
+ {/* Navigation */}
44
+ <nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
45
+ {NAV_ITEMS.map(({ to, label, icon: Icon, tourId }) => (
46
+ <NavLink
47
+ key={to}
48
+ to={to}
49
+ end={to === '/'}
50
+ data-tour={tourId}
51
+ className={({ isActive }) =>
52
+ `flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-sans font-medium transition-colors duration-100 ${
53
+ isActive
54
+ ? 'bg-gold/15 text-gold'
55
+ : 'text-[#e0dcd5] hover:bg-white/5 hover:text-white'
56
+ }`
57
+ }
58
+ >
59
+ <Icon size={18} />
60
+ {label}
61
+ </NavLink>
62
+ ))}
63
+ </nav>
64
+
65
+ {/* Footer */}
66
+ <div className="px-5 py-3 border-t border-white/10">
67
+ <p className="text-[10px] text-[#e0dcd5]/60 font-sans uppercase tracking-wider m-0">
68
+ East Africa Urban Heat
69
+ </p>
70
+ </div>
71
+ </aside>
72
+ )
73
+ }
frontend/src/components/StatusBadge.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface Props {
2
+ status: string
3
+ className?: string
4
+ }
5
+
6
+ const STATUS_MAP: Record<string, { className: string; label?: string }> = {
7
+ critical: { className: 'badge-red' },
8
+ warning: { className: 'badge-orange' },
9
+ watch: { className: 'badge-amber' },
10
+ normal: { className: 'badge-green' },
11
+ active: { className: 'badge-blue' },
12
+ sent: { className: 'badge-green' },
13
+ failed: { className: 'badge-red' },
14
+ ok: { className: 'badge-green' },
15
+ partial: { className: 'badge-amber' },
16
+ dry_run: { className: 'badge-slate', label: 'dry run' },
17
+ high: { className: 'badge-red' },
18
+ moderate: { className: 'badge-amber' },
19
+ low: { className: 'badge-green' },
20
+ poor: { className: 'badge-red' },
21
+ good: { className: 'badge-green' },
22
+ }
23
+
24
+ export default function StatusBadge({ status, className = '' }: Props) {
25
+ const config = STATUS_MAP[status] ?? { className: 'badge-slate' }
26
+ const label = config.label ?? status
27
+
28
+ return (
29
+ <span className={`${config.className} ${className}`}>
30
+ {label}
31
+ </span>
32
+ )
33
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ html {
7
+ -webkit-tap-highlight-color: transparent;
8
+ }
9
+
10
+ body {
11
+ @apply min-h-screen bg-cream font-sans text-[#1a1a1a] antialiased;
12
+ margin: 0;
13
+ }
14
+
15
+ h1, h2, h3, h4, h5, h6 {
16
+ @apply font-serif font-semibold text-[#1a1a1a];
17
+ }
18
+
19
+ h1 { @apply font-bold; }
20
+
21
+ p, li, td, span, label {
22
+ color: #555;
23
+ }
24
+
25
+ /* Custom scrollbar — warm tones */
26
+ ::-webkit-scrollbar {
27
+ width: 6px;
28
+ height: 6px;
29
+ }
30
+
31
+ ::-webkit-scrollbar-track {
32
+ @apply bg-transparent;
33
+ }
34
+
35
+ ::-webkit-scrollbar-thumb {
36
+ @apply rounded-full;
37
+ background: #d4cfc7;
38
+ }
39
+
40
+ ::-webkit-scrollbar-thumb:hover {
41
+ background: #b8b3a9;
42
+ }
43
+ }
44
+
45
+ @layer components {
46
+ /* ── Cards ── */
47
+ .card {
48
+ @apply bg-white rounded-[10px] border border-warm-border;
49
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
50
+ }
51
+
52
+ .card:hover {
53
+ transform: translateY(-2px);
54
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
55
+ border-color: #ccc8c0;
56
+ }
57
+
58
+ .card-body {
59
+ @apply p-5;
60
+ }
61
+
62
+ /* ── Metric cards ── */
63
+ .metric-card {
64
+ @apply bg-white rounded-[10px] border border-warm-border p-5;
65
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
66
+ }
67
+
68
+ .metric-card:hover {
69
+ transform: translateY(-2px);
70
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
71
+ border-color: #ccc8c0;
72
+ }
73
+
74
+ .metric-card .metric-label {
75
+ @apply uppercase text-[0.72rem] font-sans font-medium text-warm-muted;
76
+ letter-spacing: 1.2px;
77
+ }
78
+
79
+ .metric-card .metric-value {
80
+ @apply font-serif font-bold text-[#1a1a1a] text-2xl mt-1;
81
+ }
82
+
83
+ /* ── Buttons ── */
84
+ .btn {
85
+ @apply inline-flex items-center justify-center gap-2 rounded-md px-4 py-2.5
86
+ font-sans font-semibold text-sm uppercase transition-all duration-150 ease-in-out
87
+ focus:outline-none focus:ring-2 focus:ring-offset-2
88
+ disabled:opacity-50 disabled:cursor-not-allowed;
89
+ letter-spacing: 0.5px;
90
+ }
91
+
92
+ .btn-primary {
93
+ @apply inline-flex items-center justify-center gap-2 rounded-md px-4 py-2.5
94
+ font-sans font-semibold text-sm uppercase transition-all duration-150 ease-in-out
95
+ focus:outline-none focus:ring-2 focus:ring-offset-2
96
+ disabled:opacity-50 disabled:cursor-not-allowed
97
+ bg-gold text-white hover:bg-gold-hover
98
+ focus:ring-gold active:bg-gold-hover;
99
+ letter-spacing: 0.5px;
100
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06);
101
+ }
102
+
103
+ .btn-primary:hover {
104
+ transform: translateY(-1px);
105
+ box-shadow: 0 3px 8px rgba(212, 160, 25, 0.25);
106
+ }
107
+
108
+ .btn-secondary {
109
+ @apply inline-flex items-center justify-center gap-2 rounded-md px-4 py-2.5
110
+ font-sans font-semibold text-sm uppercase transition-all duration-150 ease-in-out
111
+ focus:outline-none focus:ring-2 focus:ring-offset-2
112
+ disabled:opacity-50 disabled:cursor-not-allowed
113
+ bg-white text-[#555] border border-warm-border
114
+ hover:bg-warm-header-bg focus:ring-warm-border active:bg-warm-header-bg;
115
+ letter-spacing: 0.5px;
116
+ }
117
+
118
+ /* ── Inputs ── */
119
+ .input {
120
+ @apply block w-full rounded-md border border-warm-border px-3.5 py-2.5
121
+ text-sm text-[#1a1a1a] font-sans placeholder-[#888]
122
+ focus:border-gold focus:ring-2 focus:ring-gold/20
123
+ focus:outline-none transition-colors duration-150;
124
+ }
125
+
126
+ /* ── Badges / pills ── */
127
+ .badge {
128
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5
129
+ text-xs font-semibold font-sans;
130
+ }
131
+
132
+ .badge-green {
133
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold font-sans;
134
+ background: rgba(42, 157, 143, 0.18);
135
+ color: #2a9d8f;
136
+ border: 1px solid rgba(42, 157, 143, 0.44);
137
+ }
138
+
139
+ .badge-amber {
140
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold font-sans;
141
+ background: rgba(212, 160, 25, 0.18);
142
+ color: #d4a019;
143
+ border: 1px solid rgba(212, 160, 25, 0.44);
144
+ }
145
+
146
+ .badge-red {
147
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold font-sans;
148
+ background: rgba(230, 57, 70, 0.18);
149
+ color: #e63946;
150
+ border: 1px solid rgba(230, 57, 70, 0.44);
151
+ }
152
+
153
+ .badge-blue {
154
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold font-sans;
155
+ background: rgba(21, 101, 192, 0.18);
156
+ color: #1565C0;
157
+ border: 1px solid rgba(21, 101, 192, 0.44);
158
+ }
159
+
160
+ .badge-slate {
161
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold font-sans;
162
+ background: rgba(136, 136, 136, 0.18);
163
+ color: #888;
164
+ border: 1px solid rgba(136, 136, 136, 0.44);
165
+ }
166
+
167
+ .badge-orange {
168
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold font-sans;
169
+ background: rgba(230, 126, 34, 0.18);
170
+ color: #e67e22;
171
+ border: 1px solid rgba(230, 126, 34, 0.44);
172
+ }
173
+
174
+ /* ── Tables ── */
175
+ .table-container {
176
+ @apply overflow-x-auto rounded-[10px] border border-warm-border bg-white;
177
+ }
178
+
179
+ .table-container table {
180
+ @apply w-full text-sm text-left font-sans;
181
+ }
182
+
183
+ .table-container thead {
184
+ @apply border-b border-warm-border;
185
+ background: #f5f3ef;
186
+ }
187
+
188
+ .table-container th {
189
+ @apply px-4 py-3 font-semibold text-warm-muted text-xs uppercase;
190
+ letter-spacing: 1.2px;
191
+ }
192
+
193
+ .table-container td {
194
+ @apply px-4 py-3 border-b border-warm-border/50;
195
+ color: #555;
196
+ }
197
+
198
+ .table-container tbody tr:last-child td {
199
+ @apply border-b-0;
200
+ }
201
+
202
+ .table-container tbody tr:hover {
203
+ background: rgba(250, 248, 245, 0.6);
204
+ }
205
+
206
+ /* ── Tabs (bottom-border style) ── */
207
+ .page-title {
208
+ font-size: 2rem;
209
+ font-weight: 700;
210
+ font-family: 'Source Serif 4', serif;
211
+ color: #1a1a1a;
212
+ line-height: 1.2;
213
+ margin: 0;
214
+ }
215
+
216
+ .page-caption {
217
+ font-size: 0.88rem;
218
+ color: #888;
219
+ margin-top: 6px;
220
+ }
221
+
222
+ .tab-list {
223
+ @apply flex border-b border-warm-border gap-0;
224
+ overflow-x: auto;
225
+ -webkit-overflow-scrolling: touch;
226
+ scrollbar-width: none;
227
+ }
228
+ .tab-list::-webkit-scrollbar { display: none; }
229
+
230
+ .tab-item {
231
+ @apply px-4 py-2.5 text-sm font-sans font-medium text-warm-body cursor-pointer
232
+ border-b-2 border-transparent transition-colors duration-150
233
+ hover:text-[#1a1a1a] hover:border-warm-border;
234
+ white-space: nowrap;
235
+ flex-shrink: 0;
236
+ }
237
+
238
+ .tab-item.active {
239
+ @apply text-gold border-gold font-semibold;
240
+ }
241
+
242
+ /* ── Stage cards (Dashboard) ── */
243
+ .stage-card {
244
+ @apply bg-white rounded-[14px] border border-warm-border relative overflow-hidden;
245
+ padding: 22px 20px 16px;
246
+ text-decoration: none;
247
+ color: inherit;
248
+ font-family: 'DM Sans', sans-serif;
249
+ transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
250
+ }
251
+
252
+ .stage-card:hover {
253
+ border-color: #ccc8c0;
254
+ box-shadow: 0 8px 28px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.03);
255
+ transform: translateY(-3px);
256
+ }
257
+
258
+ /* ── Section headers ── */
259
+ .section-header {
260
+ @apply uppercase text-[0.78rem] font-sans font-semibold text-warm-muted pb-2 mb-4;
261
+ letter-spacing: 1.5px;
262
+ border-bottom: 2px solid #d4a019;
263
+ }
264
+ }
265
+
266
+ @layer utilities {
267
+ .animate-fade-in {
268
+ animation: fadeIn 0.2s ease-out;
269
+ }
270
+
271
+ .animate-slide-up {
272
+ animation: slideUp 0.3s ease-out both;
273
+ }
274
+
275
+ .animate-stagger > * {
276
+ animation: slideUp 0.3s ease-out both;
277
+ }
278
+ .animate-stagger > *:nth-child(1) { animation-delay: 0ms; }
279
+ .animate-stagger > *:nth-child(2) { animation-delay: 50ms; }
280
+ .animate-stagger > *:nth-child(3) { animation-delay: 100ms; }
281
+ .animate-stagger > *:nth-child(4) { animation-delay: 150ms; }
282
+ .animate-stagger > *:nth-child(5) { animation-delay: 200ms; }
283
+ .animate-stagger > *:nth-child(6) { animation-delay: 250ms; }
284
+
285
+ .animate-tab-enter {
286
+ animation: tabEnter 0.15s ease-out both;
287
+ }
288
+
289
+ @keyframes fadeIn {
290
+ from { opacity: 0; transform: translateY(4px); }
291
+ to { opacity: 1; transform: translateY(0); }
292
+ }
293
+
294
+ @keyframes slideUp {
295
+ from { opacity: 0; transform: translateY(8px); }
296
+ to { opacity: 1; transform: translateY(0); }
297
+ }
298
+
299
+ @keyframes tabEnter {
300
+ from { opacity: 0; transform: translateY(4px); }
301
+ to { opacity: 1; transform: translateY(0); }
302
+ }
303
+ }
304
+
305
+ /* Joyride tooltip overrides */
306
+ .__floater__body {
307
+ font-family: 'DM Sans', system-ui, sans-serif !important;
308
+ }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery, keepPreviousData } from '@tanstack/react-query'
2
+
3
+ const BASE_URL = import.meta.env.VITE_API_URL ?? ''
4
+
5
+ async function fetchJson<T>(path: string): Promise<T> {
6
+ const res = await fetch(`${BASE_URL}${path}`)
7
+ if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`)
8
+ return res.json() as Promise<T>
9
+ }
10
+
11
+ // ── Types (matched to actual API response field names) ──────────────────
12
+
13
+ export interface Zone {
14
+ zone_id: string
15
+ name: string
16
+ city: string
17
+ country: string
18
+ latitude: number
19
+ longitude: number
20
+ elevation_m: number
21
+ settlement_type: string
22
+ worker_population_est: number
23
+ outdoor_exposure_pct: number
24
+ heat_vulnerability: string
25
+ risk_level: string
26
+ current_temp_c: number
27
+ current_wbgt_c: number
28
+ current_heat_index_c: number
29
+ max_temp_c: number
30
+ max_wbgt_c: number
31
+ consecutive_hot_days: number
32
+ total_days_above_33: number
33
+ heat_risk_score: number
34
+ grid_temp_c: number
35
+ uhi_delta_c: number
36
+ corrected_temp_c: number
37
+ trigger_probability_7d: number
38
+ prediction_confidence: number
39
+ model_tier: string
40
+ enrolled_workers: number
41
+ data_quality: number
42
+ last_updated: string
43
+ }
44
+
45
+ export interface ZonesResponse {
46
+ zones: Zone[]
47
+ total: number
48
+ cities: string[]
49
+ }
50
+
51
+ export interface DailyHeat {
52
+ date: string
53
+ temp_c: number
54
+ grid_temp_c: number
55
+ uhi_delta_c: number
56
+ humidity_pct: number
57
+ wbgt_c: number
58
+ heat_index_c: number
59
+ }
60
+
61
+ export interface IndexData {
62
+ zone_id: string
63
+ zone_name: string
64
+ city: string
65
+ temp_current: number
66
+ wbgt_current: number
67
+ heat_index_current: number
68
+ consecutive_hot_days: number
69
+ heat_risk_score: number
70
+ risk_level: string
71
+ grid_temp_c: number
72
+ uhi_delta_c: number
73
+ trigger_probability_7d: number
74
+ prediction_confidence: number
75
+ model_tier: string
76
+ daily_history: DailyHeat[]
77
+ }
78
+
79
+ export interface IndicesResponse {
80
+ indices: IndexData[]
81
+ total: number
82
+ }
83
+
84
+ export interface Trigger {
85
+ trigger_id: string
86
+ zone_id: string
87
+ zone_name: string
88
+ city: string
89
+ trigger_level: string
90
+ trigger_date: string
91
+ heat_risk_score: number
92
+ max_temp_c: number
93
+ max_wbgt_c: number
94
+ consecutive_days: number
95
+ total_days_above: number
96
+ settlement_type: string
97
+ payout_per_worker_usd: number
98
+ enrolled_workers: number
99
+ total_payout_usd: number
100
+ status: string
101
+ }
102
+
103
+ export interface TriggersResponse {
104
+ triggers: Trigger[]
105
+ total: number
106
+ active: number
107
+ by_level: Record<string, number>
108
+ }
109
+
110
+ export interface BasisRisk {
111
+ zone_id: string
112
+ zone_name: string
113
+ city: string
114
+ overall_score: number
115
+ false_positive_rate: number
116
+ false_negative_rate: number
117
+ correlation: number
118
+ settlement_type: string
119
+ heat_vulnerability: string
120
+ recommendation: string
121
+ }
122
+
123
+ export interface BasisRiskResponse {
124
+ assessments: BasisRisk[]
125
+ total: number
126
+ avg_score: number
127
+ }
128
+
129
+ export interface Notification {
130
+ id: string
131
+ zone_id: string
132
+ zone_name: string
133
+ city: string
134
+ trigger_level: string
135
+ channel: string
136
+ language: string
137
+ recipient_count: number
138
+ message_preview: string
139
+ status: string
140
+ delivered_at: string
141
+ cost_estimate: number
142
+ }
143
+
144
+ export interface NotificationsResponse {
145
+ notifications: Notification[]
146
+ total: number
147
+ by_language: Record<string, number>
148
+ }
149
+
150
+ export interface PipelineStep {
151
+ step: string
152
+ status: string
153
+ duration_s: number
154
+ }
155
+
156
+ export interface PipelineRun {
157
+ run_id: string
158
+ started_at: string
159
+ ended_at: string
160
+ status: string
161
+ duration_s: number
162
+ zones_processed: number
163
+ triggers_found: number
164
+ notifications_sent: number
165
+ total_cost_usd: number
166
+ steps: PipelineStep[]
167
+ }
168
+
169
+ export interface PipelineRunsResponse {
170
+ runs: PipelineRun[]
171
+ total: number
172
+ }
173
+
174
+ export interface PipelineStats {
175
+ total_runs: number
176
+ successful_runs: number
177
+ success_rate: number
178
+ zones_monitored: number
179
+ cities: number
180
+ active_triggers: number
181
+ total_enrolled: number
182
+ total_cost_usd: number
183
+ avg_cost_per_run_usd: number
184
+ last_run: string | null
185
+ data_sources: string[]
186
+ }
187
+
188
+ export interface EnrolledResponse {
189
+ by_zone: { zone_id: string; zone_name: string; city: string; enrolled: number }[]
190
+ total_enrolled: number
191
+ }
192
+
193
+ export interface CalibrateParams {
194
+ temp_threshold: number
195
+ consecutive_days: number
196
+ wbgt_threshold: number
197
+ payout_usd: number
198
+ budget_usd: number
199
+ worker_contribution_usd: number
200
+ }
201
+
202
+ export interface CalibrateZoneResult {
203
+ zone_id: string
204
+ zone_name: string
205
+ city: string
206
+ settlement_type: string
207
+ heat_vulnerability: string
208
+ enrolled_workers: number
209
+ days_above_temp: number
210
+ days_above_wbgt: number
211
+ consecutive_days_temp: number
212
+ consecutive_days_wbgt: number
213
+ trigger_events: number
214
+ events_per_year: number
215
+ annual_payout_per_worker: number
216
+ annual_payout_total: number
217
+ basis_risk_score: number
218
+ triggered: boolean
219
+ actuarial_cost_per_worker: number
220
+ cost_breakdown: Record<string, any>
221
+ allocated_budget: number
222
+ workers_covered: number
223
+ coverage_pct: number
224
+ priority_rank: number
225
+ }
226
+
227
+ export interface CalibrateSummary {
228
+ total_zones: number
229
+ zones_triggered: number
230
+ total_trigger_days: number
231
+ avg_events_per_year: number
232
+ total_annual_cost: number
233
+ avg_cost_per_worker: number
234
+ total_enrolled: number
235
+ avg_basis_risk: number
236
+ }
237
+
238
+ export interface CalibrateAllocation {
239
+ budget_usd: number
240
+ worker_contribution_usd: number
241
+ workers_covered: number
242
+ overall_coverage_pct: number
243
+ zones_fully_funded: number
244
+ zones_partially_funded: number
245
+ zones_unfunded: number
246
+ stretch_analysis: Record<string, any>
247
+ }
248
+
249
+ export interface CalibrateResponse {
250
+ zones: CalibrateZoneResult[]
251
+ summary: CalibrateSummary
252
+ allocation: CalibrateAllocation
253
+ thresholds: CalibrateParams
254
+ }
255
+
256
+ // ── Query hooks ──────────────────────────────────────────────────────────
257
+
258
+ const STALE_5MIN = 5 * 60 * 1000
259
+
260
+ export function useZones() {
261
+ return useQuery<ZonesResponse>({
262
+ queryKey: ['zones'],
263
+ queryFn: () => fetchJson('/api/zones'),
264
+ staleTime: STALE_5MIN,
265
+ })
266
+ }
267
+
268
+ export function useIndices() {
269
+ return useQuery<IndicesResponse>({
270
+ queryKey: ['indices'],
271
+ queryFn: () => fetchJson('/api/indices'),
272
+ staleTime: STALE_5MIN,
273
+ })
274
+ }
275
+
276
+ export function useTriggers() {
277
+ return useQuery<TriggersResponse>({
278
+ queryKey: ['triggers'],
279
+ queryFn: () => fetchJson('/api/triggers'),
280
+ staleTime: STALE_5MIN,
281
+ })
282
+ }
283
+
284
+ export function useBasisRisk() {
285
+ return useQuery<BasisRiskResponse>({
286
+ queryKey: ['basis-risk'],
287
+ queryFn: () => fetchJson('/api/basis-risk'),
288
+ staleTime: STALE_5MIN,
289
+ })
290
+ }
291
+
292
+ export function useNotifications() {
293
+ return useQuery<NotificationsResponse>({
294
+ queryKey: ['notifications'],
295
+ queryFn: () => fetchJson('/api/notifications'),
296
+ staleTime: STALE_5MIN,
297
+ })
298
+ }
299
+
300
+ export function usePipelineRuns() {
301
+ return useQuery<PipelineRunsResponse>({
302
+ queryKey: ['pipeline-runs'],
303
+ queryFn: () => fetchJson('/api/pipeline/runs'),
304
+ staleTime: STALE_5MIN,
305
+ })
306
+ }
307
+
308
+ export function usePipelineStats() {
309
+ return useQuery<PipelineStats>({
310
+ queryKey: ['pipeline-stats'],
311
+ queryFn: () => fetchJson('/api/pipeline/stats'),
312
+ staleTime: STALE_5MIN,
313
+ })
314
+ }
315
+
316
+ export function useEnrolled() {
317
+ return useQuery<EnrolledResponse>({
318
+ queryKey: ['enrolled'],
319
+ queryFn: () => fetchJson('/api/enrolled-workers'),
320
+ staleTime: STALE_5MIN,
321
+ })
322
+ }
323
+
324
+ export function useCalibrateQuery(params: CalibrateParams) {
325
+ const qs = new URLSearchParams({
326
+ temp_threshold: String(params.temp_threshold),
327
+ consecutive_days: String(params.consecutive_days),
328
+ wbgt_threshold: String(params.wbgt_threshold),
329
+ payout_usd: String(params.payout_usd),
330
+ budget_usd: String(params.budget_usd),
331
+ worker_contribution_usd: String(params.worker_contribution_usd),
332
+ })
333
+
334
+ return useQuery<CalibrateResponse>({
335
+ queryKey: ['calibrate', params.temp_threshold, params.consecutive_days, params.wbgt_threshold, params.payout_usd, params.budget_usd, params.worker_contribution_usd],
336
+ queryFn: () => fetchJson(`/api/calibrate?${qs.toString()}`),
337
+ staleTime: 60 * 1000,
338
+ placeholderData: keepPreviousData,
339
+ })
340
+ }
frontend/src/lib/tour.ts ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Step } from 'react-joyride'
2
+
3
+ export const tourSteps: Step[] = [
4
+ // ── Dashboard ──
5
+ {
6
+ target: '[data-tour="hero"]',
7
+ title: 'Welcome to the Heat Risk Engine',
8
+ content:
9
+ 'This dashboard monitors an extreme heat insurance pipeline for urban workers across East Africa. ' +
10
+ 'It pulls real satellite temperature data from NASA, calculates heat stress indices like WBGT, ' +
11
+ 'and triggers payout notifications to enrolled workers when dangerous heat thresholds are breached — ' +
12
+ 'across 20 neighborhoods in Nairobi, Dar es Salaam, Kampala, and Kigali.',
13
+ placement: 'bottom',
14
+ disableBeacon: true,
15
+ },
16
+ {
17
+ target: '[data-tour="stage-cards"]',
18
+ title: 'Three-stage pipeline',
19
+ content:
20
+ 'Climate temperature data flows through heat index calculation to trigger calibration. ' +
21
+ 'Each stage runs automatically on a scheduled pipeline.',
22
+ placement: 'bottom',
23
+ disableBeacon: true,
24
+ },
25
+ {
26
+ target: '[data-tour="metrics"]',
27
+ title: 'Live metrics',
28
+ content:
29
+ 'Active triggers, zones monitored, enrolled workers, and pipeline run counts ' +
30
+ 'update after each pipeline execution.',
31
+ placement: 'top',
32
+ disableBeacon: true,
33
+ },
34
+ // ── Navigate to Zones ──
35
+ {
36
+ target: '[data-tour="nav-zones"]',
37
+ title: 'Zone coverage',
38
+ content: 'Next: the 20 urban zones across East African cities.',
39
+ placement: 'right',
40
+ disableBeacon: true,
41
+ },
42
+ {
43
+ target: '[data-tour="zones-metrics"]',
44
+ title: 'Heat-vulnerable zones',
45
+ content:
46
+ 'Urban neighborhoods where outdoor workers face the highest heat exposure. ' +
47
+ 'Each zone has its own heat vulnerability profile, worker population estimate, and enrollment count.',
48
+ placement: 'bottom',
49
+ disableBeacon: true,
50
+ },
51
+ // ── Navigate to Heat Monitor ──
52
+ {
53
+ target: '[data-tour="nav-heat-monitor"]',
54
+ title: 'Heat monitoring',
55
+ content: 'Next: real-time temperature, WBGT, and heat index tracking.',
56
+ placement: 'right',
57
+ disableBeacon: true,
58
+ },
59
+ {
60
+ target: '[data-tour="heat-monitor-metrics"]',
61
+ title: 'Heat index monitoring',
62
+ content:
63
+ 'Temperature and WBGT values classify each zone from normal through caution, warning, to critical. ' +
64
+ 'Consecutive hot days track sustained heat events that trigger payouts.',
65
+ placement: 'bottom',
66
+ disableBeacon: true,
67
+ },
68
+ {
69
+ target: '[data-tour="heat-monitor-tabs"]',
70
+ title: 'Temperature trends',
71
+ content:
72
+ 'The trends tab shows 90-day temperature history for each zone, revealing heat wave patterns ' +
73
+ 'and the buildup to trigger events.',
74
+ placement: 'top',
75
+ disableBeacon: true,
76
+ },
77
+ // ── Navigate to Program Designer ──
78
+ {
79
+ target: '[data-tour="nav-calibrate"]',
80
+ title: 'Program design',
81
+ content: 'Next: the program design tool with actuarial pricing and budget allocation.',
82
+ placement: 'right',
83
+ disableBeacon: true,
84
+ },
85
+ {
86
+ target: '[data-tour="program-controls"]',
87
+ title: 'Design the product',
88
+ content:
89
+ 'Enter your total budget and payout amount. The model does the actuarial pricing \u2014 ' +
90
+ 'calculating what it costs to cover each zone based on predicted trigger frequency, ' +
91
+ 'basis risk, and worker exposure. Then it optimizes allocation: highest-impact zones get funded first.',
92
+ placement: 'bottom',
93
+ disableBeacon: true,
94
+ },
95
+ {
96
+ target: '[data-tour="program-results"]',
97
+ title: 'Allocation results',
98
+ content:
99
+ 'Results update live as you move the sliders. Zones are ranked by priority, ' +
100
+ 'with actuarial cost per worker, allocated budget, and coverage percentage for each zone.',
101
+ placement: 'top',
102
+ disableBeacon: true,
103
+ },
104
+ // ── Navigate to Alerts ──
105
+ {
106
+ target: '[data-tour="nav-alerts"]',
107
+ title: 'Worker heat alerts',
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: 'Multilingual alerts',
115
+ content:
116
+ 'SMS and WhatsApp notifications are sent in English and Swahili. ' +
117
+ 'Each message includes the temperature reading, heat danger level, and payout amount.',
118
+ placement: 'top',
119
+ disableBeacon: true,
120
+ },
121
+ // ── Final ──
122
+ {
123
+ target: '[data-tour="nav-home"]',
124
+ title: 'The hard problems remain',
125
+ content:
126
+ 'The full chain from satellite temperature data to worker payout notification, automated. ' +
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 station reading, ' +
129
+ 'and making sure people actually rest when it\u2019s dangerous to work. ' +
130
+ 'That\u2019s where the investment should go.',
131
+ placement: 'right',
132
+ disableBeacon: true,
133
+ },
134
+ ]
135
+
136
+ export const tourStyles = {
137
+ options: {
138
+ zIndex: 10000,
139
+ arrowColor: '#1a1a1a',
140
+ backgroundColor: '#1a1a1a',
141
+ primaryColor: '#d4a019',
142
+ textColor: '#e0dcd5',
143
+ overlayColor: 'rgba(0, 0, 0, 0.45)',
144
+ },
145
+ tooltip: {
146
+ borderRadius: 10,
147
+ padding: '20px 22px',
148
+ maxWidth: 380,
149
+ fontFamily: '"DM Sans", system-ui, sans-serif',
150
+ fontSize: '0.88rem',
151
+ lineHeight: 1.6,
152
+ },
153
+ tooltipTitle: {
154
+ fontFamily: '"Source Serif 4", Georgia, serif',
155
+ fontWeight: 700,
156
+ fontSize: '1.05rem',
157
+ color: '#d4a019',
158
+ marginBottom: 8,
159
+ },
160
+ tooltipContent: {
161
+ padding: '8px 0 0',
162
+ },
163
+ buttonNext: {
164
+ backgroundColor: '#d4a019',
165
+ color: '#fff',
166
+ borderRadius: 6,
167
+ fontFamily: '"DM Sans", system-ui, sans-serif',
168
+ fontWeight: 600,
169
+ fontSize: '0.8rem',
170
+ letterSpacing: '0.5px',
171
+ textTransform: 'uppercase' as const,
172
+ padding: '8px 18px',
173
+ },
174
+ buttonBack: {
175
+ color: '#888',
176
+ fontFamily: '"DM Sans", system-ui, sans-serif',
177
+ fontWeight: 500,
178
+ fontSize: '0.8rem',
179
+ marginRight: 8,
180
+ },
181
+ buttonSkip: {
182
+ color: '#666',
183
+ fontFamily: '"DM Sans", system-ui, sans-serif',
184
+ fontSize: '0.75rem',
185
+ },
186
+ spotlight: {
187
+ borderRadius: 10,
188
+ },
189
+ beacon: {
190
+ display: 'none',
191
+ },
192
+ beaconInner: {
193
+ display: 'none',
194
+ },
195
+ beaconOuter: {
196
+ display: 'none',
197
+ },
198
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import { BrowserRouter } from 'react-router-dom'
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import App from './App'
6
+ import './index.css'
7
+
8
+ const queryClient = new QueryClient({
9
+ defaultOptions: {
10
+ queries: {
11
+ staleTime: 60_000,
12
+ retry: 1,
13
+ },
14
+ },
15
+ })
16
+
17
+ ReactDOM.createRoot(document.getElementById('root')!).render(
18
+ <React.StrictMode>
19
+ <QueryClientProvider client={queryClient}>
20
+ <BrowserRouter>
21
+ <App />
22
+ </BrowserRouter>
23
+ </QueryClientProvider>
24
+ </React.StrictMode>
25
+ )
frontend/src/pages/Dashboard.tsx ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { Link } from 'react-router-dom'
3
+ import { Satellite, Thermometer, SlidersHorizontal, ChevronDown, ChevronRight, ArrowRight } from 'lucide-react'
4
+ import MetricCard from '../components/MetricCard'
5
+ import StatusBadge from '../components/StatusBadge'
6
+ import { LoadingSpinner, ErrorState } from '../components/LoadingState'
7
+ import { usePipelineStats, usePipelineRuns } from '../lib/api'
8
+
9
+ export default function Dashboard() {
10
+ const stats = usePipelineStats()
11
+ const runs = usePipelineRuns()
12
+ const [showRuns, setShowRuns] = useState(false)
13
+
14
+ if (stats.isLoading) return <LoadingSpinner />
15
+ if (stats.isError) return <ErrorState onRetry={() => stats.refetch()} />
16
+
17
+ const s = stats.data
18
+
19
+ return (
20
+ <div className="animate-slide-up">
21
+ {/* Hero */}
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
+ Parametric insurance triggers for urban workers in East Africa
26
+ </p>
27
+ </div>
28
+
29
+ {/* Stage Cards */}
30
+ <div data-tour="stage-cards" className="mb-8">
31
+ <div className="section-header">Pipeline Stages</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">Climate Data + UHI Correction</h3>
39
+ </div>
40
+ <p className="text-xs text-warm-body leading-relaxed m-0">
41
+ NASA POWER grid temperatures corrected with ML urban heat island model per zone
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(' + ')} + UHI Model
45
+ </div>
46
+ </Link>
47
+
48
+ <div className="hidden md:flex items-center justify-center">
49
+ <ArrowRight size={20} className="text-warm-border" />
50
+ </div>
51
+
52
+ <Link to="/heat-monitor" className="stage-card no-underline">
53
+ <div className="flex items-center gap-3 mb-2">
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">Heat Wave Prediction</h3>
58
+ </div>
59
+ <p className="text-xs text-warm-body leading-relaxed m-0">
60
+ 7-day trigger probability forecasts with model confidence scoring per 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
+ Prediction + WBGT + Heat Index
64
+ </div>
65
+ </Link>
66
+
67
+ <div className="hidden md:flex items-center justify-center">
68
+ <ArrowRight size={20} className="text-warm-border" />
69
+ </div>
70
+
71
+ <Link to="/calibrate" className="stage-card no-underline">
72
+ <div className="flex items-center gap-3 mb-2">
73
+ <div className="w-9 h-9 rounded-lg bg-red-50 flex items-center justify-center">
74
+ <SlidersHorizontal size={18} className="text-error" />
75
+ </div>
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
+ Actuarial pricing and budget allocation across zones with priority-ranked coverage
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 + Allocation
83
+ </div>
84
+ </Link>
85
+ </div>
86
+ </div>
87
+
88
+ {/* Metrics */}
89
+ <div data-tour="metrics" className="mb-8">
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 Triggers"
94
+ value={s?.active_triggers}
95
+ subtitle="zones above threshold"
96
+ />
97
+ <MetricCard
98
+ label="Zones Monitored"
99
+ value={s?.zones_monitored}
100
+ subtitle={`${s?.cities} cities`}
101
+ />
102
+ <MetricCard
103
+ label="Workers Enrolled"
104
+ value={s?.total_enrolled?.toLocaleString()}
105
+ subtitle="across all zones"
106
+ />
107
+ <MetricCard
108
+ label="Pipeline Runs"
109
+ value={s?.total_runs}
110
+ subtitle={`${Math.round((s?.success_rate ?? 0) * 100)}% success`}
111
+ />
112
+ </div>
113
+ </div>
114
+
115
+ {/* Run History (collapsible) */}
116
+ <div className="mb-8">
117
+ <button
118
+ onClick={() => setShowRuns(!showRuns)}
119
+ className="flex items-center gap-2 section-header cursor-pointer w-full text-left border-b-0 pb-0 mb-0 bg-transparent border-none"
120
+ style={{ borderBottom: '2px solid #d4a019', paddingBottom: 8, marginBottom: 16 }}
121
+ >
122
+ {showRuns ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
123
+ Run History
124
+ </button>
125
+ {showRuns && (
126
+ <div className="animate-tab-enter">
127
+ {runs.isLoading ? (
128
+ <LoadingSpinner message="Loading runs..." />
129
+ ) : runs.isError ? (
130
+ <ErrorState onRetry={() => runs.refetch()} />
131
+ ) : (
132
+ <div className="table-container">
133
+ <table>
134
+ <thead>
135
+ <tr>
136
+ <th>Run ID</th>
137
+ <th>Date</th>
138
+ <th>Status</th>
139
+ <th>Duration</th>
140
+ <th>Zones</th>
141
+ <th>Triggers</th>
142
+ <th>Notifications</th>
143
+ <th>Cost (USD)</th>
144
+ </tr>
145
+ </thead>
146
+ <tbody>
147
+ {runs.data?.runs.map((run) => (
148
+ <tr key={run.run_id}>
149
+ <td className="font-mono text-xs">{run.run_id}</td>
150
+ <td>{new Date(run.started_at).toLocaleDateString()}</td>
151
+ <td><StatusBadge status={run.status} /></td>
152
+ <td>{run.duration_s.toFixed(0)}s</td>
153
+ <td>{run.zones_processed}</td>
154
+ <td>{run.triggers_found}</td>
155
+ <td>{run.notifications_sent}</td>
156
+ <td>${run.total_cost_usd.toFixed(4)}</td>
157
+ </tr>
158
+ ))}
159
+ </tbody>
160
+ </table>
161
+ </div>
162
+ )}
163
+ </div>
164
+ )}
165
+ </div>
166
+ </div>
167
+ )
168
+ }
frontend/src/pages/HeatMonitor.tsx ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import {
3
+ LineChart,
4
+ Line,
5
+ XAxis,
6
+ YAxis,
7
+ CartesianGrid,
8
+ Tooltip,
9
+ ReferenceLine,
10
+ ResponsiveContainer,
11
+ } from 'recharts'
12
+ import MetricCard from '../components/MetricCard'
13
+ import StatusBadge from '../components/StatusBadge'
14
+ import { LoadingSpinner, ErrorState } from '../components/LoadingState'
15
+ import { useIndices } from '../lib/api'
16
+
17
+ function tempColor(temp: number): string {
18
+ if (temp > 37) return '#e63946'
19
+ if (temp > 35) return '#d35400'
20
+ if (temp > 33) return '#e67e22'
21
+ if (temp > 30) return '#d4a019'
22
+ return '#2a9d8f'
23
+ }
24
+
25
+ function wbgtColor(wbgt: number): string {
26
+ if (wbgt > 32) return '#e63946'
27
+ if (wbgt > 30) return '#e67e22'
28
+ if (wbgt > 28) return '#d4a019'
29
+ return '#2a9d8f'
30
+ }
31
+
32
+ export default function HeatMonitor() {
33
+ const indices = useIndices()
34
+ const [activeTab, setActiveTab] = useState<'current' | 'trends'>('current')
35
+ const [selectedZone, setSelectedZone] = useState<string>('')
36
+
37
+ if (indices.isLoading) return <LoadingSpinner />
38
+ if (indices.isError) return <ErrorState onRetry={() => indices.refetch()} />
39
+
40
+ const data = indices.data?.indices ?? []
41
+ const criticalCount = data.filter((d) => d.risk_level === 'critical').length
42
+ const wbgtDanger = data.filter((d) => d.wbgt_current > 32).length
43
+ const avgTemp = data.length > 0
44
+ ? data.reduce((sum, d) => sum + d.temp_current, 0) / data.length
45
+ : 0
46
+
47
+ // Default to first zone for trends chart
48
+ const zoneId = selectedZone || (data.length > 0 ? data[0].zone_id : '')
49
+ const selectedData = data.find((d) => d.zone_id === zoneId)
50
+ const chartData = selectedData?.daily_history.map((d) => ({
51
+ date: new Date(d.date).toLocaleDateString('en', { month: 'short', day: 'numeric' }),
52
+ temp: d.temp_c,
53
+ gridTemp: d.grid_temp_c,
54
+ wbgt: d.wbgt_c,
55
+ humidity: d.humidity_pct,
56
+ })) ?? []
57
+
58
+ return (
59
+ <div className="animate-slide-up">
60
+ {/* Title */}
61
+ <div data-tour="heat-monitor-title" className="pt-2 pb-6">
62
+ <h1 className="page-title">Heat Monitor</h1>
63
+ <p className="page-caption">
64
+ Temperature, WBGT, and heat index tracking across all zones
65
+ </p>
66
+ </div>
67
+
68
+ {/* Metrics */}
69
+ <div data-tour="heat-monitor-metrics" className="mb-8">
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="WBGT Danger" value={wbgtDanger} subtitle="WBGT > 32°C" />
74
+ <MetricCard label="Avg Temperature" value={`${avgTemp.toFixed(1)}°C`} subtitle="across all zones" />
75
+ </div>
76
+ </div>
77
+
78
+ {/* Tabs */}
79
+ <div data-tour="heat-monitor-tabs" className="mb-6">
80
+ <div className="tab-list">
81
+ <button
82
+ className={`tab-item ${activeTab === 'current' ? 'active' : ''}`}
83
+ onClick={() => setActiveTab('current')}
84
+ >
85
+ Current Heat
86
+ </button>
87
+ <button
88
+ className={`tab-item ${activeTab === 'trends' ? 'active' : ''}`}
89
+ onClick={() => setActiveTab('trends')}
90
+ >
91
+ Temperature Trends
92
+ </button>
93
+ </div>
94
+ </div>
95
+
96
+ {activeTab === 'current' && (
97
+ <div className="animate-tab-enter">
98
+ <div className="table-container">
99
+ <table>
100
+ <thead>
101
+ <tr>
102
+ <th>Zone</th>
103
+ <th>Temp (°C)</th>
104
+ <th>WBGT (°C)</th>
105
+ <th>Heat Index</th>
106
+ <th>Consecutive Hot Days</th>
107
+ <th>7-Day Trigger</th>
108
+ <th>Confidence</th>
109
+ <th>Model</th>
110
+ <th>Risk Level</th>
111
+ </tr>
112
+ </thead>
113
+ <tbody>
114
+ {data.map((idx) => {
115
+ const pct = (idx.trigger_probability_7d ?? 0) * 100
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 === 'full_model' ? 'Full Model' : tier === 'persistence' ? 'Persistence' : 'Climatology'
120
+ return (
121
+ <tr key={idx.zone_id}>
122
+ <td>
123
+ <div className="font-medium text-[#1a1a1a]">{idx.zone_name}</div>
124
+ <div className="text-[10px] text-warm-muted">{idx.city}</div>
125
+ </td>
126
+ <td>
127
+ <span
128
+ className="font-mono font-semibold text-sm"
129
+ style={{ color: tempColor(idx.temp_current) }}
130
+ >
131
+ {idx.temp_current.toFixed(1)}
132
+ </span>
133
+ </td>
134
+ <td>
135
+ <span
136
+ className="font-mono font-semibold text-sm"
137
+ style={{ color: wbgtColor(idx.wbgt_current) }}
138
+ >
139
+ {idx.wbgt_current.toFixed(1)}
140
+ </span>
141
+ </td>
142
+ <td className="font-mono text-sm">{idx.heat_index_current.toFixed(1)}</td>
143
+ <td className="font-mono text-sm">{idx.consecutive_hot_days}</td>
144
+ <td>
145
+ <div className="flex items-center gap-2">
146
+ <div className="w-16 h-2 rounded-full bg-warm-border overflow-hidden">
147
+ <div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: probColor }} />
148
+ </div>
149
+ <span className="font-mono font-semibold text-sm" style={{ color: probColor }}>
150
+ {pct?.toFixed(0)}%
151
+ </span>
152
+ </div>
153
+ </td>
154
+ <td className="font-mono text-sm">{((idx.prediction_confidence ?? 0) * 100).toFixed(0)}%</td>
155
+ <td>
156
+ <span
157
+ className="inline-block px-2 py-0.5 rounded text-[10px] font-sans font-semibold uppercase tracking-wider"
158
+ style={{ backgroundColor: `${tierColor}18`, color: tierColor, border: `1px solid ${tierColor}40` }}
159
+ >
160
+ {tierLabel}
161
+ </span>
162
+ </td>
163
+ <td><StatusBadge status={idx.risk_level} /></td>
164
+ </tr>
165
+ )
166
+ })}
167
+ </tbody>
168
+ </table>
169
+ </div>
170
+
171
+ {/* Temperature color legend */}
172
+ <div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-warm-muted font-sans">
173
+ <span className="font-semibold uppercase tracking-wider">Temp:</span>
174
+ {[
175
+ { label: '<30°C Safe', color: '#2a9d8f' },
176
+ { label: '30-33°C Caution', color: '#d4a019' },
177
+ { label: '33-35°C Warning', color: '#e67e22' },
178
+ { label: '35-37°C Danger', color: '#d35400' },
179
+ { label: '>37°C Extreme', color: '#e63946' },
180
+ ].map((item) => (
181
+ <span key={item.label} className="flex items-center gap-1.5">
182
+ <span
183
+ className="w-3 h-3 rounded-sm inline-block"
184
+ style={{ backgroundColor: item.color }}
185
+ />
186
+ {item.label}
187
+ </span>
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">WBGT:</span>
192
+ {[
193
+ { label: '<28°C Safe', color: '#2a9d8f' },
194
+ { label: '28-30°C Caution', color: '#d4a019' },
195
+ { label: '30-32°C Danger', color: '#e67e22' },
196
+ { label: '>32°C Extreme', color: '#e63946' },
197
+ ].map((item) => (
198
+ <span key={item.label} className="flex items-center gap-1.5">
199
+ <span
200
+ className="w-3 h-3 rounded-sm inline-block"
201
+ style={{ backgroundColor: item.color }}
202
+ />
203
+ {item.label}
204
+ </span>
205
+ ))}
206
+ </div>
207
+ </div>
208
+ )}
209
+
210
+ {activeTab === 'trends' && (
211
+ <div className="animate-tab-enter">
212
+ {/* Zone selector */}
213
+ <div className="mb-6 flex items-center gap-3">
214
+ <label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
215
+ Zone
216
+ </label>
217
+ <select
218
+ value={zoneId}
219
+ onChange={(e) => setSelectedZone(e.target.value)}
220
+ className="input max-w-xs text-sm"
221
+ >
222
+ {data.map((d) => (
223
+ <option key={d.zone_id} value={d.zone_id}>
224
+ {d.zone_name} ({d.city})
225
+ </option>
226
+ ))}
227
+ </select>
228
+ {selectedData && (
229
+ <span className="ml-2">
230
+ <StatusBadge status={selectedData.risk_level} />
231
+ </span>
232
+ )}
233
+ </div>
234
+
235
+ {/* Temperature Chart */}
236
+ <div className="card card-body">
237
+ <h3 className="text-sm font-semibold font-sans text-[#1a1a1a] mb-1">
238
+ 90-Day Temperature History: {selectedData?.zone_name}
239
+ </h3>
240
+ <p className="text-xs text-warm-muted mb-4">
241
+ Current:{' '}
242
+ <span style={{ color: tempColor(selectedData?.temp_current ?? 0) }} className="font-semibold">
243
+ {selectedData?.temp_current.toFixed(1)}°C
244
+ </span>
245
+ {' / WBGT: '}
246
+ <span style={{ color: wbgtColor(selectedData?.wbgt_current ?? 0) }} className="font-semibold">
247
+ {selectedData?.wbgt_current.toFixed(1)}°C
248
+ </span>
249
+ </p>
250
+ <ResponsiveContainer width="100%" height={320}>
251
+ <LineChart data={chartData} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
252
+ <CartesianGrid strokeDasharray="3 3" stroke="#e0dcd5" />
253
+ <XAxis
254
+ dataKey="date"
255
+ tick={{ fontSize: 11, fill: '#888' }}
256
+ tickLine={false}
257
+ axisLine={{ stroke: '#e0dcd5' }}
258
+ interval={Math.max(0, Math.floor(chartData.length / 10) - 1)}
259
+ />
260
+ <YAxis
261
+ tick={{ fontSize: 11, fill: '#888' }}
262
+ tickLine={false}
263
+ axisLine={{ stroke: '#e0dcd5' }}
264
+ domain={['auto', 'auto']}
265
+ />
266
+ <Tooltip
267
+ contentStyle={{
268
+ backgroundColor: '#1a1a1a',
269
+ border: 'none',
270
+ borderRadius: 8,
271
+ color: '#e0dcd5',
272
+ fontFamily: 'DM Sans, system-ui, sans-serif',
273
+ fontSize: '0.8rem',
274
+ }}
275
+ labelStyle={{ color: '#d4a019', fontWeight: 600 }}
276
+ formatter={(value: number, name: string) => {
277
+ const labels: Record<string, string> = { temp: 'Corrected Temp (°C)', gridTemp: 'Grid Temp (°C)', wbgt: 'WBGT (°C)', humidity: 'Humidity (%)' }
278
+ return [typeof value === 'number' ? value.toFixed(1) : value, labels[name] ?? name]
279
+ }}
280
+ />
281
+ <ReferenceLine y={35} stroke="#e63946" strokeDasharray="4 4" strokeOpacity={0.6} label={{ value: '35°C', fill: '#e63946', fontSize: 10 }} />
282
+ <ReferenceLine y={30} stroke="#d4a019" strokeDasharray="4 4" strokeOpacity={0.4} />
283
+ <Line
284
+ type="monotone"
285
+ dataKey="gridTemp"
286
+ stroke="#888"
287
+ strokeWidth={1.5}
288
+ strokeDasharray="4 4"
289
+ dot={false}
290
+ activeDot={{ r: 3, stroke: '#888', strokeWidth: 2 }}
291
+ />
292
+ <Line
293
+ type="monotone"
294
+ dataKey="temp"
295
+ stroke="#e63946"
296
+ strokeWidth={2}
297
+ dot={false}
298
+ activeDot={{ r: 4, stroke: '#e63946', strokeWidth: 2 }}
299
+ />
300
+ <Line
301
+ type="monotone"
302
+ dataKey="wbgt"
303
+ stroke="#1565C0"
304
+ strokeWidth={1.5}
305
+ strokeDasharray="4 4"
306
+ dot={false}
307
+ activeDot={{ r: 4, stroke: '#1565C0', strokeWidth: 2 }}
308
+ />
309
+ </LineChart>
310
+ </ResponsiveContainer>
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
+ Grid 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
+ UHI-Corrected 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
+ WBGT (°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 threshold
327
+ </span>
328
+ </div>
329
+ </div>
330
+ </div>
331
+ )}
332
+ </div>
333
+ )
334
+ }
frontend/src/pages/Notifications.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import MetricCard from '../components/MetricCard'
2
+ import StatusBadge from '../components/StatusBadge'
3
+ import { LoadingSpinner, ErrorState } from '../components/LoadingState'
4
+ import { useNotifications } from '../lib/api'
5
+
6
+ const LANG_LABELS: Record<string, string> = {
7
+ en: 'English',
8
+ sw: 'Swahili',
9
+ }
10
+
11
+ export default function Notifications() {
12
+ const notifications = useNotifications()
13
+
14
+ if (notifications.isLoading) return <LoadingSpinner />
15
+ if (notifications.isError) return <ErrorState onRetry={() => notifications.refetch()} />
16
+
17
+ const data = notifications.data?.notifications ?? []
18
+ const byLang = notifications.data?.by_language ?? {}
19
+ const sentCount = data.filter((n) => n.status === 'sent').length
20
+ const channels = [...new Set(data.map((n) => n.channel))]
21
+ const deliveryRate = data.length > 0 ? Math.round((sentCount / data.length) * 100) : 0
22
+ const languages = Object.keys(byLang)
23
+
24
+ return (
25
+ <div className="animate-slide-up">
26
+ {/* Title */}
27
+ <div data-tour="alerts-title" className="pt-2 pb-6">
28
+ <h1 className="page-title">Heat Alerts</h1>
29
+ <p className="page-caption">
30
+ Worker heat alerts sent via SMS and WhatsApp in English and Swahili
31
+ </p>
32
+ </div>
33
+
34
+ {/* Metrics */}
35
+ <div data-tour="alerts-metrics" className="mb-8">
36
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-stagger">
37
+ <MetricCard
38
+ label="Total Sent"
39
+ value={sentCount}
40
+ subtitle={`of ${data.length} total`}
41
+ />
42
+ <MetricCard
43
+ label="Languages"
44
+ value={languages.length}
45
+ subtitle={languages.map((l) => LANG_LABELS[l] ?? l).join(', ')}
46
+ />
47
+ <MetricCard
48
+ label="Channels"
49
+ value={channels.length}
50
+ subtitle={channels.join(', ')}
51
+ />
52
+ <MetricCard
53
+ label="Delivery Rate"
54
+ value={`${deliveryRate}%`}
55
+ subtitle="of notifications sent"
56
+ />
57
+ </div>
58
+ </div>
59
+
60
+ {/* Notification Feed */}
61
+ <div data-tour="alerts-feed">
62
+ <div className="section-header">Alert Feed</div>
63
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
64
+ {data.map((n) => (
65
+ <div key={n.id} className="card card-body">
66
+ <div className="flex items-start justify-between mb-2">
67
+ <div>
68
+ <h3 className="text-sm font-semibold font-sans text-[#1a1a1a] m-0">
69
+ {n.zone_name}
70
+ </h3>
71
+ <p className="text-[10px] text-warm-muted m-0">{n.city}</p>
72
+ </div>
73
+ <div className="flex items-center gap-2">
74
+ <StatusBadge status={n.trigger_level} />
75
+ <span className="badge-blue">{LANG_LABELS[n.language] ?? n.language}</span>
76
+ </div>
77
+ </div>
78
+
79
+ <p className="text-xs text-warm-body leading-relaxed my-3 bg-warm-header-bg rounded-md p-3">
80
+ {n.message_preview}
81
+ </p>
82
+
83
+ <div className="flex items-center justify-between text-[10px] text-warm-muted">
84
+ <div className="flex items-center gap-3">
85
+ <span className="uppercase tracking-wider font-semibold">
86
+ {n.channel}
87
+ </span>
88
+ <span>{n.recipient_count.toLocaleString()} recipients</span>
89
+ </div>
90
+ <div className="flex items-center gap-2">
91
+ <StatusBadge status={n.status} />
92
+ <span>{new Date(n.delivered_at).toLocaleString()}</span>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ ))}
97
+ </div>
98
+ {data.length === 0 && (
99
+ <div className="text-center py-12 text-warm-muted text-sm font-sans">
100
+ No heat alerts sent yet.
101
+ </div>
102
+ )}
103
+ </div>
104
+ </div>
105
+ )
106
+ }
frontend/src/pages/Pipeline.tsx ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { ChevronDown, ChevronRight } from 'lucide-react'
3
+ import MetricCard from '../components/MetricCard'
4
+ import StatusBadge from '../components/StatusBadge'
5
+ import { LoadingSpinner, ErrorState } from '../components/LoadingState'
6
+ import { usePipelineRuns, usePipelineStats } from '../lib/api'
7
+
8
+ const STATUS_COLOR: Record<string, string> = {
9
+ ok: '#2a9d8f', success: '#2a9d8f', completed: '#2a9d8f',
10
+ partial: '#f4a261', running: '#1976D2',
11
+ failed: '#e63946', error: '#e63946',
12
+ }
13
+
14
+ const STEP_LABELS: Record<string, string> = {
15
+ ingest: 'Data Ingestion',
16
+ heal: 'Data Healing',
17
+ index: 'Heat Index Calculation',
18
+ calibrate: 'Trigger Calibration',
19
+ explain: 'Alert Generation',
20
+ notify: 'Worker Notification',
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Architecture Diagram
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const ARCH_STEPS = [
28
+ {
29
+ num: 1, name: 'Ingest', table: 'daily_readings', color: '#2E7D32',
30
+ desc: 'Fetch temperature and humidity from satellite sources',
31
+ options: [
32
+ { label: 'NASA POWER', note: 'T2M, T2M_MAX, RH2M — free, no auth', active: true },
33
+ { label: 'Custom Station Data', note: 'Your own weather stations or CSV files' },
34
+ ],
35
+ },
36
+ {
37
+ num: 2, name: 'Heal', table: 'healed_readings', color: '#1565C0',
38
+ desc: 'Validate and repair temperature anomalies',
39
+ options: [
40
+ { label: 'Claude AI Agent', note: '5 diagnostic tools, reasoning logged', active: true },
41
+ { label: 'Rule-Based', note: 'Zero-cost fallback, same output format' },
42
+ ],
43
+ },
44
+ {
45
+ num: 3, name: 'Index', table: 'heat_indices', color: '#7B1FA2',
46
+ desc: 'Calculate WBGT, Heat Index, consecutive day counts',
47
+ options: [
48
+ { label: 'WBGT (Liljegren)', note: 'Simplified outdoor wet bulb globe temperature', active: true },
49
+ { label: 'NWS Heat Index', note: 'Rothfusz regression, "feels like" temperature', active: true },
50
+ { label: 'Consecutive Days', note: 'Run-length above configurable threshold', active: true },
51
+ ],
52
+ },
53
+ {
54
+ num: 4, name: 'Calibrate', table: 'trigger_events', color: '#E65100',
55
+ desc: 'Detect trigger events and estimate basis risk',
56
+ options: [
57
+ { label: 'Composite Scoring', note: 'Temp 30% + WBGT 25% + Duration 20% + Vulnerability 15% + Exposure 10%', active: true },
58
+ { label: 'Basis Risk Model', note: 'Urban heat island, worker exposure simulation', active: true },
59
+ ],
60
+ },
61
+ {
62
+ num: 5, name: 'Explain', table: 'explanations', color: '#C62828',
63
+ desc: 'Generate plain-language heat alerts in English + Swahili',
64
+ options: [
65
+ { label: 'Claude + Knowledge Base', note: 'WHO/ILO heat guidance, zone-specific context', active: true },
66
+ { label: 'Template-Based', note: 'Zero-cost fallback with pre-written alerts' },
67
+ ],
68
+ },
69
+ {
70
+ num: 6, name: 'Notify', table: 'notifications', color: '#d4a019',
71
+ desc: 'Deliver alerts and payout notifications to enrolled workers',
72
+ options: [
73
+ { label: 'Console', note: 'Dry-run, always works', active: true },
74
+ { label: 'Twilio SMS', note: 'Live delivery to worker phones' },
75
+ { label: 'WhatsApp', note: 'Via Twilio Business API' },
76
+ ],
77
+ },
78
+ ]
79
+
80
+ function ArchitectureDiagram() {
81
+ return (
82
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0', paddingLeft: '20px' }}>
83
+ {ARCH_STEPS.map((s, i) => (
84
+ <div key={s.num}>
85
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: '14px' }}>
86
+ <div style={{
87
+ width: '36px', height: '36px', borderRadius: '50%', background: s.color,
88
+ color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
89
+ fontWeight: 700, fontSize: '0.85rem', flexShrink: 0, marginTop: '2px',
90
+ }}>
91
+ {s.num}
92
+ </div>
93
+ <div style={{
94
+ flex: 1, background: '#fff', border: '1px solid #e0dcd5', borderRadius: '8px',
95
+ padding: '14px 16px',
96
+ }}>
97
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginBottom: '4px' }}>
98
+ <span style={{ fontWeight: 700, fontSize: '0.9rem', color: s.color }}>{s.name}</span>
99
+ <code style={{
100
+ background: '#f0ede8', padding: '2px 8px', borderRadius: '4px',
101
+ fontSize: '0.72rem', color: '#555',
102
+ }}>{s.table}</code>
103
+ </div>
104
+ <div style={{ fontSize: '0.8rem', color: '#666', marginBottom: '8px' }}>{s.desc}</div>
105
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
106
+ {s.options.map(opt => (
107
+ <div key={opt.label} title={opt.note} style={{
108
+ background: opt.active ? `${s.color}10` : '#faf8f5',
109
+ border: `1px solid ${opt.active ? s.color + '40' : '#e0dcd5'}`,
110
+ borderRadius: '6px', padding: '4px 10px',
111
+ fontSize: '0.72rem', color: opt.active ? s.color : '#888',
112
+ fontWeight: opt.active ? 600 : 400,
113
+ cursor: 'default',
114
+ }}>
115
+ {opt.label}
116
+ {opt.active && <span style={{ marginLeft: '4px', fontSize: '0.65rem' }}>{'\u2713'}</span>}
117
+ </div>
118
+ ))}
119
+ </div>
120
+ </div>
121
+ </div>
122
+ {i < ARCH_STEPS.length - 1 && (
123
+ <div style={{
124
+ width: '2px', height: '14px', background: '#d4a019', marginLeft: '17px',
125
+ }} />
126
+ )}
127
+ </div>
128
+ ))}
129
+ </div>
130
+ )
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Build Your Own Tab
135
+ // ---------------------------------------------------------------------------
136
+
137
+ function BuildYourOwnTab() {
138
+ return (
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 pipeline is designed to be forked and adapted for any region. The architecture separates
143
+ globally portable components (NASA POWER ingestion, WBGT calculation, Claude healing, notification delivery)
144
+ from region-specific configuration (zone definitions, temperature thresholds, payout tiers, knowledge base).
145
+ </p>
146
+ <p style={{ fontSize: '0.85rem', color: '#888', lineHeight: 1.7 }}>
147
+ To adapt for a new region, you need to 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: 'Zone definitions: GPS coordinates, worker populations, heat vulnerability, payout tiers' },
155
+ { file: 'src/healing/healer.py', desc: 'Historical temperature norms for your cities (monthly averages, standard deviations)' },
156
+ { file: 'src/explanation/knowledge_base.py', desc: 'Local heat safety guidance, emergency contacts, zone-specific context, language translations' },
157
+ { file: 'src/calibration/basis_risk.py', desc: 'Urban heat island estimates, worker exposure patterns for your settlement types' },
158
+ ].map(item => (
159
+ <div key={item.file} style={{
160
+ background: '#fff', border: '1px solid #e0dcd5', borderRadius: '8px',
161
+ padding: '14px 16px',
162
+ }}>
163
+ <code style={{
164
+ background: '#f0ede8', padding: '2px 8px', borderRadius: '4px',
165
+ fontSize: '0.78rem', color: '#555', display: 'inline-block', marginBottom: '6px',
166
+ }}>{item.file}</code>
167
+ <p style={{ fontSize: '0.82rem', color: '#666', lineHeight: 1.5, margin: 0 }}>{item.desc}</p>
168
+ </div>
169
+ ))}
170
+ </div>
171
+
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 lat/lon coordinates' },
176
+ { file: 'src/indexing/heat_index.py', desc: 'WBGT and Heat Index calculations are physics-based, universally applicable' },
177
+ { file: 'src/indexing/heat_risk.py', desc: 'Composite scoring with parameterizable thresholds — configure via config.py' },
178
+ { file: 'src/notification/sender.py', desc: 'Console, SMS, or WhatsApp — plug in Twilio credentials' },
179
+ ].map(item => (
180
+ <div key={item.file} style={{
181
+ background: '#fff', border: '1px solid #e0dcd5', borderRadius: '8px',
182
+ padding: '14px 16px',
183
+ }}>
184
+ <code style={{
185
+ background: '#f0ede8', padding: '2px 8px', borderRadius: '4px',
186
+ fontSize: '0.78rem', color: '#2a9d8f', display: 'inline-block', marginBottom: '6px',
187
+ }}>{item.file}</code>
188
+ <p style={{ fontSize: '0.82rem', color: '#666', lineHeight: 1.5, margin: 0 }}>{item.desc}</p>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ )
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Main Component
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export default function Pipeline() {
201
+ const runs = usePipelineRuns()
202
+ const stats = usePipelineStats()
203
+ const [activeTab, setActiveTab] = useState<'architecture' | 'runs' | 'build'>('architecture')
204
+ const [expandedRun, setExpandedRun] = useState<string | null>(null)
205
+
206
+ if (runs.isLoading || stats.isLoading) return <LoadingSpinner />
207
+ if (runs.isError) return <ErrorState onRetry={() => runs.refetch()} />
208
+ if (stats.isError) return <ErrorState onRetry={() => stats.refetch()} />
209
+
210
+ const runsData = runs.data?.runs ?? []
211
+ const s = stats.data
212
+
213
+ const TABS = ['Architecture', 'Pipeline Runs', 'Build Your Own']
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">Pipeline</h1>
219
+ <p className="page-caption">
220
+ 6-step automated pipeline: ingest, heal, index, calibrate, explain, notify
221
+ </p>
222
+ </div>
223
+
224
+ {/* Stats */}
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 Runs"
229
+ value={s?.total_runs}
230
+ subtitle={`${s?.successful_runs} successful`}
231
+ />
232
+ <MetricCard
233
+ label="Success Rate"
234
+ value={`${Math.round((s?.success_rate ?? 0) * 100)}%`}
235
+ subtitle="pipeline completions"
236
+ />
237
+ <MetricCard
238
+ label="Total Cost"
239
+ value={`$${s?.total_cost_usd?.toFixed(2)}`}
240
+ subtitle={`$${s?.avg_cost_per_run_usd?.toFixed(4)} avg/run`}
241
+ />
242
+ <MetricCard
243
+ label="Data Sources"
244
+ value={s?.data_sources?.length}
245
+ subtitle={s?.data_sources?.join(', ')}
246
+ />
247
+ </div>
248
+ </div>
249
+
250
+ {/* Tabs */}
251
+ <div className="tab-list mb-6">
252
+ {TABS.map((tab, i) => (
253
+ <button
254
+ key={tab}
255
+ className={`tab-item ${activeTab === (['architecture', 'runs', 'build'] as const)[i] ? 'active' : ''}`}
256
+ onClick={() => setActiveTab((['architecture', 'runs', 'build'] as const)[i])}
257
+ >
258
+ {tab}
259
+ </button>
260
+ ))}
261
+ </div>
262
+
263
+ {/* Architecture Tab */}
264
+ {activeTab === 'architecture' && (
265
+ <div className="animate-tab-enter">
266
+ <ArchitectureDiagram />
267
+ </div>
268
+ )}
269
+
270
+ {/* Pipeline Runs Tab */}
271
+ {activeTab === 'runs' && (
272
+ <div className="animate-tab-enter space-y-6">
273
+ {/* Cost Summary */}
274
+ <div className="card card-body">
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 Pipeline Cost
279
+ </p>
280
+ <p className="text-lg font-serif font-bold text-[#1a1a1a]">
281
+ ${s?.total_cost_usd?.toFixed(2)}
282
+ </p>
283
+ </div>
284
+ <div>
285
+ <p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-1">
286
+ Average per Run
287
+ </p>
288
+ <p className="text-lg font-serif font-bold text-[#1a1a1a]">
289
+ ${s?.avg_cost_per_run_usd?.toFixed(4)}
290
+ </p>
291
+ </div>
292
+ <div>
293
+ <p className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider mb-1">
294
+ Last Run
295
+ </p>
296
+ <p className="text-lg font-serif font-bold text-[#1a1a1a]">
297
+ {s?.last_run ? new Date(s.last_run).toLocaleDateString() : '--'}
298
+ </p>
299
+ </div>
300
+ </div>
301
+ </div>
302
+
303
+ {/* Run History */}
304
+ <div className="section-header">Run History</div>
305
+ <div className="table-container">
306
+ <table>
307
+ <thead>
308
+ <tr>
309
+ <th className="w-8"></th>
310
+ <th>Run ID</th>
311
+ <th>Date</th>
312
+ <th>Status</th>
313
+ <th>Duration</th>
314
+ <th>Zones</th>
315
+ <th>Triggers</th>
316
+ <th>Notifications</th>
317
+ <th>Cost (USD)</th>
318
+ </tr>
319
+ </thead>
320
+ {runsData.map((run) => (
321
+ <tbody key={run.run_id}>
322
+ <tr
323
+ className="cursor-pointer"
324
+ onClick={() => setExpandedRun(expandedRun === run.run_id ? null : run.run_id)}
325
+ >
326
+ <td>
327
+ {expandedRun === run.run_id ? (
328
+ <ChevronDown size={14} className="text-warm-muted" />
329
+ ) : (
330
+ <ChevronRight size={14} className="text-warm-muted" />
331
+ )}
332
+ </td>
333
+ <td className="font-mono text-xs">{run.run_id}</td>
334
+ <td>{new Date(run.started_at).toLocaleDateString()}</td>
335
+ <td><StatusBadge status={run.status} /></td>
336
+ <td>{run.duration_s.toFixed(0)}s</td>
337
+ <td>{run.zones_processed}</td>
338
+ <td>{run.triggers_found}</td>
339
+ <td>{run.notifications_sent}</td>
340
+ <td>${run.total_cost_usd.toFixed(4)}</td>
341
+ </tr>
342
+ {expandedRun === run.run_id && (
343
+ <tr>
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
+ Pipeline 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) => (
351
+ <div
352
+ key={step.step}
353
+ className="bg-white rounded-lg border border-warm-border p-3 text-center"
354
+ >
355
+ <div className="text-[10px] text-warm-muted uppercase tracking-wider font-semibold mb-1">
356
+ Step {i + 1}
357
+ </div>
358
+ <div className="text-xs font-medium text-[#1a1a1a] mb-1">
359
+ {STEP_LABELS[step.step] ?? step.step}
360
+ </div>
361
+ <StatusBadge status={step.status} />
362
+ <div className="text-[10px] text-warm-muted mt-1">
363
+ {step.duration_s.toFixed(1)}s
364
+ </div>
365
+ </div>
366
+ ))}
367
+ </div>
368
+ </div>
369
+ </td>
370
+ </tr>
371
+ )}
372
+ </tbody>
373
+ ))}
374
+ </table>
375
+ </div>
376
+ </div>
377
+ )}
378
+
379
+ {/* Build Your Own Tab */}
380
+ {activeTab === 'build' && (
381
+ <div className="animate-tab-enter">
382
+ <BuildYourOwnTab />
383
+ </div>
384
+ )}
385
+ </div>
386
+ )
387
+ }
frontend/src/pages/ProgramDesigner.tsx ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import MetricCard from '../components/MetricCard'
3
+ import { LoadingSpinner, ErrorState } from '../components/LoadingState'
4
+ import { useCalibrateQuery } from '../lib/api'
5
+ import type { CalibrateParams } from '../lib/api'
6
+
7
+ function formatUsd(n: number | undefined): string {
8
+ if (n == null) return '--'
9
+ return '$' + n.toLocaleString(undefined, { maximumFractionDigits: 0 })
10
+ }
11
+
12
+ function rankColor(rank: number): string {
13
+ if (rank <= 5) return '#2a9d8f'
14
+ if (rank <= 10) return '#d4a019'
15
+ return '#888'
16
+ }
17
+
18
+ export default function ProgramDesigner() {
19
+ const [params, setParams] = useState<CalibrateParams>({
20
+ temp_threshold: 35,
21
+ consecutive_days: 2,
22
+ wbgt_threshold: 30,
23
+ payout_usd: 10,
24
+ budget_usd: 500000,
25
+ worker_contribution_usd: 0,
26
+ })
27
+
28
+ // Debounced params for API calls
29
+ const [debouncedParams, setDebouncedParams] = useState<CalibrateParams>(params)
30
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
31
+
32
+ useEffect(() => {
33
+ if (debounceTimer.current) clearTimeout(debounceTimer.current)
34
+ debounceTimer.current = setTimeout(() => {
35
+ setDebouncedParams(params)
36
+ }, 300)
37
+ return () => {
38
+ if (debounceTimer.current) clearTimeout(debounceTimer.current)
39
+ }
40
+ }, [params])
41
+
42
+ const calibrate = useCalibrateQuery(debouncedParams)
43
+
44
+ const updateParam = <K extends keyof CalibrateParams>(key: K, value: CalibrateParams[K]) => {
45
+ setParams((prev) => ({ ...prev, [key]: value }))
46
+ }
47
+
48
+ const summary = calibrate.data?.summary
49
+ const allocation = calibrate.data?.allocation
50
+ const zones = calibrate.data?.zones ?? []
51
+ const sortedZones = [...zones].sort((a, b) => (a.priority_rank ?? 99) - (b.priority_rank ?? 99))
52
+
53
+ return (
54
+ <div className="animate-slide-up">
55
+ {/* Title */}
56
+ <div className="pt-2 pb-6">
57
+ <h1 className="page-title">Program Design</h1>
58
+ <p className="page-caption">
59
+ Set budget, payout, and thresholds. The model prices each zone actuarially and allocates budget by priority.
60
+ </p>
61
+ </div>
62
+
63
+ {/* Controls */}
64
+ <div data-tour="program-controls" className="card card-body mb-6">
65
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
66
+ {/* Budget */}
67
+ <div>
68
+ <div className="flex items-center justify-between mb-1.5">
69
+ <label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
70
+ Total Budget
71
+ </label>
72
+ <span className="font-mono font-semibold text-sm text-[#1a1a1a]">
73
+ {formatUsd(params.budget_usd)}
74
+ </span>
75
+ </div>
76
+ <input
77
+ type="range"
78
+ min={100000}
79
+ max={5000000}
80
+ step={50000}
81
+ value={params.budget_usd}
82
+ onChange={(e) => updateParam('budget_usd', Number(e.target.value))}
83
+ className="w-full h-1.5 bg-warm-border rounded-lg appearance-none cursor-pointer accent-gold"
84
+ />
85
+ <div className="flex justify-between text-[10px] text-warm-muted mt-1">
86
+ <span>$100K</span>
87
+ <span>$5M</span>
88
+ </div>
89
+ </div>
90
+
91
+ {/* Payout per event */}
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 / Event
96
+ </label>
97
+ <span className="font-mono font-semibold text-sm text-[#1a1a1a]">
98
+ ${params.payout_usd}
99
+ </span>
100
+ </div>
101
+ <input
102
+ type="range"
103
+ min={1}
104
+ max={50}
105
+ step={1}
106
+ value={params.payout_usd}
107
+ onChange={(e) => updateParam('payout_usd', Number(e.target.value))}
108
+ className="w-full h-1.5 bg-warm-border rounded-lg appearance-none cursor-pointer accent-gold"
109
+ />
110
+ <div className="flex justify-between text-[10px] text-warm-muted mt-1">
111
+ <span>$1</span>
112
+ <span>$50</span>
113
+ </div>
114
+ </div>
115
+
116
+ {/* Worker contribution */}
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 Contrib.
121
+ </label>
122
+ <span className="font-mono font-semibold text-sm text-[#1a1a1a]">
123
+ ${params.worker_contribution_usd}
124
+ </span>
125
+ </div>
126
+ <input
127
+ type="range"
128
+ min={0}
129
+ max={20}
130
+ step={1}
131
+ value={params.worker_contribution_usd}
132
+ onChange={(e) => updateParam('worker_contribution_usd', Number(e.target.value))}
133
+ className="w-full h-1.5 bg-warm-border rounded-lg appearance-none cursor-pointer accent-gold"
134
+ />
135
+ <div className="flex justify-between text-[10px] text-warm-muted mt-1">
136
+ <span>$0</span>
137
+ <span>$20</span>
138
+ </div>
139
+ </div>
140
+
141
+ {/* Temperature threshold */}
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
+ Temp Threshold
146
+ </label>
147
+ <span className="font-mono font-semibold text-sm text-[#1a1a1a]">
148
+ {params.temp_threshold}°C
149
+ </span>
150
+ </div>
151
+ <input
152
+ type="range"
153
+ min={30}
154
+ max={45}
155
+ step={1}
156
+ value={params.temp_threshold}
157
+ onChange={(e) => updateParam('temp_threshold', Number(e.target.value))}
158
+ className="w-full h-1.5 bg-warm-border rounded-lg appearance-none cursor-pointer accent-gold"
159
+ />
160
+ <div className="flex justify-between text-[10px] text-warm-muted mt-1">
161
+ <span>30°C</span>
162
+ <span>45°C</span>
163
+ </div>
164
+ </div>
165
+
166
+ {/* Consecutive days */}
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
+ Consec. Days
171
+ </label>
172
+ <span className="font-mono font-semibold text-sm text-[#1a1a1a]">
173
+ {params.consecutive_days}
174
+ </span>
175
+ </div>
176
+ <input
177
+ type="range"
178
+ min={1}
179
+ max={7}
180
+ step={1}
181
+ value={params.consecutive_days}
182
+ onChange={(e) => updateParam('consecutive_days', Number(e.target.value))}
183
+ className="w-full h-1.5 bg-warm-border rounded-lg appearance-none cursor-pointer accent-gold"
184
+ />
185
+ <div className="flex justify-between text-[10px] text-warm-muted mt-1">
186
+ <span>1 day</span>
187
+ <span>7 days</span>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ {/* Results */}
194
+ <div data-tour="program-results">
195
+ {calibrate.isLoading ? (
196
+ <LoadingSpinner message="Running actuarial model..." />
197
+ ) : calibrate.isError ? (
198
+ <ErrorState onRetry={() => calibrate.refetch()} />
199
+ ) : (
200
+ <>
201
+ {/* Allocation Summary */}
202
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 animate-stagger">
203
+ <MetricCard
204
+ label="Workers Covered"
205
+ value={allocation?.workers_covered?.toLocaleString() ?? '--'}
206
+ subtitle={`of ${summary?.total_enrolled?.toLocaleString() ?? '--'} enrolled`}
207
+ />
208
+ <MetricCard
209
+ label="Coverage"
210
+ value={`${allocation?.overall_coverage_pct?.toFixed(1) ?? '--'}%`}
211
+ subtitle="of enrolled workers"
212
+ />
213
+ <MetricCard
214
+ label="Zones Funded"
215
+ value={`${(allocation?.zones_fully_funded ?? 0) + (allocation?.zones_partially_funded ?? 0)}`}
216
+ subtitle={`${allocation?.zones_fully_funded ?? 0} full + ${allocation?.zones_partially_funded ?? 0} partial`}
217
+ />
218
+ <MetricCard
219
+ label="Avg Cost / Worker"
220
+ value={`$${summary?.avg_cost_per_worker?.toFixed(2) ?? '--'}`}
221
+ subtitle="annual actuarial"
222
+ />
223
+ </div>
224
+
225
+ {/* Zone Allocation Table */}
226
+ <div className="table-container">
227
+ <table>
228
+ <thead>
229
+ <tr>
230
+ <th>Rank</th>
231
+ <th>Zone</th>
232
+ <th>City</th>
233
+ <th>Actuarial Cost/Worker</th>
234
+ <th>Allocated Budget</th>
235
+ <th>Workers Covered</th>
236
+ <th>Coverage %</th>
237
+ <th>Events/Year</th>
238
+ <th>Basis Risk</th>
239
+ </tr>
240
+ </thead>
241
+ <tbody>
242
+ {sortedZones.map((z) => {
243
+ const unfunded = (z.allocated_budget ?? 0) === 0
244
+ return (
245
+ <tr key={z.zone_id} style={unfunded ? { opacity: 0.45 } : undefined}>
246
+ <td>
247
+ <span
248
+ className="inline-flex items-center justify-center w-6 h-6 rounded-full text-[11px] font-mono font-bold text-white"
249
+ style={{ backgroundColor: rankColor(z.priority_rank ?? 99) }}
250
+ >
251
+ {z.priority_rank ?? '--'}
252
+ </span>
253
+ </td>
254
+ <td className="font-medium text-[#1a1a1a]">{z.zone_name}</td>
255
+ <td>{z.city}</td>
256
+ <td className="font-mono text-sm">${z.actuarial_cost_per_worker?.toFixed(2) ?? '--'}</td>
257
+ <td className="font-mono text-sm font-semibold">{formatUsd(z.allocated_budget)}</td>
258
+ <td className="font-mono text-sm">{z.workers_covered?.toLocaleString() ?? '0'}</td>
259
+ <td>
260
+ <span
261
+ className="font-mono font-semibold text-sm"
262
+ style={{
263
+ color: (z.coverage_pct ?? 0) >= 80 ? '#2a9d8f' : (z.coverage_pct ?? 0) >= 40 ? '#d4a019' : '#e63946',
264
+ }}
265
+ >
266
+ {z.coverage_pct?.toFixed(1) ?? '0'}%
267
+ </span>
268
+ </td>
269
+ <td className="font-mono text-sm">{z.events_per_year?.toFixed(1)}</td>
270
+ <td>
271
+ <span
272
+ className="font-mono font-semibold text-sm"
273
+ style={{
274
+ color: z.basis_risk_score > 0.3 ? '#e63946' : z.basis_risk_score > 0.2 ? '#e67e22' : '#2a9d8f',
275
+ }}
276
+ >
277
+ {z.basis_risk_score?.toFixed(3)}
278
+ </span>
279
+ </td>
280
+ </tr>
281
+ )
282
+ })}
283
+ </tbody>
284
+ </table>
285
+ </div>
286
+ {sortedZones.length === 0 && (
287
+ <div className="text-center py-12 text-warm-muted text-sm font-sans">
288
+ No zones triggered at current thresholds. Try lowering the temperature threshold.
289
+ </div>
290
+ )}
291
+ </>
292
+ )}
293
+ </div>
294
+ </div>
295
+ )
296
+ }
frontend/src/pages/Zones.tsx ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import MetricCard from '../components/MetricCard'
3
+ import StatusBadge from '../components/StatusBadge'
4
+ import { LoadingSpinner, ErrorState } from '../components/LoadingState'
5
+ import { useZones, useEnrolled } from '../lib/api'
6
+
7
+ function tempColor(temp: number): string {
8
+ if (temp > 37) return '#e63946'
9
+ if (temp > 35) return '#d35400'
10
+ if (temp > 33) return '#e67e22'
11
+ if (temp > 30) return '#d4a019'
12
+ return '#2a9d8f'
13
+ }
14
+
15
+ function wbgtColor(wbgt: number): string {
16
+ if (wbgt > 32) return '#e63946'
17
+ if (wbgt > 30) return '#e67e22'
18
+ if (wbgt > 28) return '#d4a019'
19
+ return '#2a9d8f'
20
+ }
21
+
22
+ export default function Zones() {
23
+ const zones = useZones()
24
+ const enrolled = useEnrolled()
25
+ const [cityFilter, setCityFilter] = useState<string>('all')
26
+ const [activeTab, setActiveTab] = useState<'zones' | 'exposure'>('zones')
27
+
28
+ if (zones.isLoading) return <LoadingSpinner />
29
+ if (zones.isError) return <ErrorState onRetry={() => zones.refetch()} />
30
+
31
+ const cities = zones.data?.cities ?? []
32
+ const allZones = zones.data?.zones ?? []
33
+ const filtered = cityFilter === 'all' ? allZones : allZones.filter((z) => z.city === cityFilter)
34
+ const totalWorkers = allZones.reduce((sum, z) => sum + z.worker_population_est, 0)
35
+
36
+ const enrolledByZone = enrolled.data?.by_zone ?? []
37
+
38
+ return (
39
+ <div className="animate-slide-up">
40
+ {/* Title */}
41
+ <div data-tour="zones-title" className="pt-2 pb-6">
42
+ <h1 className="page-title">Zones</h1>
43
+ <p className="page-caption">
44
+ 20 heat-vulnerable urban zones across East African cities
45
+ </p>
46
+ </div>
47
+
48
+ {/* Metrics */}
49
+ <div data-tour="zones-metrics" className="mb-8">
50
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-stagger">
51
+ <MetricCard
52
+ label="Zones Monitored"
53
+ value={allZones.length}
54
+ subtitle={`${cities.length} cities`}
55
+ />
56
+ <MetricCard
57
+ label="Total Workers"
58
+ value={totalWorkers.toLocaleString()}
59
+ subtitle="estimated outdoor workers"
60
+ />
61
+ <MetricCard
62
+ label="High Vulnerability"
63
+ value={allZones.filter((z) => z.heat_vulnerability === 'high' || z.heat_vulnerability === 'critical').length}
64
+ subtitle="zones at elevated risk"
65
+ />
66
+ <MetricCard
67
+ label="Workers Enrolled"
68
+ value={enrolled.data?.total_enrolled?.toLocaleString() ?? '--'}
69
+ subtitle="across all zones"
70
+ />
71
+ </div>
72
+ </div>
73
+
74
+ {/* Tabs */}
75
+ <div className="mb-6">
76
+ <div className="tab-list">
77
+ <button
78
+ className={`tab-item ${activeTab === 'zones' ? 'active' : ''}`}
79
+ onClick={() => setActiveTab('zones')}
80
+ >
81
+ Zone Overview
82
+ </button>
83
+ <button
84
+ className={`tab-item ${activeTab === 'exposure' ? 'active' : ''}`}
85
+ onClick={() => setActiveTab('exposure')}
86
+ >
87
+ Worker Exposure
88
+ </button>
89
+ </div>
90
+ </div>
91
+
92
+ {activeTab === 'zones' && (
93
+ <div className="animate-tab-enter">
94
+ {/* City filter */}
95
+ <div className="mb-4 flex items-center gap-3">
96
+ <label className="text-xs text-warm-muted font-sans font-medium uppercase tracking-wider">
97
+ Filter by city
98
+ </label>
99
+ <select
100
+ value={cityFilter}
101
+ onChange={(e) => setCityFilter(e.target.value)}
102
+ className="input max-w-xs text-sm"
103
+ >
104
+ <option value="all">All Cities ({allZones.length})</option>
105
+ {cities.map((c) => (
106
+ <option key={c} value={c}>
107
+ {c} ({allZones.filter((z) => z.city === c).length})
108
+ </option>
109
+ ))}
110
+ </select>
111
+ </div>
112
+
113
+ {/* Zone table */}
114
+ <div className="table-container">
115
+ <table>
116
+ <thead>
117
+ <tr>
118
+ <th>Zone</th>
119
+ <th>City</th>
120
+ <th>Settlement</th>
121
+ <th>Heat Vulnerability</th>
122
+ <th>Workers</th>
123
+ <th>Grid Temp</th>
124
+ <th>UHI</th>
125
+ <th>Corrected</th>
126
+ <th>WBGT (°C)</th>
127
+ <th>7d Trigger</th>
128
+ <th>Risk Level</th>
129
+ </tr>
130
+ </thead>
131
+ <tbody>
132
+ {filtered.map((z) => (
133
+ <tr key={z.zone_id}>
134
+ <td className="font-medium text-[#1a1a1a]">{z.name}</td>
135
+ <td>{z.city}</td>
136
+ <td className="capitalize">{z.settlement_type}</td>
137
+ <td><StatusBadge status={z.heat_vulnerability} /></td>
138
+ <td>{z.worker_population_est.toLocaleString()}</td>
139
+ <td>
140
+ <span className="font-mono text-sm text-warm-muted">
141
+ {z.grid_temp_c?.toFixed(1)}°C
142
+ </span>
143
+ </td>
144
+ <td>
145
+ <span
146
+ className="font-mono font-semibold text-sm"
147
+ style={{ color: (z.uhi_delta_c ?? 0) > 4 ? '#e63946' : (z.uhi_delta_c ?? 0) > 2 ? '#e67e22' : '#d4a019' }}
148
+ >
149
+ +{z.uhi_delta_c?.toFixed(1)}°C
150
+ </span>
151
+ </td>
152
+ <td>
153
+ <span
154
+ className="font-mono font-semibold text-sm"
155
+ style={{ color: tempColor(z.corrected_temp_c ?? 0) }}
156
+ >
157
+ {z.corrected_temp_c?.toFixed(1)}°C
158
+ </span>
159
+ </td>
160
+ <td>
161
+ <span
162
+ className="font-mono font-semibold text-sm"
163
+ style={{ color: wbgtColor(z.current_wbgt_c) }}
164
+ >
165
+ {z.current_wbgt_c.toFixed(1)}
166
+ </span>
167
+ </td>
168
+ <td>
169
+ {(() => {
170
+ const pct = (z.trigger_probability_7d ?? 0) * 100
171
+ const color = pct > 70 ? '#e63946' : pct > 50 ? '#e67e22' : pct > 20 ? '#d4a019' : '#2a9d8f'
172
+ return (
173
+ <div className="flex items-center gap-2">
174
+ <div className="w-16 h-2 rounded-full bg-warm-border overflow-hidden">
175
+ <div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: color }} />
176
+ </div>
177
+ <span className="font-mono font-semibold text-sm" style={{ color }}>
178
+ {pct?.toFixed(0)}%
179
+ </span>
180
+ </div>
181
+ )
182
+ })()}
183
+ </td>
184
+ <td><StatusBadge status={z.risk_level} /></td>
185
+ </tr>
186
+ ))}
187
+ </tbody>
188
+ </table>
189
+ </div>
190
+ </div>
191
+ )}
192
+
193
+ {activeTab === 'exposure' && (
194
+ <div className="animate-tab-enter">
195
+ {enrolled.isLoading ? (
196
+ <LoadingSpinner message="Loading enrollment data..." />
197
+ ) : enrolled.isError ? (
198
+ <ErrorState onRetry={() => enrolled.refetch()} />
199
+ ) : (
200
+ <div className="table-container">
201
+ <table>
202
+ <thead>
203
+ <tr>
204
+ <th>Zone</th>
205
+ <th>City</th>
206
+ <th>Worker Population</th>
207
+ <th>Outdoor Exposure</th>
208
+ <th>Heat Vulnerability</th>
209
+ <th>Enrolled Workers</th>
210
+ <th>Enrollment Rate</th>
211
+ </tr>
212
+ </thead>
213
+ <tbody>
214
+ {allZones.map((z) => {
215
+ const enrolledZone = enrolledByZone.find((e) => e.zone_id === z.zone_id)
216
+ const enrolledCount = enrolledZone?.enrolled ?? 0
217
+ const enrollmentRate = z.worker_population_est > 0
218
+ ? Math.round((enrolledCount / z.worker_population_est) * 100)
219
+ : 0
220
+ return (
221
+ <tr key={z.zone_id}>
222
+ <td className="font-medium text-[#1a1a1a]">{z.name}</td>
223
+ <td>{z.city}</td>
224
+ <td>{z.worker_population_est.toLocaleString()}</td>
225
+ <td>{z.outdoor_exposure_pct}%</td>
226
+ <td><StatusBadge status={z.heat_vulnerability} /></td>
227
+ <td>{enrolledCount.toLocaleString()}</td>
228
+ <td>
229
+ <span className={`font-mono text-sm font-semibold ${enrollmentRate < 20 ? 'text-[#e63946]' : enrollmentRate < 50 ? 'text-[#e67e22]' : 'text-[#2a9d8f]'}`}>
230
+ {enrollmentRate}%
231
+ </span>
232
+ </td>
233
+ </tr>
234
+ )
235
+ })}
236
+ </tbody>
237
+ </table>
238
+ </div>
239
+ )}
240
+ </div>
241
+ )}
242
+ </div>
243
+ )
244
+ }
frontend/src/vite-env.d.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly VITE_API_URL: string
5
+ }
6
+
7
+ interface ImportMeta {
8
+ readonly env: ImportMetaEnv
9
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ theme: {
8
+ extend: {
9
+ colors: {
10
+ cream: '#faf8f5',
11
+ gold: {
12
+ DEFAULT: '#d4a019',
13
+ hover: '#b8880f',
14
+ light: '#f0d97a',
15
+ dark: '#a67c10',
16
+ },
17
+ warm: {
18
+ border: '#e0dcd5',
19
+ muted: '#888888',
20
+ 'muted-light': '#999999',
21
+ body: '#555555',
22
+ 'body-light': '#666666',
23
+ 'header-bg': '#f5f3ef',
24
+ },
25
+ sidebar: {
26
+ DEFAULT: '#1a1a1a',
27
+ end: '#222018',
28
+ hover: '#2a2a3e',
29
+ },
30
+ success: '#2a9d8f',
31
+ warning: '#d4a019',
32
+ error: '#e63946',
33
+ info: '#1565C0',
34
+ },
35
+ fontFamily: {
36
+ serif: ['"Source Serif 4"', 'Georgia', 'serif'],
37
+ sans: ['"DM Sans"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
38
+ },
39
+ borderRadius: {
40
+ DEFAULT: '10px',
41
+ },
42
+ letterSpacing: {
43
+ 'section': '1.5px',
44
+ 'label': '1.2px',
45
+ 'btn': '0.5px',
46
+ },
47
+ },
48
+ },
49
+ plugins: [],
50
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": false,
16
+ "noUnusedParameters": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "forceConsistentCasingInFileNames": true,
19
+ "resolveJsonModule": true,
20
+ "esModuleInterop": true
21
+ },
22
+ "include": ["src"]
23
+ }
frontend/tsconfig.tsbuildinfo ADDED
@@ -0,0 +1 @@
 
 
1
+ {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/Layout.tsx","./src/components/LoadingState.tsx","./src/components/MetricCard.tsx","./src/components/Sidebar.tsx","./src/components/StatusBadge.tsx","./src/lib/api.ts","./src/lib/tour.ts","./src/pages/Dashboard.tsx","./src/pages/HeatMonitor.tsx","./src/pages/Notifications.tsx","./src/pages/Pipeline.tsx","./src/pages/ProgramDesigner.tsx","./src/pages/Zones.tsx"],"version":"5.6.3"}
frontend/vercel.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "buildCommand": "npm run build",
3
+ "outputDirectory": "dist",
4
+ "framework": "vite",
5
+ "rewrites": [
6
+ { "source": "/((?!api/).*)", "destination": "/index.html" }
7
+ ]
8
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ proxy: {
9
+ '/api': {
10
+ target: 'http://localhost:7860',
11
+ changeOrigin: true,
12
+ },
13
+ '/health': {
14
+ target: 'http://localhost:7860',
15
+ changeOrigin: true,
16
+ },
17
+ },
18
+ },
19
+ })
models/heat_predictor_xgb.json ADDED
The diff for this file is too large to render. See raw diff
 
models/uhi_xgb.json ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ httpx>=0.27.0
4
+ anthropic>=0.40.0
5
+ scipy>=1.12.0
6
+ numpy>=1.26.0
7
+ xgboost>=2.0.0
8
+ asyncpg>=0.30.0
9
+ aiofiles>=24.1.0
run_pipeline.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Climate Risk Index Engine — CLI Runner
4
+
5
+ Usage:
6
+ python run_pipeline.py # Full pipeline, console delivery
7
+ python run_pipeline.py --days 30 # Last 30 days only
8
+ python run_pipeline.py --no-claude # Rule-based healing, template explanations
9
+ python run_pipeline.py --channel sms # SMS delivery via Twilio
10
+ """
11
+
12
+ import argparse
13
+ import asyncio
14
+ import logging
15
+ import json
16
+ import sys
17
+ from datetime import datetime
18
+
19
+ from src.pipeline import FloodRiskPipeline
20
+ from src.store import store
21
+
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
25
+ datefmt="%H:%M:%S",
26
+ )
27
+
28
+
29
+ def main():
30
+ parser = argparse.ArgumentParser(description="Climate Risk Index Engine")
31
+ parser.add_argument("--days", type=int, default=90, help="Days of data to fetch (default: 90)")
32
+ parser.add_argument("--no-claude", action="store_true", help="Use rule-based healing and template explanations")
33
+ parser.add_argument("--channel", default="console", choices=["console", "sms", "whatsapp"], help="Delivery channel")
34
+ parser.add_argument("--json", action="store_true", help="Output results as JSON")
35
+ args = parser.parse_args()
36
+
37
+ pipeline = FloodRiskPipeline(
38
+ days_back=args.days,
39
+ use_claude_healer=not args.no_claude,
40
+ use_claude_explainer=not args.no_claude,
41
+ delivery_channel=args.channel,
42
+ )
43
+
44
+ result = asyncio.run(pipeline.run())
45
+
46
+ # Populate the shared store so the API can serve real data
47
+ store.update_from_pipeline(pipeline, run_result=result)
48
+
49
+ if args.json:
50
+ print(json.dumps({
51
+ "run_id": result.run_id,
52
+ "status": result.status,
53
+ "started_at": result.started_at,
54
+ "ended_at": result.ended_at,
55
+ "duration_s": result.duration_s,
56
+ "zones_processed": result.zones_processed,
57
+ "triggers_found": result.triggers_found,
58
+ "notifications_sent": result.notifications_sent,
59
+ "total_cost_usd": result.total_cost_usd,
60
+ "steps": [
61
+ {
62
+ "step": s.step,
63
+ "status": s.status,
64
+ "duration_s": round(s.duration_s, 2),
65
+ "records": s.records_processed,
66
+ "errors": s.errors,
67
+ }
68
+ for s in result.steps
69
+ ],
70
+ }, indent=2))
71
+ else:
72
+ print(f"\n{'='*60}")
73
+ print(f"Pipeline Run: {result.run_id}")
74
+ print(f"Status: {result.status.upper()}")
75
+ print(f"Duration: {result.duration_s:.1f}s")
76
+ print(f"Zones: {result.zones_processed}")
77
+ print(f"Triggers: {result.triggers_found}")
78
+ print(f"Notifications: {result.notifications_sent}")
79
+ print(f"Cost: ${result.total_cost_usd:.4f}")
80
+ print(f"{'='*60}")
81
+ for s in result.steps:
82
+ icon = "✓" if s.status == "ok" else "⚠" if s.status == "partial" else "○" if s.status == "skipped" else "✗"
83
+ print(f" {icon} {s.step:<12} {s.status:<8} {s.duration_s:>6.1f}s ({s.records_processed} records)")
84
+ if s.errors:
85
+ for e in s.errors[:3]:
86
+ print(f" └─ {e[:80]}")
87
+ print()
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
src/__init__.py ADDED
File without changes
src/api.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extreme Heat Risk Engine — FastAPI Application
3
+
4
+ Serves synthetic demo data for the dashboard.
5
+ When the real pipeline has been run, serves pipeline results instead.
6
+ """
7
+
8
+ import logging
9
+ import random
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ from fastapi import FastAPI
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.staticfiles import StaticFiles
16
+ from fastapi.responses import FileResponse
17
+
18
+ from config import ZONES, ZONE_MAP, CITIES, HEAT_THRESHOLDS, PAYOUT_PER_EVENT_USD
19
+ from src.indexing.heat_index import calculate_wbgt, calculate_heat_index, count_consecutive_days, count_trigger_days
20
+ 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
+ app = FastAPI(title="Extreme Heat Risk Engine", version="1.0.0")
28
+
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=["*"],
32
+ allow_credentials=True,
33
+ allow_methods=["*"],
34
+ allow_headers=["*"],
35
+ )
36
+
37
+ SEED = 42
38
+
39
+
40
+ def _generate_demo_data():
41
+ """Deterministic synthetic data for the dashboard demo, using real ML models."""
42
+ rng = random.Random(SEED)
43
+ now = datetime(2026, 3, 29, 10, 0, 0)
44
+
45
+ # Initialize ML models
46
+ uhi_corrector = UHICorrector()
47
+ predictor = HeatWavePredictor()
48
+ pricer = ActuarialPricer()
49
+
50
+ # City base temperatures (ERA5-Land grid-level — before UHI correction)
51
+ city_climate = {
52
+ "Dar es Salaam": {"base_temp": 31, "temp_var": 2.5, "base_hum": 78, "hum_var": 8},
53
+ "Kampala": {"base_temp": 28, "temp_var": 2.5, "base_hum": 68, "hum_var": 10},
54
+ "Nairobi": {"base_temp": 25, "temp_var": 2.5, "base_hum": 55, "hum_var": 12},
55
+ "Kigali": {"base_temp": 25, "temp_var": 2, "base_hum": 60, "hum_var": 10},
56
+ }
57
+
58
+ # Generate 90 days of daily data per zone
59
+ zones = []
60
+ indices = []
61
+ all_triggers = []
62
+ tid = 1
63
+
64
+ for z in ZONES:
65
+ clim = city_climate[z.city]
66
+
67
+ daily_grid_temps = []
68
+ daily_temps = []
69
+ daily_humidity = []
70
+ daily_dates = []
71
+ daily_wbgt = []
72
+ daily_hi = []
73
+ daily_uhi_deltas = []
74
+
75
+ for d in range(90):
76
+ date = now - timedelta(days=89 - d)
77
+ month = date.month
78
+ seasonal = 1.5 if month in z.hot_months else -0.5
79
+ # Grid-level temperature (ERA5-Land equivalent — before UHI)
80
+ grid_temp = clim["base_temp"] + seasonal + rng.gauss(0, clim["temp_var"] * 0.4)
81
+ grid_temp = round(max(18, min(42, grid_temp)), 1)
82
+ hum = clim["base_hum"] + rng.gauss(0, clim["hum_var"] * 0.3)
83
+ hum = round(max(30, min(95, hum)), 1)
84
+ # ML UHI correction
85
+ corrected, uhi_delta, _ = uhi_corrector.correct_temperature(z, grid_temp, hour=14, month=month)
86
+ temp = round(corrected, 1)
87
+ daily_grid_temps.append(grid_temp)
88
+ wbgt = calculate_wbgt(temp, hum)
89
+ hi = calculate_heat_index(temp, hum)
90
+
91
+ daily_temps.append(temp)
92
+ daily_humidity.append(hum)
93
+ daily_dates.append(date.strftime("%Y-%m-%d"))
94
+ daily_wbgt.append(wbgt)
95
+ daily_hi.append(hi)
96
+ daily_uhi_deltas.append(round(uhi_delta, 1))
97
+
98
+ # ML heat wave prediction (pass last 30 days for proper anomaly features)
99
+ pred_prob, pred_conf, pred_tier = predictor.predict(
100
+ z, daily_temps[-30:], daily_humidity[-30:], daily_wbgt[-30:],
101
+ )
102
+
103
+ max_temp = max(daily_temps)
104
+ max_wbgt = max(daily_wbgt)
105
+ recent_temps = daily_temps[-7:]
106
+ recent_wbgt = daily_wbgt[-7:]
107
+ current_temp = daily_temps[-1]
108
+ current_wbgt = daily_wbgt[-1]
109
+ current_hi = daily_hi[-1]
110
+ watch_temp = HEAT_THRESHOLDS["watch"]["temp_c"]
111
+ consec = count_consecutive_days(recent_temps, watch_temp)
112
+ total_above = count_trigger_days(daily_temps, watch_temp)
113
+
114
+ # Risk level from config thresholds
115
+ recent_max = max(recent_temps)
116
+ risk_level = "normal"
117
+ for level in ("critical", "warning", "watch"):
118
+ ht = HEAT_THRESHOLDS[level]
119
+ if recent_max >= ht["temp_c"] and consec >= ht["consecutive_days"]:
120
+ risk_level = level
121
+ break
122
+
123
+ # Composite score
124
+ temp_score = min(100, max(0, (max_temp - 28) * 10))
125
+ wbgt_score = min(100, max(0, (max_wbgt - 25) * 12))
126
+ vuln_score = {"high": 85, "moderate": 50, "low": 20}[z.heat_vulnerability]
127
+ exposure_score = z.outdoor_exposure_pct * 100
128
+ composite = round(temp_score * 0.3 + wbgt_score * 0.25 + consec * 10 * 0.2 + vuln_score * 0.15 + exposure_score * 0.1, 1)
129
+ composite = min(100, max(0, composite))
130
+
131
+ enrolled = int(z.worker_population_est * rng.uniform(0.15, 0.45))
132
+
133
+ zone_data = {
134
+ "zone_id": z.zone_id,
135
+ "name": z.name,
136
+ "city": z.city,
137
+ "country": z.country,
138
+ "latitude": z.latitude,
139
+ "longitude": z.longitude,
140
+ "elevation_m": z.elevation_m,
141
+ "settlement_type": z.settlement_type,
142
+ "worker_population_est": z.worker_population_est,
143
+ "outdoor_exposure_pct": z.outdoor_exposure_pct,
144
+ "heat_vulnerability": z.heat_vulnerability,
145
+ "risk_level": risk_level,
146
+ "current_temp_c": current_temp,
147
+ "current_wbgt_c": current_wbgt,
148
+ "current_heat_index_c": current_hi,
149
+ "max_temp_c": round(max_temp, 1),
150
+ "max_wbgt_c": round(max_wbgt, 1),
151
+ "consecutive_hot_days": consec,
152
+ "total_days_above_33": total_above,
153
+ "heat_risk_score": composite,
154
+ "grid_temp_c": daily_grid_temps[-1],
155
+ "uhi_delta_c": daily_uhi_deltas[-1],
156
+ "corrected_temp_c": temp,
157
+ "trigger_probability_7d": round(pred_prob, 2),
158
+ "prediction_confidence": round(pred_conf, 2),
159
+ "model_tier": pred_tier,
160
+ "enrolled_workers": enrolled,
161
+ "data_quality": round(rng.uniform(0.80, 0.98), 2),
162
+ "last_updated": now.isoformat(),
163
+ }
164
+ zones.append(zone_data)
165
+
166
+ # Index data with daily history
167
+ indices.append({
168
+ "zone_id": z.zone_id,
169
+ "zone_name": z.name,
170
+ "city": z.city,
171
+ "risk_level": risk_level,
172
+ "temp_current": current_temp,
173
+ "wbgt_current": current_wbgt,
174
+ "heat_index_current": current_hi,
175
+ "consecutive_hot_days": consec,
176
+ "heat_risk_score": composite,
177
+ "grid_temp_c": daily_grid_temps[-1],
178
+ "uhi_delta_c": daily_uhi_deltas[-1],
179
+ "trigger_probability_7d": round(pred_prob, 2),
180
+ "prediction_confidence": round(pred_conf, 2),
181
+ "model_tier": pred_tier,
182
+ "daily_history": [
183
+ {"date": daily_dates[i], "temp_c": daily_temps[i], "grid_temp_c": daily_grid_temps[i], "uhi_delta_c": daily_uhi_deltas[i], "humidity_pct": daily_humidity[i], "wbgt_c": daily_wbgt[i], "heat_index_c": daily_hi[i]}
184
+ for i in range(90)
185
+ ],
186
+ })
187
+
188
+ # Triggers
189
+ if risk_level != "normal":
190
+ payout = PAYOUT_PER_EVENT_USD.get(risk_level, 5)
191
+ all_triggers.append({
192
+ "trigger_id": f"TRG-{tid:04d}",
193
+ "zone_id": z.zone_id,
194
+ "zone_name": z.name,
195
+ "city": z.city,
196
+ "trigger_level": risk_level,
197
+ "trigger_date": (now - timedelta(hours=rng.randint(2, 48))).isoformat(),
198
+ "heat_risk_score": composite,
199
+ "max_temp_c": round(max_temp, 1),
200
+ "max_wbgt_c": round(max_wbgt, 1),
201
+ "consecutive_days": consec,
202
+ "total_days_above": total_above,
203
+ "settlement_type": z.settlement_type,
204
+ "payout_per_worker_usd": payout,
205
+ "enrolled_workers": enrolled,
206
+ "total_payout_usd": payout * enrolled,
207
+ "status": "active",
208
+ })
209
+ tid += 1
210
+
211
+ # Basis risk
212
+ basis_risk = []
213
+ for z_data in zones:
214
+ zone_obj = ZONE_MAP[z_data["zone_id"]]
215
+ if zone_obj.heat_vulnerability == "high" and zone_obj.settlement_type == "informal":
216
+ score = rng.uniform(0.25, 0.40)
217
+ elif zone_obj.heat_vulnerability == "high":
218
+ score = rng.uniform(0.18, 0.32)
219
+ elif zone_obj.heat_vulnerability == "moderate":
220
+ score = rng.uniform(0.10, 0.22)
221
+ else:
222
+ score = rng.uniform(0.05, 0.15)
223
+ basis_risk.append({
224
+ "zone_id": z_data["zone_id"],
225
+ "zone_name": z_data["name"],
226
+ "city": z_data["city"],
227
+ "overall_score": round(score, 3),
228
+ "false_positive_rate": round(score * rng.uniform(0.4, 0.7), 3),
229
+ "false_negative_rate": round(score * rng.uniform(0.3, 0.6), 3),
230
+ "correlation": round(1 - score * rng.uniform(0.8, 1.1), 3),
231
+ "settlement_type": z_data["settlement_type"],
232
+ "heat_vulnerability": z_data["heat_vulnerability"],
233
+ "recommendation": (
234
+ "Urban heat island effect significant — consider localized temperature sensors"
235
+ if zone_obj.settlement_type == "informal"
236
+ else "Station temperature may underestimate worker-experienced heat by 2-3°C"
237
+ if score > 0.2
238
+ else "Current calibration adequate for this zone"
239
+ ),
240
+ })
241
+
242
+ # Notifications
243
+ notifications = []
244
+ nid = 1
245
+ for trigger in all_triggers:
246
+ if trigger["trigger_level"] in ("critical", "warning"):
247
+ notifications.append({
248
+ "id": f"NOT-{nid:04d}",
249
+ "zone_id": trigger["zone_id"],
250
+ "zone_name": trigger["zone_name"],
251
+ "city": trigger["city"],
252
+ "trigger_level": trigger["trigger_level"],
253
+ "channel": rng.choice(["sms", "whatsapp"]),
254
+ "language": rng.choice(["en", "sw"]),
255
+ "recipient_count": trigger["enrolled_workers"],
256
+ "message_preview": (
257
+ f"HEAT ALERT [{trigger['trigger_level'].upper()}]: "
258
+ f"{trigger['zone_name']}, {trigger['city']}. "
259
+ f"Temperature {trigger['max_temp_c']}°C (WBGT {trigger['max_wbgt_c']}°C). "
260
+ f"Payout: ${trigger['payout_per_worker_usd']}."
261
+ ),
262
+ "status": "sent",
263
+ "delivered_at": trigger["trigger_date"],
264
+ "cost_estimate": round(trigger["enrolled_workers"] * 0.0075, 2),
265
+ })
266
+ nid += 1
267
+ notifications.append({
268
+ "id": f"NOT-{nid:04d}",
269
+ "zone_id": trigger["zone_id"],
270
+ "zone_name": trigger["zone_name"],
271
+ "city": trigger["city"],
272
+ "trigger_level": trigger["trigger_level"],
273
+ "channel": "sms",
274
+ "language": "sw",
275
+ "recipient_count": trigger["enrolled_workers"],
276
+ "message_preview": (
277
+ f"TAHADHARI YA JOTO [{trigger['trigger_level'].upper()}]: "
278
+ f"{trigger['zone_name']}, {trigger['city']}. "
279
+ f"Joto {trigger['max_temp_c']}°C. "
280
+ f"Malipo: ${trigger['payout_per_worker_usd']}."
281
+ ),
282
+ "status": "sent",
283
+ "delivered_at": trigger["trigger_date"],
284
+ "cost_estimate": round(trigger["enrolled_workers"] * 0.0075, 2),
285
+ })
286
+ nid += 1
287
+
288
+ # Pipeline runs
289
+ pipeline_runs = []
290
+ for i in range(15):
291
+ run_date = now - timedelta(days=i * 2)
292
+ duration = rng.uniform(30, 120)
293
+ cost = rng.uniform(0.06, 0.18)
294
+ status = "ok" if rng.random() > 0.15 else "partial"
295
+ pipeline_runs.append({
296
+ "run_id": f"run-{1000 + i}",
297
+ "started_at": run_date.isoformat(),
298
+ "ended_at": (run_date + timedelta(seconds=duration)).isoformat(),
299
+ "status": status,
300
+ "duration_s": round(duration, 1),
301
+ "zones_processed": 20,
302
+ "triggers_found": rng.randint(0, 8),
303
+ "notifications_sent": rng.randint(0, 16),
304
+ "total_cost_usd": round(cost, 4),
305
+ "steps": [
306
+ {"step": s, "status": "ok", "duration_s": round(duration / 6, 1)}
307
+ for s in ["ingest", "heal", "index", "calibrate", "explain", "notify"]
308
+ ],
309
+ })
310
+
311
+ stats = {
312
+ "total_runs": len(pipeline_runs),
313
+ "successful_runs": sum(1 for r in pipeline_runs if r["status"] == "ok"),
314
+ "success_rate": round(sum(1 for r in pipeline_runs if r["status"] == "ok") / len(pipeline_runs), 2),
315
+ "zones_monitored": len(ZONES),
316
+ "cities": len(CITIES),
317
+ "active_triggers": len(all_triggers),
318
+ "total_enrolled": sum(z["enrolled_workers"] for z in zones),
319
+ "total_cost_usd": round(sum(r["total_cost_usd"] for r in pipeline_runs), 2),
320
+ "avg_cost_per_run_usd": round(sum(r["total_cost_usd"] for r in pipeline_runs) / len(pipeline_runs), 4),
321
+ "last_run": pipeline_runs[0]["started_at"],
322
+ "data_sources": ["NASA POWER"],
323
+ }
324
+
325
+ return {
326
+ "zones": zones,
327
+ "indices": indices,
328
+ "triggers": all_triggers,
329
+ "basis_risk": basis_risk,
330
+ "notifications": notifications,
331
+ "pipeline_runs": pipeline_runs,
332
+ "stats": stats,
333
+ }
334
+
335
+
336
+ _demo = _generate_demo_data()
337
+
338
+ # Singletons for calibrate endpoint (avoid re-instantiation per request)
339
+ _actuarial_pricer = ActuarialPricer()
340
+ _budget_optimizer = BudgetOptimizer()
341
+
342
+
343
+ # ── API Endpoints ──────────────────────────────────────────────────────────
344
+
345
+ @app.get("/health")
346
+ def health():
347
+ return {"status": "ok", "service": "extreme-heat-risk-engine", "version": "1.0.0"}
348
+
349
+
350
+ @app.get("/api/zones")
351
+ def get_zones():
352
+ return {"zones": _demo["zones"], "total": len(_demo["zones"]), "cities": CITIES}
353
+
354
+
355
+ @app.get("/api/indices")
356
+ def get_indices():
357
+ return {"indices": _demo["indices"], "total": len(_demo["indices"])}
358
+
359
+
360
+ @app.get("/api/triggers")
361
+ def get_triggers():
362
+ triggers = _demo["triggers"]
363
+ return {
364
+ "triggers": triggers,
365
+ "total": len(triggers),
366
+ "active": sum(1 for t in triggers if t["status"] == "active"),
367
+ "by_level": {
368
+ level: sum(1 for t in triggers if t["trigger_level"] == level)
369
+ for level in ["critical", "warning", "watch"]
370
+ },
371
+ }
372
+
373
+
374
+ @app.get("/api/basis-risk")
375
+ def get_basis_risk():
376
+ br = _demo["basis_risk"]
377
+ return {
378
+ "assessments": br,
379
+ "total": len(br),
380
+ "avg_score": round(sum(b["overall_score"] for b in br) / max(1, len(br)), 3),
381
+ }
382
+
383
+
384
+ @app.get("/api/notifications")
385
+ def get_notifications():
386
+ notifs = _demo["notifications"]
387
+ return {
388
+ "notifications": notifs,
389
+ "total": len(notifs),
390
+ "by_language": {
391
+ lang: sum(1 for n in notifs if n["language"] == lang)
392
+ for lang in ["en", "sw"]
393
+ },
394
+ }
395
+
396
+
397
+ @app.get("/api/enrolled-workers")
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 _demo["zones"]
402
+ ]
403
+ return {"by_zone": by_zone, "total_enrolled": sum(z["enrolled_workers"] for z in _demo["zones"])}
404
+
405
+
406
+ @app.get("/api/pipeline/runs")
407
+ def get_pipeline_runs():
408
+ return {"runs": _demo["pipeline_runs"], "total": len(_demo["pipeline_runs"])}
409
+
410
+
411
+ @app.get("/api/pipeline/stats")
412
+ def get_pipeline_stats():
413
+ return _demo["stats"]
414
+
415
+
416
+ @app.get("/api/calibrate")
417
+ def calibrate(
418
+ temp_threshold: float = 35.0,
419
+ consecutive_days: int = 2,
420
+ wbgt_threshold: float = 30.0,
421
+ payout_usd: float = 10.0,
422
+ budget_usd: float = 500000.0,
423
+ worker_contribution_usd: float = 0.0,
424
+ ):
425
+ """Interactive calibration endpoint.
426
+
427
+ Run heat risk scoring with custom thresholds against all zones.
428
+ Returns per-zone trigger analysis and program cost estimates.
429
+ """
430
+ rng = random.Random(SEED)
431
+ results = []
432
+ total_trigger_days = 0
433
+ total_annual_cost = 0.0
434
+ zones_triggered = 0
435
+
436
+ zones_by_id = {z["zone_id"]: z for z in _demo["zones"]}
437
+ basis_by_id = {b["zone_id"]: b for b in _demo["basis_risk"]}
438
+
439
+ for idx_data in _demo["indices"]:
440
+ zone_id = idx_data["zone_id"]
441
+ zone = ZONE_MAP.get(zone_id)
442
+ if not zone:
443
+ continue
444
+
445
+ # Extract daily temps and humidity from history
446
+ history = idx_data.get("daily_history", [])
447
+ temps = [d["temp_c"] for d in history]
448
+ humidity = [d["humidity_pct"] for d in history]
449
+ wbgts = [d["wbgt_c"] for d in history]
450
+
451
+ # Apply custom thresholds
452
+ days_above_temp = count_trigger_days(temps, temp_threshold)
453
+ days_above_wbgt = count_trigger_days(wbgts, wbgt_threshold)
454
+ consec_temp = count_consecutive_days(temps, temp_threshold)
455
+ consec_wbgt = count_consecutive_days(wbgts, wbgt_threshold)
456
+
457
+ # Count trigger events (consecutive runs above threshold)
458
+ trigger_events = 0
459
+ run_length = 0
460
+ for t in temps:
461
+ if t > temp_threshold:
462
+ run_length += 1
463
+ else:
464
+ if run_length >= consecutive_days:
465
+ trigger_events += 1
466
+ run_length = 0
467
+ if run_length >= consecutive_days:
468
+ trigger_events += 1
469
+
470
+ # Annualize (90 days of data → multiply by 4)
471
+ events_per_year = round(trigger_events * (365 / max(len(temps), 1)), 1)
472
+
473
+ zone_demo = zones_by_id.get(zone_id, {})
474
+ enrolled = zone_demo.get("enrolled_workers", 0)
475
+
476
+ annual_payout = round(events_per_year * payout_usd * enrolled, 2)
477
+ annual_per_worker = round(events_per_year * payout_usd, 2)
478
+
479
+ br = basis_by_id.get(zone_id, {})
480
+ basis_score = br.get("overall_score", 0.15)
481
+
482
+ triggered = trigger_events > 0
483
+ if triggered:
484
+ zones_triggered += 1
485
+ total_trigger_days += days_above_temp
486
+ total_annual_cost += annual_payout
487
+
488
+ results.append({
489
+ "zone_id": zone_id,
490
+ "zone_name": zone.name,
491
+ "city": zone.city,
492
+ "settlement_type": zone.settlement_type,
493
+ "heat_vulnerability": zone.heat_vulnerability,
494
+ "enrolled_workers": enrolled,
495
+ "days_above_temp": days_above_temp,
496
+ "days_above_wbgt": days_above_wbgt,
497
+ "consecutive_days_temp": consec_temp,
498
+ "consecutive_days_wbgt": consec_wbgt,
499
+ "trigger_events": trigger_events,
500
+ "events_per_year": events_per_year,
501
+ "annual_payout_per_worker": annual_per_worker,
502
+ "annual_payout_total": annual_payout,
503
+ "basis_risk_score": basis_score,
504
+ "triggered": triggered,
505
+ })
506
+
507
+ total_enrolled = sum(r["enrolled_workers"] for r in results)
508
+
509
+ # Actuarial pricing per zone
510
+ actuarial_results = []
511
+ for r in results:
512
+ zone = ZONE_MAP.get(r["zone_id"])
513
+ if not zone:
514
+ continue
515
+ ar = _actuarial_pricer.price_zone(
516
+ zone=zone,
517
+ predicted_frequency=r["events_per_year"],
518
+ basis_risk_score=r["basis_risk_score"],
519
+ payout_per_event=payout_usd,
520
+ enrolled=r["enrolled_workers"],
521
+ )
522
+ r["actuarial_cost_per_worker"] = round(ar.cost_per_worker_year, 2)
523
+ r["cost_breakdown"] = ar.cost_breakdown
524
+ actuarial_results.append(ar)
525
+
526
+ # Budget allocation
527
+ allocation = _budget_optimizer.optimize(
528
+ budget_usd=budget_usd,
529
+ actuarial_results=actuarial_results,
530
+ payout_per_event=payout_usd,
531
+ worker_contribution=worker_contribution_usd,
532
+ )
533
+
534
+ # Merge allocation into zone results
535
+ alloc_map = {a.zone_id: a for a in allocation.allocations}
536
+ for r in results:
537
+ a = alloc_map.get(r["zone_id"])
538
+ if a:
539
+ r["allocated_budget"] = round(a.allocated_budget, 2)
540
+ r["workers_covered"] = a.workers_covered
541
+ r["coverage_pct"] = round(a.coverage_pct, 1)
542
+ r["priority_rank"] = a.priority_rank
543
+ else:
544
+ r["allocated_budget"] = 0
545
+ r["workers_covered"] = 0
546
+ r["coverage_pct"] = 0
547
+ r["priority_rank"] = 99
548
+
549
+ return {
550
+ "zones": sorted(results, key=lambda r: r.get("priority_rank", 99)),
551
+ "summary": {
552
+ "total_zones": len(results),
553
+ "zones_triggered": zones_triggered,
554
+ "total_trigger_days": total_trigger_days,
555
+ "avg_events_per_year": round(sum(r["events_per_year"] for r in results) / max(1, len(results)), 1),
556
+ "total_annual_cost": round(total_annual_cost, 2),
557
+ "avg_cost_per_worker": round(total_annual_cost / max(1, total_enrolled), 2),
558
+ "total_enrolled": total_enrolled,
559
+ "avg_basis_risk": round(sum(r["basis_risk_score"] for r in results) / max(1, len(results)), 3),
560
+ },
561
+ "allocation": {
562
+ "budget_usd": budget_usd,
563
+ "worker_contribution_usd": worker_contribution_usd,
564
+ "workers_covered": allocation.total_workers_covered,
565
+ "overall_coverage_pct": round(allocation.overall_coverage_pct, 1),
566
+ "zones_fully_funded": allocation.zones_fully_funded,
567
+ "zones_partially_funded": allocation.zones_partially_funded,
568
+ "zones_unfunded": allocation.zones_unfunded,
569
+ "stretch_analysis": allocation.stretch_analysis,
570
+ },
571
+ "thresholds": {
572
+ "temp_threshold": temp_threshold,
573
+ "consecutive_days": consecutive_days,
574
+ "wbgt_threshold": wbgt_threshold,
575
+ "payout_usd": payout_usd,
576
+ },
577
+ }
578
+
579
+
580
+ # ── Static file serving ───────────────────────────────────────────────────
581
+
582
+ dist_path = Path(__file__).parent.parent / "frontend" / "dist"
583
+ if dist_path.exists():
584
+ app.mount("/assets", StaticFiles(directory=str(dist_path / "assets")), name="static")
585
+
586
+ @app.get("/{path:path}")
587
+ async def serve_spa(path: str):
588
+ file_path = dist_path / path
589
+ if file_path.exists() and file_path.is_file():
590
+ return FileResponse(str(file_path))
591
+ return FileResponse(str(dist_path / "index.html"))
src/calibration/__init__.py ADDED
File without changes
src/calibration/basis_risk.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Basis risk estimation for parametric heat insurance.
3
+
4
+ Basis risk is the mismatch between what the temperature index says happened
5
+ and what outdoor workers actually experienced. In parametric heat insurance
6
+ the key sources of basis risk are:
7
+
8
+ 1. Urban heat island effect — informal settlements with tin roofs are
9
+ significantly hotter than the station/satellite reading
10
+ 2. Time-of-day exposure — workers are outside during 10am-4pm peak,
11
+ but daily-max temperature may occur at a different time
12
+ 3. Indoor/outdoor split — the index triggers for the whole zone but
13
+ only outdoor workers are affected
14
+ 4. Microclimate variation — shade, wind, surface type differ block to block
15
+
16
+ Realistic basis risk ranges:
17
+ - Informal high-vulnerability zones: 0.25 - 0.40
18
+ - Mixed/moderate zones: 0.15 - 0.28
19
+ - Formal low-vulnerability zones: 0.08 - 0.18
20
+
21
+ Each zone gets a BasisRiskReport with false-positive/negative rates,
22
+ index-damage correlation, and threshold adjustment recommendations.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import logging
29
+ import math
30
+ import random
31
+ from dataclasses import dataclass, field
32
+ from typing import Optional
33
+
34
+ from config import HEAT_THRESHOLDS, PAYOUT_PER_EVENT_USD, ZONE_MAP, ZONES, UrbanZone
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+
39
+ # -- Data containers --------------------------------------------------------
40
+
41
+ @dataclass
42
+ class SimulatedEvent:
43
+ """One historical event: what the index said vs. what workers experienced."""
44
+ zone_id: str
45
+ date: str
46
+ index_triggered: bool
47
+ trigger_level: str # critical, warning, watch, none
48
+ actual_heat_stress: bool
49
+ actual_severity: float # 0.0-1.0
50
+ index_severity: float # 0.0-1.0
51
+ notes: str = ""
52
+
53
+
54
+ @dataclass
55
+ class BasisRiskReport:
56
+ """Basis risk assessment for a single zone."""
57
+ zone_id: str
58
+ zone_name: str
59
+ city: str
60
+ settlement_type: str
61
+ heat_vulnerability: str
62
+
63
+ # Core metrics
64
+ overall_score: float # 0-1, lower is better
65
+ false_positive_rate: float # index fired but no heat stress
66
+ false_negative_rate: float # heat stress occurred but index missed
67
+ correlation: float # Pearson r between index and actual severity
68
+ mae: float # mean absolute error
69
+
70
+ # Trigger accuracy
71
+ total_events: int
72
+ true_positives: int
73
+ true_negatives: int
74
+ false_positives: int
75
+ false_negatives: int
76
+ trigger_accuracy: float
77
+
78
+ # Basis risk components
79
+ uhi_adjustment: float # urban heat island temperature delta (C)
80
+ time_of_day_factor: float # mismatch from peak-hour vs daily-max
81
+ indoor_outdoor_split: float # fraction of workers truly exposed
82
+
83
+ # Recommendations
84
+ recommendations: list[str] = field(default_factory=list)
85
+ confidence_interval: tuple[float, float] = (0.0, 1.0)
86
+ tier_accuracy: dict[str, float] = field(default_factory=dict)
87
+
88
+
89
+ # -- Simulation parameters --------------------------------------------------
90
+
91
+ # Basis risk ranges by (settlement_type, heat_vulnerability)
92
+ _RISK_RANGES: dict[tuple[str, str], tuple[float, float]] = {
93
+ ("informal", "high"): (0.30, 0.40),
94
+ ("informal", "moderate"): (0.25, 0.35),
95
+ ("informal", "low"): (0.20, 0.30),
96
+ ("mixed", "high"): (0.22, 0.32),
97
+ ("mixed", "moderate"): (0.18, 0.28),
98
+ ("mixed", "low"): (0.15, 0.22),
99
+ ("formal", "high"): (0.14, 0.22),
100
+ ("formal", "moderate"): (0.10, 0.18),
101
+ ("formal", "low"): (0.08, 0.15),
102
+ ("commercial", "high"): (0.12, 0.20),
103
+ ("commercial", "moderate"): (0.10, 0.16),
104
+ ("commercial", "low"): (0.08, 0.14),
105
+ }
106
+
107
+ # Urban heat island temperature deltas (C above station reading)
108
+ _UHI_DELTA = {
109
+ "informal": 3.5, # tin roofs, no shade, concrete/dirt heat absorption
110
+ "mixed": 2.0,
111
+ "formal": 1.0,
112
+ "commercial": 1.5, # concrete + AC exhaust
113
+ }
114
+
115
+ # Time-of-day exposure factor (how much 10am-4pm temps exceed daily max)
116
+ _TIME_OF_DAY = {
117
+ "high": 0.15, # workers out all day, actual exposure often higher
118
+ "moderate": 0.10,
119
+ "low": 0.05,
120
+ }
121
+
122
+ _SIM_EVENTS = 60 # ~5 years of hot-season events
123
+
124
+
125
+ def _zone_seed(zone_id: str) -> int:
126
+ """Deterministic seed per zone."""
127
+ return int(hashlib.sha256(zone_id.encode()).hexdigest()[:8], 16)
128
+
129
+
130
+ # -- Simulation engine -------------------------------------------------------
131
+
132
+ def _simulate_events(zone: UrbanZone, n: int = _SIM_EVENTS) -> list[SimulatedEvent]:
133
+ """Generate a plausible history of heat trigger events vs actual impacts."""
134
+ rng = random.Random(_zone_seed(zone.zone_id))
135
+
136
+ risk_range = _RISK_RANGES.get(
137
+ (zone.settlement_type, zone.heat_vulnerability),
138
+ (0.18, 0.28),
139
+ )
140
+ base_risk = rng.uniform(*risk_range)
141
+
142
+ # UHI increases false negatives (station reads cooler than reality)
143
+ uhi = _UHI_DELTA.get(zone.settlement_type, 1.5)
144
+ base_risk += uhi * 0.015 # each degree of UHI adds ~1.5% basis risk
145
+ base_risk = max(0.05, min(0.55, base_risk))
146
+
147
+ trigger_prob = 0.30 if zone.heat_vulnerability == "high" else (
148
+ 0.20 if zone.heat_vulnerability == "moderate" else 0.12
149
+ )
150
+ stress_base_prob = trigger_prob * (1.0 + 0.10 * (1 if zone.outdoor_exposure_pct > 0.6 else 0))
151
+
152
+ events: list[SimulatedEvent] = []
153
+ for i in range(n):
154
+ month = rng.choice(zone.hot_months) if zone.hot_months else rng.choice([1, 2, 3])
155
+ year = 2019 + (i // 12)
156
+ day = rng.randint(1, 28)
157
+ date = f"{year}-{month:02d}-{day:02d}"
158
+
159
+ index_triggered = rng.random() < trigger_prob
160
+ if index_triggered:
161
+ level_roll = rng.random()
162
+ trigger_level = (
163
+ "critical" if level_roll < 0.12 else
164
+ "warning" if level_roll < 0.45 else
165
+ "watch"
166
+ )
167
+ index_severity = (
168
+ rng.uniform(0.7, 1.0) if trigger_level == "critical" else
169
+ rng.uniform(0.4, 0.7) if trigger_level == "warning" else
170
+ rng.uniform(0.1, 0.4)
171
+ )
172
+ else:
173
+ trigger_level = "none"
174
+ index_severity = rng.uniform(0.0, 0.15)
175
+
176
+ if index_triggered:
177
+ actual_heat_stress = rng.random() < (1.0 - base_risk * 0.55)
178
+ else:
179
+ # False negative: UHI makes ground truth hotter than station
180
+ actual_heat_stress = rng.random() < (base_risk * 0.45)
181
+
182
+ if actual_heat_stress:
183
+ noise = rng.gauss(0, base_risk * 0.4)
184
+ actual_severity = max(0.0, min(1.0, index_severity + noise + uhi * 0.03))
185
+ if not index_triggered:
186
+ actual_severity = rng.uniform(0.15, 0.50)
187
+ else:
188
+ actual_severity = 0.0
189
+
190
+ notes = ""
191
+ if index_triggered and not actual_heat_stress:
192
+ notes = "False alarm: index triggered but workers did not report heat stress"
193
+ elif not index_triggered and actual_heat_stress:
194
+ notes = "Missed event: workers experienced heat stress but station temp was below threshold (likely UHI gap)"
195
+
196
+ events.append(SimulatedEvent(
197
+ zone_id=zone.zone_id,
198
+ date=date,
199
+ index_triggered=index_triggered,
200
+ trigger_level=trigger_level,
201
+ actual_heat_stress=actual_heat_stress,
202
+ actual_severity=actual_severity,
203
+ index_severity=index_severity,
204
+ notes=notes,
205
+ ))
206
+
207
+ return events
208
+
209
+
210
+ # -- Metrics computation -----------------------------------------------------
211
+
212
+ def _pearson_r(xs: list[float], ys: list[float]) -> float:
213
+ """Pearson correlation coefficient. Returns 0.0 if degenerate."""
214
+ n = len(xs)
215
+ if n < 3:
216
+ return 0.0
217
+ mean_x = sum(xs) / n
218
+ mean_y = sum(ys) / n
219
+ cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
220
+ std_x = math.sqrt(sum((x - mean_x) ** 2 for x in xs))
221
+ std_y = math.sqrt(sum((y - mean_y) ** 2 for y in ys))
222
+ if std_x < 1e-9 or std_y < 1e-9:
223
+ return 0.0
224
+ return cov / (std_x * std_y)
225
+
226
+
227
+ def _compute_report(zone: UrbanZone, events: list[SimulatedEvent]) -> BasisRiskReport:
228
+ """Compute basis risk metrics from simulated events."""
229
+ tp = sum(1 for e in events if e.index_triggered and e.actual_heat_stress)
230
+ tn = sum(1 for e in events if not e.index_triggered and not e.actual_heat_stress)
231
+ fp = sum(1 for e in events if e.index_triggered and not e.actual_heat_stress)
232
+ fn = sum(1 for e in events if not e.index_triggered and e.actual_heat_stress)
233
+ total = len(events)
234
+
235
+ fpr = fp / max(1, fp + tp)
236
+ fnr = fn / max(1, fn + tn)
237
+ accuracy = (tp + tn) / max(1, total)
238
+
239
+ idx_sev = [e.index_severity for e in events]
240
+ act_sev = [e.actual_severity for e in events]
241
+ corr = _pearson_r(idx_sev, act_sev)
242
+ mae = sum(abs(i - a) for i, a in zip(idx_sev, act_sev)) / max(1, total)
243
+
244
+ overall = 0.30 * fpr + 0.30 * fnr + 0.20 * mae + 0.20 * (1.0 - max(0, corr))
245
+ overall = max(0.0, min(1.0, overall))
246
+
247
+ # UHI and time-of-day factors
248
+ uhi = _UHI_DELTA.get(zone.settlement_type, 1.5)
249
+ tod = _TIME_OF_DAY.get(zone.heat_vulnerability, 0.10)
250
+
251
+ # Per-tier accuracy
252
+ tier_acc: dict[str, float] = {}
253
+ for level in ("critical", "warning", "watch"):
254
+ tier_events = [e for e in events if e.trigger_level == level]
255
+ if tier_events:
256
+ tier_correct = sum(1 for e in tier_events if e.actual_heat_stress)
257
+ tier_acc[level] = tier_correct / len(tier_events)
258
+ else:
259
+ tier_acc[level] = 0.0
260
+
261
+ # Recommendations
262
+ recs: list[str] = []
263
+ if uhi >= 3.0:
264
+ recs.append(
265
+ f"Urban heat island effect adds ~{uhi:.1f}C in {zone.name}. "
266
+ f"Consider lowering trigger threshold by {uhi * 0.5:.1f}C for this zone."
267
+ )
268
+ if fnr > 0.20:
269
+ recs.append(
270
+ f"Missed event rate ({fnr:.0%}) is high. Station temperature "
271
+ f"underestimates ground-level heat in {zone.settlement_type} areas. "
272
+ f"Consider adding community-reported heat stress data."
273
+ )
274
+ if fpr > 0.25:
275
+ recs.append(
276
+ f"False positive rate ({fpr:.0%}). Consider raising threshold "
277
+ f"or adding WBGT as a secondary trigger condition."
278
+ )
279
+ if zone.outdoor_exposure_pct > 0.7:
280
+ recs.append(
281
+ f"High outdoor exposure ({zone.outdoor_exposure_pct:.0%}). "
282
+ f"Prioritize this zone for shade structures and rest-break interventions."
283
+ )
284
+ if zone.settlement_type == "informal":
285
+ recs.append(
286
+ "Informal settlement — tin roof temperatures can exceed ambient by 10-15C. "
287
+ "Ground-truth heat monitoring (e.g., community thermometers) would "
288
+ "significantly reduce basis risk."
289
+ )
290
+ if not recs:
291
+ recs.append(
292
+ "Basis risk is within acceptable range. Continue monitoring "
293
+ "and recalibrate with seasonal ground-truth data."
294
+ )
295
+
296
+ se = math.sqrt(overall * (1 - overall) / max(1, total))
297
+ ci_low = max(0.0, overall - 1.96 * se)
298
+ ci_high = min(1.0, overall + 1.96 * se)
299
+
300
+ return BasisRiskReport(
301
+ zone_id=zone.zone_id,
302
+ zone_name=zone.name,
303
+ city=zone.city,
304
+ settlement_type=zone.settlement_type,
305
+ heat_vulnerability=zone.heat_vulnerability,
306
+ overall_score=round(overall, 3),
307
+ false_positive_rate=round(fpr, 3),
308
+ false_negative_rate=round(fnr, 3),
309
+ correlation=round(corr, 3),
310
+ mae=round(mae, 3),
311
+ total_events=total,
312
+ true_positives=tp,
313
+ true_negatives=tn,
314
+ false_positives=fp,
315
+ false_negatives=fn,
316
+ trigger_accuracy=round(accuracy, 3),
317
+ uhi_adjustment=uhi,
318
+ time_of_day_factor=tod,
319
+ indoor_outdoor_split=zone.outdoor_exposure_pct,
320
+ recommendations=recs,
321
+ confidence_interval=(round(ci_low, 3), round(ci_high, 3)),
322
+ tier_accuracy={k: round(v, 3) for k, v in tier_acc.items()},
323
+ )
324
+
325
+
326
+ # -- Public API ---------------------------------------------------------------
327
+
328
+ def assess_zone(zone_id: str) -> BasisRiskReport:
329
+ """Run basis risk assessment for a single zone."""
330
+ zone = ZONE_MAP.get(zone_id)
331
+ if zone is None:
332
+ raise ValueError(f"Unknown zone: {zone_id}")
333
+ events = _simulate_events(zone)
334
+ report = _compute_report(zone, events)
335
+ log.info(
336
+ "Basis risk for %s (%s): %.3f [FPR=%.2f, FNR=%.2f, r=%.2f, UHI=+%.1fC]",
337
+ zone.name, zone.zone_id, report.overall_score,
338
+ report.false_positive_rate, report.false_negative_rate,
339
+ report.correlation, report.uhi_adjustment,
340
+ )
341
+ return report
342
+
343
+
344
+ def assess_all_zones() -> dict[str, BasisRiskReport]:
345
+ """Run basis risk assessment across every configured zone."""
346
+ reports: dict[str, BasisRiskReport] = {}
347
+ for zone_id in ZONE_MAP:
348
+ reports[zone_id] = assess_zone(zone_id)
349
+ return reports
350
+
351
+
352
+ def recommended_threshold_adjustment(
353
+ report: BasisRiskReport,
354
+ ) -> Optional[dict[str, dict[str, float]]]:
355
+ """Suggest adjusted trigger thresholds based on basis risk analysis.
356
+
357
+ Returns a dict like HEAT_THRESHOLDS but with modified values,
358
+ or None if no adjustment is needed.
359
+ """
360
+ if report.overall_score < 0.20:
361
+ return None
362
+
363
+ adjusted = {}
364
+ for level in ("critical", "warning", "watch"):
365
+ base = HEAT_THRESHOLDS[level].copy()
366
+ if report.false_negative_rate > 0.20:
367
+ # Missing too many events: lower temperature thresholds
368
+ # Account for UHI by reducing threshold
369
+ uhi_adj = report.uhi_adjustment * 0.4
370
+ adjusted[level] = {
371
+ "temp_c": round(base["temp_c"] - uhi_adj, 1),
372
+ "wbgt_c": round(base["wbgt_c"] - uhi_adj * 0.6, 1),
373
+ "consecutive_days": base["consecutive_days"],
374
+ }
375
+ elif report.false_positive_rate > 0.25:
376
+ # Too many false alarms: raise thresholds
377
+ factor = 1.0 + (report.false_positive_rate - 0.25) * 0.3
378
+ adjusted[level] = {
379
+ "temp_c": round(base["temp_c"] * factor, 1),
380
+ "wbgt_c": round(base["wbgt_c"] * factor, 1),
381
+ "consecutive_days": base["consecutive_days"],
382
+ }
383
+ else:
384
+ adjusted[level] = base
385
+
386
+ return adjusted
src/database/__init__.py ADDED
File without changes
src/database/crud.py ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database CRUD operations for the Climate Risk Index Engine.
3
+
4
+ Uses asyncpg for PostgreSQL when DATABASE_URL is set, with an in-memory
5
+ fallback for demo/testing. Pattern follows Weather AI 2's PgConnection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ 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, Sequence
18
+
19
+ from src.database.schema import get_full_ddl, get_table_names
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ # ── Connection wrapper ───────────────────────────────────────────────────
25
+
26
+ class PgConnection:
27
+ """
28
+ Async PostgreSQL connection manager.
29
+
30
+ If DATABASE_URL is not set, transparently switches to InMemoryStore
31
+ so the pipeline can run in demo mode without a database.
32
+ """
33
+
34
+ def __init__(self, database_url: Optional[str] = None):
35
+ self.database_url = database_url or os.environ.get("DATABASE_URL", "")
36
+ self._pool = None
37
+ self._memory: Optional[InMemoryStore] = None
38
+
39
+ @property
40
+ def is_postgres(self) -> bool:
41
+ return bool(self.database_url)
42
+
43
+ async def connect(self) -> None:
44
+ """Establish the connection pool or init in-memory store."""
45
+ if self.database_url:
46
+ try:
47
+ import asyncpg
48
+ self._pool = await asyncpg.create_pool(
49
+ self.database_url,
50
+ min_size=2,
51
+ max_size=10,
52
+ command_timeout=30,
53
+ )
54
+ log.info("Connected to PostgreSQL")
55
+ except Exception as exc:
56
+ log.warning("PostgreSQL unavailable (%s), using in-memory store", exc)
57
+ self._memory = InMemoryStore()
58
+ else:
59
+ log.info("No DATABASE_URL set, using in-memory store")
60
+ self._memory = InMemoryStore()
61
+
62
+ async def close(self) -> None:
63
+ """Close the connection pool."""
64
+ if self._pool:
65
+ await self._pool.close()
66
+ self._pool = None
67
+ self._memory = None
68
+
69
+ async def init_schema(self) -> None:
70
+ """Create all tables if they don't exist."""
71
+ if self._pool:
72
+ async with self._pool.acquire() as conn:
73
+ await conn.execute(get_full_ddl())
74
+ log.info("Schema initialized (%d tables)", len(get_table_names()))
75
+ else:
76
+ log.info("In-memory store: schema init is a no-op")
77
+
78
+ async def execute(self, query: str, *args) -> str:
79
+ """Execute a query, return status string."""
80
+ if self._pool:
81
+ async with self._pool.acquire() as conn:
82
+ return await conn.execute(query, *args)
83
+ return "OK (in-memory)"
84
+
85
+ async def fetch(self, query: str, *args) -> list[dict]:
86
+ """Fetch rows as list of dicts."""
87
+ if self._pool:
88
+ async with self._pool.acquire() as conn:
89
+ rows = await conn.fetch(query, *args)
90
+ return [dict(r) for r in rows]
91
+ return []
92
+
93
+ async def fetchrow(self, query: str, *args) -> Optional[dict]:
94
+ """Fetch a single row as dict."""
95
+ if self._pool:
96
+ async with self._pool.acquire() as conn:
97
+ row = await conn.fetchrow(query, *args)
98
+ return dict(row) if row else None
99
+ return None
100
+
101
+ async def fetchval(self, query: str, *args) -> Any:
102
+ """Fetch a single value."""
103
+ if self._pool:
104
+ async with self._pool.acquire() as conn:
105
+ return await conn.fetchval(query, *args)
106
+ return None
107
+
108
+
109
+ # ── In-memory fallback ───────────────────────────────────────────────────
110
+
111
+ class InMemoryStore:
112
+ """
113
+ Simple in-memory storage for demo mode.
114
+ Stores rows as dicts keyed by table name.
115
+ """
116
+
117
+ def __init__(self):
118
+ self.tables: Dict[str, list[dict]] = defaultdict(list)
119
+ self._id_counters: Dict[str, int] = defaultdict(int)
120
+
121
+ def insert(self, table: str, row: dict) -> int:
122
+ """Insert a row, returning a synthetic ID."""
123
+ self._id_counters[table] += 1
124
+ row_copy = dict(row)
125
+ row_copy["id"] = self._id_counters[table]
126
+ if "created_at" not in row_copy:
127
+ row_copy["created_at"] = datetime.now(timezone.utc).isoformat()
128
+ self.tables[table].append(row_copy)
129
+ return row_copy["id"]
130
+
131
+ def query(
132
+ self, table: str, filters: Optional[Dict[str, Any]] = None, limit: int = 100
133
+ ) -> list[dict]:
134
+ """Query rows with optional simple equality filters."""
135
+ rows = self.tables.get(table, [])
136
+ if filters:
137
+ rows = [
138
+ r for r in rows
139
+ if all(r.get(k) == v for k, v in filters.items())
140
+ ]
141
+ return rows[:limit]
142
+
143
+ def count(self, table: str) -> int:
144
+ return len(self.tables.get(table, []))
145
+
146
+
147
+ # ── CRUD functions ───────────────────────────────────────────────────────
148
+
149
+ # --- Zones ---
150
+
151
+ async def upsert_zone(db: PgConnection, zone_data: dict) -> None:
152
+ """Insert or update a zone."""
153
+ if db._pool:
154
+ await db.execute(
155
+ """
156
+ INSERT INTO zones (zone_id, name, city, country, latitude, longitude,
157
+ elevation_m, area_km2, population_est, settlement_type,
158
+ flood_susceptibility, drainage_quality, primary_flood_type,
159
+ rainy_seasons, notes)
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["flood_susceptibility"], zone_data["drainage_quality"],
171
+ zone_data.get("primary_flood_type"), zone_data.get("rainy_seasons"),
172
+ zone_data.get("notes", ""),
173
+ )
174
+ elif db._memory:
175
+ # Simple overwrite in memory
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]:
187
+ """Fetch a single zone."""
188
+ if db._pool:
189
+ return await db.fetchrow("SELECT * FROM zones WHERE zone_id = $1", zone_id)
190
+ elif db._memory:
191
+ rows = db._memory.query("zones", {"zone_id": zone_id}, limit=1)
192
+ return rows[0] if rows else None
193
+ return None
194
+
195
+
196
+ async def get_all_zones(db: PgConnection) -> list[dict]:
197
+ """Fetch all zones."""
198
+ if db._pool:
199
+ return await db.fetch("SELECT * FROM zones ORDER BY city, name")
200
+ elif db._memory:
201
+ return db._memory.query("zones", limit=1000)
202
+ return []
203
+
204
+
205
+ # --- Daily readings ---
206
+
207
+ async def insert_daily_reading(db: PgConnection, reading: dict) -> Optional[int]:
208
+ """Insert a daily reading. Returns the row ID."""
209
+ if db._pool:
210
+ return await db.fetchval(
211
+ """
212
+ INSERT INTO daily_readings (zone_id, date, precip_mm, precip_nasa_mm,
213
+ precip_chirps_mm, temp_mean_c, temp_max_c, temp_min_c,
214
+ humidity_pct, wind_speed_ms, source, source_agreement, data_quality)
215
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
216
+ ON CONFLICT (zone_id, date) DO UPDATE SET
217
+ precip_mm = EXCLUDED.precip_mm,
218
+ data_quality = EXCLUDED.data_quality
219
+ RETURNING id
220
+ """,
221
+ reading["zone_id"], reading["date"],
222
+ reading.get("precip_mm"), reading.get("precip_nasa_mm"),
223
+ reading.get("precip_chirps_mm"), reading.get("temp_mean_c"),
224
+ reading.get("temp_max_c"), reading.get("temp_min_c"),
225
+ reading.get("humidity_pct"), reading.get("wind_speed_ms"),
226
+ reading.get("source", "unknown"), reading.get("source_agreement"),
227
+ reading.get("data_quality", 0.0),
228
+ )
229
+ elif db._memory:
230
+ return db._memory.insert("daily_readings", reading)
231
+ return None
232
+
233
+
234
+ async def get_daily_readings(
235
+ db: PgConnection, zone_id: str, limit: int = 90
236
+ ) -> list[dict]:
237
+ """Fetch recent daily readings for a zone."""
238
+ if db._pool:
239
+ return await db.fetch(
240
+ "SELECT * FROM daily_readings WHERE zone_id = $1 ORDER BY date DESC LIMIT $2",
241
+ zone_id, limit,
242
+ )
243
+ elif db._memory:
244
+ rows = db._memory.query("daily_readings", {"zone_id": zone_id}, limit=limit)
245
+ return sorted(rows, key=lambda r: r.get("date", ""), reverse=True)
246
+ return []
247
+
248
+
249
+ # --- Healed readings ---
250
+
251
+ async def insert_healed_reading(db: PgConnection, reading: dict) -> Optional[int]:
252
+ """Insert a healed reading."""
253
+ if db._pool:
254
+ return await db.fetchval(
255
+ """
256
+ INSERT INTO healed_readings (zone_id, date, raw_reading_id,
257
+ precip_mm, temp_mean_c, temp_max_c, temp_min_c,
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, $12)
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("precip_mm"), reading.get("temp_mean_c"),
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),
270
+ reading.get("heal_action", "passthrough"),
271
+ reading.get("fields_corrected", []),
272
+ )
273
+ elif db._memory:
274
+ return db._memory.insert("healed_readings", reading)
275
+ return None
276
+
277
+
278
+ # --- Healing log ---
279
+
280
+ async def insert_healing_log(db: PgConnection, entry: dict) -> Optional[int]:
281
+ """Insert a healing log entry."""
282
+ if db._pool:
283
+ return await db.fetchval(
284
+ """
285
+ INSERT INTO healing_log (zone_id, date, healed_reading_id,
286
+ agent_type, reasoning, corrections, tools_used,
287
+ confidence, tokens_used, latency_ms)
288
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
289
+ RETURNING id
290
+ """,
291
+ entry["zone_id"], entry["date"], entry.get("healed_reading_id"),
292
+ entry.get("agent_type", "rule_based"), entry.get("reasoning"),
293
+ json.dumps(entry.get("corrections", {})),
294
+ entry.get("tools_used", []),
295
+ entry.get("confidence"), entry.get("tokens_used", 0),
296
+ entry.get("latency_ms", 0),
297
+ )
298
+ elif db._memory:
299
+ return db._memory.insert("healing_log", entry)
300
+ return None
301
+
302
+
303
+ # --- Monthly indices ---
304
+
305
+ async def upsert_monthly_index(db: PgConnection, index_data: dict) -> None:
306
+ """Insert or update a monthly index record."""
307
+ if db._pool:
308
+ await db.execute(
309
+ """
310
+ INSERT INTO monthly_indices (zone_id, year, month, spi_1, spi_3, spi_6,
311
+ total_precip_mm, mean_precip_mm, precip_days, precip_anomaly_pct)
312
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
313
+ ON CONFLICT (zone_id, year, month) DO UPDATE SET
314
+ spi_1 = EXCLUDED.spi_1,
315
+ spi_3 = EXCLUDED.spi_3,
316
+ spi_6 = EXCLUDED.spi_6,
317
+ total_precip_mm = EXCLUDED.total_precip_mm,
318
+ computed_at = NOW()
319
+ """,
320
+ index_data["zone_id"], index_data["year"], index_data["month"],
321
+ index_data.get("spi_1"), index_data.get("spi_3"), index_data.get("spi_6"),
322
+ index_data.get("total_precip_mm"), index_data.get("mean_precip_mm"),
323
+ index_data.get("precip_days"), index_data.get("precip_anomaly_pct"),
324
+ )
325
+ elif db._memory:
326
+ db._memory.insert("monthly_indices", index_data)
327
+
328
+
329
+ async def get_monthly_indices(
330
+ db: PgConnection, zone_id: str, limit: int = 24
331
+ ) -> list[dict]:
332
+ """Fetch recent monthly indices for a zone."""
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.query("monthly_indices", {"zone_id": zone_id}, limit=limit)
345
+ return []
346
+
347
+
348
+ # --- Flood risk scores ---
349
+
350
+ async def insert_flood_risk_score(db: PgConnection, score: dict) -> Optional[int]:
351
+ """Insert a daily flood risk score."""
352
+ if db._pool:
353
+ return await db.fetchval(
354
+ """
355
+ INSERT INTO flood_risk_scores (zone_id, date, composite_score,
356
+ precip_score, api_5day_score, spi_score, drainage_factor,
357
+ settlement_factor, risk_level, contributing_factors)
358
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
359
+ ON CONFLICT (zone_id, date) DO UPDATE SET
360
+ composite_score = EXCLUDED.composite_score,
361
+ risk_level = EXCLUDED.risk_level
362
+ RETURNING id
363
+ """,
364
+ score["zone_id"], score["date"], score["composite_score"],
365
+ score.get("precip_score"), score.get("api_5day_score"),
366
+ score.get("spi_score"), score.get("drainage_factor"),
367
+ score.get("settlement_factor"), score.get("risk_level"),
368
+ score.get("contributing_factors", []),
369
+ )
370
+ elif db._memory:
371
+ return db._memory.insert("flood_risk_scores", score)
372
+ return None
373
+
374
+
375
+ # --- Trigger events ---
376
+
377
+ async def insert_trigger_event(db: PgConnection, event: dict) -> Optional[int]:
378
+ """Insert a trigger event."""
379
+ if db._pool:
380
+ return await db.fetchval(
381
+ """
382
+ INSERT INTO trigger_events (zone_id, trigger_level, triggered_at,
383
+ daily_precip_mm, api_5day_mm, spi_1month, composite_score,
384
+ contributing_factors, risk_score_id)
385
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
386
+ RETURNING id
387
+ """,
388
+ event["zone_id"], event["trigger_level"], event["triggered_at"],
389
+ event.get("daily_precip_mm"), event.get("api_5day_mm"),
390
+ event.get("spi_1month"), event.get("composite_score"),
391
+ event.get("contributing_factors", []), event.get("risk_score_id"),
392
+ )
393
+ elif db._memory:
394
+ return db._memory.insert("trigger_events", event)
395
+ return None
396
+
397
+
398
+ async def get_trigger_events(
399
+ db: PgConnection, zone_id: Optional[str] = None, limit: int = 50
400
+ ) -> list[dict]:
401
+ """Fetch trigger events, optionally filtered by zone."""
402
+ if db._pool:
403
+ if zone_id:
404
+ return await db.fetch(
405
+ "SELECT * FROM trigger_events WHERE zone_id = $1 ORDER BY triggered_at DESC LIMIT $2",
406
+ zone_id, limit,
407
+ )
408
+ return await db.fetch(
409
+ "SELECT * FROM trigger_events ORDER BY triggered_at DESC LIMIT $1",
410
+ limit,
411
+ )
412
+ elif db._memory:
413
+ filters = {"zone_id": zone_id} if zone_id else None
414
+ return db._memory.query("trigger_events", filters, limit=limit)
415
+ return []
416
+
417
+
418
+ # --- Basis risk ---
419
+
420
+ async def insert_basis_risk(db: PgConnection, report: dict) -> Optional[int]:
421
+ """Insert a basis risk assessment."""
422
+ if db._pool:
423
+ return await db.fetchval(
424
+ """
425
+ INSERT INTO basis_risk (zone_id, overall_score, false_positive_rate,
426
+ false_negative_rate, correlation, mae, total_events,
427
+ true_positives, true_negatives, false_positives, false_negatives,
428
+ trigger_accuracy, tier_accuracy, recommendations,
429
+ confidence_low, confidence_high)
430
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
431
+ RETURNING id
432
+ """,
433
+ report["zone_id"], report["overall_score"],
434
+ report.get("false_positive_rate"), report.get("false_negative_rate"),
435
+ report.get("correlation"), report.get("mae"),
436
+ report.get("total_events"), report.get("true_positives"),
437
+ report.get("true_negatives"), report.get("false_positives"),
438
+ report.get("false_negatives"), report.get("trigger_accuracy"),
439
+ json.dumps(report.get("tier_accuracy", {})),
440
+ report.get("recommendations", []),
441
+ report.get("confidence_low"), report.get("confidence_high"),
442
+ )
443
+ elif db._memory:
444
+ return db._memory.insert("basis_risk", report)
445
+ return None
446
+
447
+
448
+ # --- Explanations ---
449
+
450
+ async def insert_explanation(db: PgConnection, explanation: dict) -> Optional[int]:
451
+ """Insert a generated explanation."""
452
+ if db._pool:
453
+ return await db.fetchval(
454
+ """
455
+ INSERT INTO explanations (trigger_event_id, zone_id, trigger_level,
456
+ english_text, swahili_text, payout_amount, payout_currency,
457
+ settlement_type, protective_actions, provider)
458
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
459
+ RETURNING id
460
+ """,
461
+ explanation.get("trigger_event_id"), explanation["zone_id"],
462
+ explanation["trigger_level"], explanation["english_text"],
463
+ explanation["swahili_text"], explanation.get("payout_amount"),
464
+ explanation.get("payout_currency", "KES"),
465
+ explanation.get("settlement_type"),
466
+ explanation.get("protective_actions", []),
467
+ explanation.get("provider", "template"),
468
+ )
469
+ elif db._memory:
470
+ return db._memory.insert("explanations", explanation)
471
+ return None
472
+
473
+
474
+ async def get_explanations(
475
+ db: PgConnection, zone_id: Optional[str] = None, limit: int = 20
476
+ ) -> list[dict]:
477
+ """Fetch explanations, optionally by zone."""
478
+ if db._pool:
479
+ if zone_id:
480
+ return await db.fetch(
481
+ "SELECT * FROM explanations WHERE zone_id = $1 ORDER BY generated_at DESC LIMIT $2",
482
+ zone_id, limit,
483
+ )
484
+ return await db.fetch(
485
+ "SELECT * FROM explanations ORDER BY generated_at DESC LIMIT $1",
486
+ limit,
487
+ )
488
+ elif db._memory:
489
+ filters = {"zone_id": zone_id} if zone_id else None
490
+ return db._memory.query("explanations", filters, limit=limit)
491
+ return []
492
+
493
+
494
+ # --- Notifications ---
495
+
496
+ async def insert_notification(db: PgConnection, notif: dict) -> Optional[int]:
497
+ """Insert a notification delivery record."""
498
+ if db._pool:
499
+ return await db.fetchval(
500
+ """
501
+ INSERT INTO notifications (explanation_id, zone_id, recipient, channel,
502
+ status, message_preview, message_sid, cost_estimate, error)
503
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
504
+ RETURNING id
505
+ """,
506
+ notif.get("explanation_id"), notif["zone_id"],
507
+ notif["recipient"], notif["channel"],
508
+ notif["status"], notif.get("message_preview"),
509
+ notif.get("message_sid"), notif.get("cost_estimate", 0.0),
510
+ notif.get("error"),
511
+ )
512
+ elif db._memory:
513
+ return db._memory.insert("notifications", notif)
514
+ return None
515
+
516
+
517
+ async def get_notifications(
518
+ db: PgConnection, zone_id: Optional[str] = None, limit: int = 50
519
+ ) -> list[dict]:
520
+ """Fetch notification records."""
521
+ if db._pool:
522
+ if zone_id:
523
+ return await db.fetch(
524
+ "SELECT * FROM notifications WHERE zone_id = $1 ORDER BY sent_at DESC LIMIT $2",
525
+ zone_id, limit,
526
+ )
527
+ return await db.fetch(
528
+ "SELECT * FROM notifications ORDER BY sent_at DESC LIMIT $1",
529
+ limit,
530
+ )
531
+ elif db._memory:
532
+ filters = {"zone_id": zone_id} if zone_id else None
533
+ return db._memory.query("notifications", filters, limit=limit)
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:
585
+ """Record the start of a pipeline run. Returns run_id."""
586
+ rid = run_id or f"run-{uuid.uuid4().hex[:12]}"
587
+ now = datetime.now(timezone.utc).isoformat()
588
+
589
+ if db._pool:
590
+ await db.execute(
591
+ """
592
+ INSERT INTO pipeline_runs (run_id, started_at, status)
593
+ VALUES ($1, $2, 'running')
594
+ """,
595
+ rid, now,
596
+ )
597
+ elif db._memory:
598
+ db._memory.insert("pipeline_runs", {
599
+ "run_id": rid, "started_at": now, "status": "running",
600
+ })
601
+ return rid
602
+
603
+
604
+ async def finish_pipeline_run(
605
+ db: PgConnection,
606
+ run_id: str,
607
+ status: str = "completed",
608
+ steps_completed: Optional[list[str]] = None,
609
+ step_status: Optional[dict] = None,
610
+ error: Optional[str] = None,
611
+ zones_processed: int = 0,
612
+ ) -> None:
613
+ """Record the completion of a pipeline run."""
614
+ now = datetime.now(timezone.utc).isoformat()
615
+
616
+ if db._pool:
617
+ await db.execute(
618
+ """
619
+ UPDATE pipeline_runs
620
+ SET finished_at = $2, status = $3, steps_completed = $4,
621
+ step_status = $5, error = $6, zones_processed = $7
622
+ WHERE run_id = $1
623
+ """,
624
+ run_id, now, status,
625
+ steps_completed or [],
626
+ json.dumps(step_status or {}),
627
+ error, zones_processed,
628
+ )
629
+ elif db._memory:
630
+ for row in db._memory.tables.get("pipeline_runs", []):
631
+ if row.get("run_id") == run_id:
632
+ row["finished_at"] = now
633
+ row["status"] = status
634
+ row["steps_completed"] = steps_completed or []
635
+ row["step_status"] = step_status or {}
636
+ row["error"] = error
637
+ row["zones_processed"] = zones_processed
638
+ break
639
+
640
+
641
+ async def get_recent_runs(db: PgConnection, limit: int = 10) -> list[dict]:
642
+ """Fetch recent pipeline runs."""
643
+ if db._pool:
644
+ return await db.fetch(
645
+ "SELECT * FROM pipeline_runs ORDER BY started_at DESC LIMIT $1",
646
+ limit,
647
+ )
648
+ elif db._memory:
649
+ rows = db._memory.query("pipeline_runs", limit=limit)
650
+ return sorted(rows, key=lambda r: r.get("started_at", ""), reverse=True)
651
+ return []
src/database/schema.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
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
+
9
+ from __future__ import annotations
10
+
11
+
12
+ # ── Table creation order (respects foreign keys) ─────────────────────────
13
+
14
+ TABLES_ORDERED: list[str] = [
15
+ "zones",
16
+ "daily_readings",
17
+ "healed_readings",
18
+ "healing_log",
19
+ "monthly_indices",
20
+ "flood_risk_scores",
21
+ "enrolled_policies",
22
+ "trigger_events",
23
+ "basis_risk",
24
+ "explanations",
25
+ "notifications",
26
+ "pipeline_runs",
27
+ ]
28
+
29
+
30
+ # ── DDL statements ───────────────────────────────────────────────────────
31
+
32
+ CREATE_ZONES = """
33
+ CREATE TABLE IF NOT EXISTS zones (
34
+ zone_id TEXT PRIMARY KEY,
35
+ name TEXT NOT NULL,
36
+ city TEXT NOT NULL,
37
+ country TEXT NOT NULL,
38
+ latitude DOUBLE PRECISION NOT NULL,
39
+ longitude DOUBLE PRECISION NOT NULL,
40
+ elevation_m DOUBLE PRECISION,
41
+ area_km2 DOUBLE PRECISION,
42
+ population_est INTEGER,
43
+ settlement_type TEXT NOT NULL CHECK (settlement_type IN ('formal', 'informal', 'mixed', 'commercial')),
44
+ flood_susceptibility TEXT NOT NULL CHECK (flood_susceptibility IN ('high', 'moderate', 'low')),
45
+ drainage_quality TEXT NOT NULL CHECK (drainage_quality IN ('poor', 'moderate', 'good')),
46
+ primary_flood_type TEXT,
47
+ rainy_seasons INTEGER[],
48
+ notes TEXT DEFAULT '',
49
+ created_at TIMESTAMPTZ DEFAULT NOW()
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 = """
57
+ 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)
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS idx_daily_readings_zone_date ON daily_readings (zone_id, date DESC);
77
+ CREATE INDEX IF NOT EXISTS idx_daily_readings_date ON daily_readings (date DESC);
78
+ """
79
+
80
+ CREATE_HEALED_READINGS = """
81
+ CREATE TABLE IF NOT EXISTS healed_readings (
82
+ id BIGSERIAL PRIMARY KEY,
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,
90
+ humidity_pct DOUBLE PRECISION,
91
+ wind_speed_ms DOUBLE PRECISION,
92
+ quality_score DOUBLE PRECISION DEFAULT 0.0,
93
+ heal_action TEXT DEFAULT 'passthrough',
94
+ fields_corrected TEXT[] DEFAULT '{}',
95
+ healed_at TIMESTAMPTZ DEFAULT NOW(),
96
+ UNIQUE (zone_id, date)
97
+ );
98
+
99
+ CREATE INDEX IF NOT EXISTS idx_healed_readings_zone_date ON healed_readings (zone_id, date DESC);
100
+ CREATE INDEX IF NOT EXISTS idx_healed_readings_quality ON healed_readings (quality_score);
101
+ """
102
+
103
+ CREATE_HEALING_LOG = """
104
+ CREATE TABLE IF NOT EXISTS healing_log (
105
+ id BIGSERIAL PRIMARY KEY,
106
+ zone_id TEXT NOT NULL REFERENCES zones(zone_id),
107
+ date DATE NOT NULL,
108
+ healed_reading_id BIGINT REFERENCES healed_readings(id),
109
+ agent_type TEXT DEFAULT 'rule_based',
110
+ reasoning TEXT,
111
+ corrections JSONB DEFAULT '{}',
112
+ tools_used TEXT[] DEFAULT '{}',
113
+ confidence DOUBLE PRECISION,
114
+ tokens_used INTEGER DEFAULT 0,
115
+ latency_ms INTEGER DEFAULT 0,
116
+ created_at TIMESTAMPTZ DEFAULT NOW()
117
+ );
118
+
119
+ CREATE INDEX IF NOT EXISTS idx_healing_log_zone ON healing_log (zone_id, date DESC);
120
+ """
121
+
122
+ CREATE_MONTHLY_INDICES = """
123
+ CREATE TABLE IF NOT EXISTS monthly_indices (
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
+ composite_score DOUBLE PRECISION NOT NULL CHECK (composite_score BETWEEN 0 AND 1),
148
+ precip_score DOUBLE PRECISION,
149
+ api_5day_score DOUBLE PRECISION,
150
+ spi_score DOUBLE PRECISION,
151
+ drainage_factor DOUBLE PRECISION,
152
+ settlement_factor DOUBLE PRECISION,
153
+ risk_level TEXT CHECK (risk_level IN ('low', 'moderate', 'high', 'critical')),
154
+ contributing_factors TEXT[] DEFAULT '{}',
155
+ computed_at TIMESTAMPTZ DEFAULT NOW(),
156
+ UNIQUE (zone_id, date)
157
+ );
158
+
159
+ CREATE INDEX IF NOT EXISTS idx_flood_risk_zone_date ON flood_risk_scores (zone_id, date DESC);
160
+ CREATE INDEX IF NOT EXISTS idx_flood_risk_level ON flood_risk_scores (risk_level);
161
+ """
162
+
163
+ CREATE_ENROLLED_POLICIES = """
164
+ CREATE TABLE IF NOT EXISTS enrolled_policies (
165
+ id BIGSERIAL PRIMARY KEY,
166
+ policy_number TEXT UNIQUE NOT NULL,
167
+ zone_id TEXT NOT NULL REFERENCES zones(zone_id),
168
+ holder_name TEXT NOT NULL,
169
+ holder_phone TEXT,
170
+ holder_email TEXT,
171
+ settlement_type TEXT NOT NULL CHECK (settlement_type IN ('formal', 'informal', 'mixed', 'commercial', 'residential')),
172
+ premium_kes DOUBLE PRECISION,
173
+ coverage_start DATE NOT NULL,
174
+ coverage_end DATE NOT NULL,
175
+ payment_method TEXT DEFAULT 'mpesa' CHECK (payment_method IN ('mpesa', 'bank', 'cash')),
176
+ is_active BOOLEAN DEFAULT TRUE,
177
+ created_at TIMESTAMPTZ DEFAULT NOW(),
178
+ updated_at TIMESTAMPTZ DEFAULT NOW()
179
+ );
180
+
181
+ CREATE INDEX IF NOT EXISTS idx_policies_zone ON enrolled_policies (zone_id);
182
+ CREATE INDEX IF NOT EXISTS idx_policies_active ON enrolled_policies (is_active) WHERE is_active = TRUE;
183
+ CREATE INDEX IF NOT EXISTS idx_policies_phone ON enrolled_policies (holder_phone);
184
+ """
185
+
186
+ CREATE_TRIGGER_EVENTS = """
187
+ CREATE TABLE IF NOT EXISTS trigger_events (
188
+ id BIGSERIAL PRIMARY KEY,
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
+ daily_precip_mm DOUBLE PRECISION,
193
+ api_5day_mm DOUBLE PRECISION,
194
+ spi_1month DOUBLE PRECISION,
195
+ composite_score DOUBLE PRECISION,
196
+ contributing_factors TEXT[] DEFAULT '{}',
197
+ risk_score_id BIGINT REFERENCES flood_risk_scores(id),
198
+ resolved_at TIMESTAMPTZ,
199
+ resolution_notes TEXT,
200
+ created_at TIMESTAMPTZ DEFAULT NOW()
201
+ );
202
+
203
+ CREATE INDEX IF NOT EXISTS idx_trigger_events_zone ON trigger_events (zone_id, triggered_at DESC);
204
+ CREATE INDEX IF NOT EXISTS idx_trigger_events_level ON trigger_events (trigger_level);
205
+ CREATE INDEX IF NOT EXISTS idx_trigger_events_unresolved ON trigger_events (zone_id) WHERE resolved_at IS NULL;
206
+ """
207
+
208
+ CREATE_BASIS_RISK = """
209
+ CREATE TABLE IF NOT EXISTS basis_risk (
210
+ id BIGSERIAL PRIMARY KEY,
211
+ zone_id TEXT NOT NULL REFERENCES zones(zone_id),
212
+ assessed_at TIMESTAMPTZ DEFAULT NOW(),
213
+ overall_score DOUBLE PRECISION NOT NULL CHECK (overall_score BETWEEN 0 AND 1),
214
+ false_positive_rate DOUBLE PRECISION,
215
+ false_negative_rate DOUBLE PRECISION,
216
+ correlation DOUBLE PRECISION,
217
+ mae DOUBLE PRECISION,
218
+ total_events INTEGER,
219
+ true_positives INTEGER,
220
+ true_negatives INTEGER,
221
+ false_positives INTEGER,
222
+ false_negatives INTEGER,
223
+ trigger_accuracy DOUBLE PRECISION,
224
+ tier_accuracy JSONB DEFAULT '{}',
225
+ recommendations TEXT[] DEFAULT '{}',
226
+ confidence_low DOUBLE PRECISION,
227
+ confidence_high DOUBLE PRECISION
228
+ );
229
+
230
+ CREATE INDEX IF NOT EXISTS idx_basis_risk_zone ON basis_risk (zone_id, assessed_at DESC);
231
+ """
232
+
233
+ CREATE_EXPLANATIONS = """
234
+ CREATE TABLE IF NOT EXISTS explanations (
235
+ id BIGSERIAL PRIMARY KEY,
236
+ trigger_event_id BIGINT REFERENCES trigger_events(id),
237
+ zone_id TEXT NOT NULL REFERENCES zones(zone_id),
238
+ trigger_level TEXT NOT NULL,
239
+ english_text TEXT NOT NULL,
240
+ swahili_text TEXT NOT NULL,
241
+ payout_amount DOUBLE PRECISION,
242
+ payout_currency TEXT DEFAULT 'KES',
243
+ settlement_type TEXT,
244
+ protective_actions TEXT[] DEFAULT '{}',
245
+ provider TEXT DEFAULT 'template',
246
+ generated_at TIMESTAMPTZ DEFAULT NOW()
247
+ );
248
+
249
+ CREATE INDEX IF NOT EXISTS idx_explanations_trigger ON explanations (trigger_event_id);
250
+ CREATE INDEX IF NOT EXISTS idx_explanations_zone ON explanations (zone_id, generated_at DESC);
251
+ """
252
+
253
+ CREATE_NOTIFICATIONS = """
254
+ CREATE TABLE IF NOT EXISTS notifications (
255
+ id BIGSERIAL PRIMARY KEY,
256
+ explanation_id BIGINT REFERENCES explanations(id),
257
+ zone_id TEXT NOT NULL REFERENCES zones(zone_id),
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,
264
+ error TEXT,
265
+ sent_at TIMESTAMPTZ DEFAULT NOW()
266
+ );
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 = """
274
+ CREATE TABLE IF NOT EXISTS pipeline_runs (
275
+ id BIGSERIAL PRIMARY KEY,
276
+ run_id TEXT UNIQUE NOT NULL,
277
+ started_at TIMESTAMPTZ NOT NULL,
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 '{}'
286
+ );
287
+
288
+ CREATE INDEX IF NOT EXISTS idx_pipeline_runs_status ON pipeline_runs (status);
289
+ CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started ON pipeline_runs (started_at DESC);
290
+ """
291
+
292
+
293
+ # ── Aggregate DDL ────────────────────────────────────────────────────────
294
+
295
+ ALL_DDL: dict[str, str] = {
296
+ "zones": CREATE_ZONES,
297
+ "daily_readings": CREATE_DAILY_READINGS,
298
+ "healed_readings": CREATE_HEALED_READINGS,
299
+ "healing_log": CREATE_HEALING_LOG,
300
+ "monthly_indices": CREATE_MONTHLY_INDICES,
301
+ "flood_risk_scores": CREATE_FLOOD_RISK_SCORES,
302
+ "enrolled_policies": CREATE_ENROLLED_POLICIES,
303
+ "trigger_events": CREATE_TRIGGER_EVENTS,
304
+ "basis_risk": CREATE_BASIS_RISK,
305
+ "explanations": CREATE_EXPLANATIONS,
306
+ "notifications": CREATE_NOTIFICATIONS,
307
+ "pipeline_runs": CREATE_PIPELINE_RUNS,
308
+ }
309
+
310
+
311
+ def get_full_ddl() -> str:
312
+ """Return the complete DDL script to create all tables in order."""
313
+ parts = [f"-- Table: {name}\n{ddl}" for name, ddl in ALL_DDL.items()]
314
+ return "\n\n".join(parts)
315
+
316
+
317
+ def get_table_names() -> list[str]:
318
+ """Return table names in creation order."""
319
+ return list(TABLES_ORDERED)
src/downscaling/__init__.py ADDED
File without changes
src/downscaling/uhi_model.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Urban Heat Island (UHI) ML correction model.
3
+
4
+ Adjusts ERA5-Land grid-cell temperature to neighborhood-level using
5
+ zone characteristics (settlement type, outdoor exposure, elevation)
6
+ and temporal features (hour, month).
7
+
8
+ Calibrated to published UHI literature for tropical African cities:
9
+ - Informal settlements (tin roofs, no shade): +3 to +6 C
10
+ - Mixed zones: +1 to +3 C
11
+ - Formal residential: +0.5 to +1.5 C
12
+ - Commercial districts: +1 to +2 C
13
+
14
+ References:
15
+ - Oke et al. (2017) Urban Climates, Cambridge University Press
16
+ - Scott et al. (2017) UHI in African cities, Urban Climate
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+
25
+ try:
26
+ import xgboost as xgb
27
+ except ImportError:
28
+ xgb = None
29
+
30
+ # Settlement type encoding order (for one-hot)
31
+ SETTLEMENT_TYPES = ["informal", "mixed", "formal", "commercial"]
32
+
33
+ # UHI delta ranges by settlement type (daytime peak, deg C)
34
+ UHI_RANGES = {
35
+ "informal": (3.0, 6.0),
36
+ "mixed": (1.0, 3.0),
37
+ "formal": (0.5, 1.5),
38
+ "commercial": (1.0, 2.0),
39
+ }
40
+
41
+ # Vulnerability multipliers
42
+ VULNERABILITY_MULT = {"high": 1.15, "moderate": 1.0, "low": 0.85}
43
+
44
+
45
+ def _resolve_model_path(model_path: str) -> Path:
46
+ """Resolve model path relative to the project root."""
47
+ p = Path(model_path)
48
+ if not p.is_absolute():
49
+ p = Path(__file__).resolve().parents[2] / model_path
50
+ return p
51
+
52
+
53
+ class UHICorrector:
54
+ """XGBoost model: zone characteristics -> temperature delta from grid."""
55
+
56
+ def __init__(self, model_path: str = "models/uhi_xgb.json"):
57
+ if xgb is None:
58
+ raise ImportError(
59
+ "xgboost is required. Install with: pip install 'xgboost>=2.0.0'"
60
+ )
61
+ self.model_path = _resolve_model_path(model_path)
62
+ self.model: xgb.XGBRegressor | None = None
63
+ self._load_or_train()
64
+
65
+ # ------------------------------------------------------------------
66
+ # Public API
67
+ # ------------------------------------------------------------------
68
+
69
+ def correct_temperature(
70
+ self,
71
+ zone,
72
+ grid_temp_c: float,
73
+ hour: int = 12,
74
+ month: int = 3,
75
+ ) -> tuple[float, float, float]:
76
+ """Return (corrected_temp, uhi_delta, confidence).
77
+
78
+ Args:
79
+ zone: An UrbanZone instance (from config.ZONES).
80
+ grid_temp_c: ERA5-Land grid temperature in deg C.
81
+ hour: Local hour (0-23).
82
+ month: Month (1-12).
83
+
84
+ Returns:
85
+ Tuple of (corrected temperature, UHI delta, confidence 0-1).
86
+ """
87
+ features = self._build_features(zone, hour, month)
88
+ delta = float(self.model.predict(features)[0])
89
+
90
+ # Confidence based on how well the zone matches training regime
91
+ confidence = self._estimate_confidence(zone, hour, month)
92
+
93
+ return round(grid_temp_c + delta, 2), round(delta, 2), round(confidence, 3)
94
+
95
+ # ------------------------------------------------------------------
96
+ # Training
97
+ # ------------------------------------------------------------------
98
+
99
+ def train(self, n_samples: int = 5000, seed: int = 42) -> None:
100
+ """Generate synthetic training data and train the XGBoost model.
101
+
102
+ Creates samples from the 20 configured zones across varying
103
+ hours (0-23) and months (1-12), with UHI deltas drawn from
104
+ literature-calibrated distributions.
105
+ """
106
+ rng = np.random.default_rng(seed)
107
+
108
+ # Import zones lazily to avoid circular imports at module level
109
+ from config import ZONES # noqa: WPS433
110
+
111
+ X_rows = []
112
+ y_rows = []
113
+
114
+ samples_per_zone = n_samples // len(ZONES)
115
+ extra = n_samples - samples_per_zone * len(ZONES)
116
+
117
+ for idx, zone in enumerate(ZONES):
118
+ n = samples_per_zone + (1 if idx < extra else 0)
119
+ hours = rng.integers(0, 24, size=n)
120
+ months = rng.integers(1, 13, size=n)
121
+
122
+ for h, m in zip(hours, months):
123
+ feat = self._build_features(zone, int(h), int(m))
124
+ X_rows.append(feat[0])
125
+
126
+ delta = self._synthetic_delta(zone, int(h), int(m), rng)
127
+ y_rows.append(delta)
128
+
129
+ X = np.array(X_rows, dtype=np.float32)
130
+ y = np.array(y_rows, dtype=np.float32)
131
+
132
+ self.model = xgb.XGBRegressor(
133
+ n_estimators=100,
134
+ max_depth=4,
135
+ learning_rate=0.1,
136
+ random_state=seed,
137
+ )
138
+ self.model.fit(X, y)
139
+ self._save_model()
140
+
141
+ # ------------------------------------------------------------------
142
+ # Feature engineering
143
+ # ------------------------------------------------------------------
144
+
145
+ def _build_features(self, zone, hour: int, month: int) -> np.ndarray:
146
+ """Construct feature vector from zone + time.
147
+
148
+ Features (12 total):
149
+ 0-3: settlement_type one-hot (informal, mixed, formal, commercial)
150
+ 4: outdoor_exposure_pct
151
+ 5: elevation_m (scaled /1000)
152
+ 6: heat_vulnerability encoded (high=1, moderate=0.5, low=0)
153
+ 7-8: hour_sin, hour_cos
154
+ 9-10: month_sin, month_cos
155
+ 11: outdoor_exposure * vulnerability interaction
156
+ """
157
+ # Settlement one-hot
158
+ onehot = [0.0] * len(SETTLEMENT_TYPES)
159
+ if zone.settlement_type in SETTLEMENT_TYPES:
160
+ onehot[SETTLEMENT_TYPES.index(zone.settlement_type)] = 1.0
161
+
162
+ # Vulnerability numeric
163
+ vuln_map = {"high": 1.0, "moderate": 0.5, "low": 0.0}
164
+ vuln = vuln_map.get(zone.heat_vulnerability, 0.5)
165
+
166
+ # Cyclic time encoding
167
+ hour_sin = np.sin(2 * np.pi * hour / 24.0)
168
+ hour_cos = np.cos(2 * np.pi * hour / 24.0)
169
+ month_sin = np.sin(2 * np.pi * (month - 1) / 12.0)
170
+ month_cos = np.cos(2 * np.pi * (month - 1) / 12.0)
171
+
172
+ # Interaction term
173
+ interaction = zone.outdoor_exposure_pct * vuln
174
+
175
+ features = np.array(
176
+ onehot
177
+ + [
178
+ zone.outdoor_exposure_pct,
179
+ zone.elevation_m / 1000.0,
180
+ vuln,
181
+ hour_sin,
182
+ hour_cos,
183
+ month_sin,
184
+ month_cos,
185
+ interaction,
186
+ ],
187
+ dtype=np.float32,
188
+ ).reshape(1, -1)
189
+
190
+ return features
191
+
192
+ # ------------------------------------------------------------------
193
+ # Synthetic data generation
194
+ # ------------------------------------------------------------------
195
+
196
+ @staticmethod
197
+ def _synthetic_delta(zone, hour: int, month: int, rng) -> float:
198
+ """Generate a single UHI delta calibrated to literature.
199
+
200
+ Key drivers:
201
+ 1. Settlement type (biggest factor) — sets the base range.
202
+ 2. Time of day — UHI peaks at night (urban surfaces release
203
+ stored heat), smaller during daytime due to rural insolation.
204
+ 3. Season — hot dry months amplify UHI; cool/wet months dampen.
205
+ 4. Elevation — higher elevation slightly reduces UHI.
206
+ """
207
+ lo, hi = UHI_RANGES.get(zone.settlement_type, (1.0, 3.0))
208
+ base = rng.uniform(lo, hi)
209
+
210
+ # Nocturnal amplification: UHI peaks around 22:00-04:00
211
+ # Minimum around 10:00-14:00 (rural catches up via insolation)
212
+ hour_factor = 1.0 + 0.35 * np.cos(2 * np.pi * (hour - 1) / 24.0)
213
+
214
+ # Seasonal modulation: hot months get +10-15%, cool months -10%
215
+ is_hot_month = month in getattr(zone, "hot_months", [])
216
+ season_factor = 1.12 if is_hot_month else 0.90
217
+
218
+ # Elevation damping: higher = slightly less UHI
219
+ elev_factor = max(0.7, 1.0 - zone.elevation_m / 5000.0)
220
+
221
+ # Vulnerability/exposure boost
222
+ vuln_mult = VULNERABILITY_MULT.get(zone.heat_vulnerability, 1.0)
223
+
224
+ delta = base * hour_factor * season_factor * elev_factor * vuln_mult
225
+ # Add noise
226
+ delta += rng.normal(0, 0.3)
227
+
228
+ return float(max(0.0, delta))
229
+
230
+ # ------------------------------------------------------------------
231
+ # Confidence estimation
232
+ # ------------------------------------------------------------------
233
+
234
+ @staticmethod
235
+ def _estimate_confidence(zone, hour: int, month: int) -> float:
236
+ """Heuristic confidence score (0-1).
237
+
238
+ Higher confidence for:
239
+ - Informal/mixed settlements (most UHI literature covers these)
240
+ - Daytime hours (better-studied regime)
241
+ - Hot-season months (more extreme, easier to model)
242
+ """
243
+ conf = 0.75 # baseline
244
+
245
+ # Settlement coverage
246
+ if zone.settlement_type in ("informal", "mixed"):
247
+ conf += 0.10
248
+ elif zone.settlement_type == "commercial":
249
+ conf += 0.05
250
+
251
+ # Daytime boost
252
+ if 6 <= hour <= 18:
253
+ conf += 0.05
254
+
255
+ # Hot season
256
+ if month in getattr(zone, "hot_months", []):
257
+ conf += 0.05
258
+
259
+ return min(conf, 0.95)
260
+
261
+ # ------------------------------------------------------------------
262
+ # Persistence
263
+ # ------------------------------------------------------------------
264
+
265
+ def _save_model(self) -> None:
266
+ self.model_path.parent.mkdir(parents=True, exist_ok=True)
267
+ self.model.save_model(str(self.model_path))
268
+
269
+ def _load_or_train(self) -> None:
270
+ if self.model_path.exists():
271
+ self.model = xgb.XGBRegressor()
272
+ self.model.load_model(str(self.model_path))
273
+ else:
274
+ self.train()
src/explanation/__init__.py ADDED
File without changes
src/explanation/explainer.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Claude-powered heat alert explanation generator.
3
+
4
+ When a heat risk trigger fires, enrolled workers need a plain-language
5
+ explanation of WHY, what it means for their payout, and what protective
6
+ actions to take. This module generates bilingual (English + Swahili)
7
+ explanations using Claude with RAG context, with a template-based
8
+ fallback if Claude is unavailable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Dict, Optional
18
+
19
+ from config import HEAT_THRESHOLDS, PAYOUT_PER_EVENT_USD, ZONE_MAP, UrbanZone
20
+ from src.explanation.knowledge_base import (
21
+ INSURANCE_PRODUCT_INFO,
22
+ SWAHILI_TERMS,
23
+ get_emergency_contacts,
24
+ get_protective_actions,
25
+ get_zone_context,
26
+ )
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ # -- Data containers -------------------------------------------------------
32
+
33
+ @dataclass
34
+ class TriggerEvent:
35
+ """A triggered insurance event to be explained."""
36
+ zone_id: str
37
+ trigger_level: str # critical, warning, watch
38
+ triggered_at: str # ISO timestamp
39
+ max_temp_c: float = 0.0
40
+ max_wbgt_c: float = 0.0
41
+ consecutive_days: int = 0
42
+ heat_risk_score: float = 0.0
43
+ contributing_factors: list[str] = field(default_factory=list)
44
+
45
+
46
+ @dataclass
47
+ class ExplanationResult:
48
+ """Generated explanation for a trigger event."""
49
+ zone_id: str
50
+ trigger_level: str
51
+ english_text: str
52
+ swahili_text: str
53
+ payout_estimate: Dict[str, Any]
54
+ protective_actions: list[str]
55
+ emergency_contacts: Dict[str, str]
56
+ generated_at: str = field(
57
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
58
+ )
59
+ provider: str = "template" # claude or template
60
+ zone_name: str = ""
61
+ city: str = ""
62
+ zone_context: str = ""
63
+ tokens_used: int = 0
64
+
65
+
66
+ # -- Claude-based explainer ------------------------------------------------
67
+
68
+ class TriggerExplainer:
69
+ """Generates bilingual heat alert explanations using Claude with RAG."""
70
+
71
+ def __init__(self, api_key: Optional[str] = None, model: str = "claude-sonnet-4-20250514"):
72
+ self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
73
+ self.model = model
74
+ self._client = None
75
+
76
+ def _get_client(self):
77
+ if self._client is None:
78
+ try:
79
+ import anthropic
80
+ self._client = anthropic.AsyncAnthropic(api_key=self.api_key)
81
+ except Exception as exc:
82
+ log.warning("Could not init Anthropic client: %s", exc)
83
+ return self._client
84
+
85
+ async def explain(self, event, zone=None, basis_risk=None) -> ExplanationResult:
86
+ """Generate a full explanation for a heat trigger event."""
87
+ # Support both TriggerEvent and HeatTriggerEvent dataclass inputs
88
+ zone_id = getattr(event, 'zone_id', '')
89
+ trigger_level = getattr(event, 'trigger_level', 'watch')
90
+
91
+ zone_obj = zone or ZONE_MAP.get(zone_id)
92
+ if zone_obj is None:
93
+ raise ValueError(f"Unknown zone: {zone_id}")
94
+
95
+ payout = _compute_payout(event, zone_obj)
96
+ actions = get_protective_actions(trigger_level)
97
+ contacts = get_emergency_contacts(zone_obj.city)
98
+
99
+ if self.api_key:
100
+ try:
101
+ english, swahili = await self._generate_claude(event, zone_obj, payout)
102
+ return ExplanationResult(
103
+ zone_id=zone_id,
104
+ trigger_level=trigger_level,
105
+ english_text=english,
106
+ swahili_text=swahili,
107
+ payout_estimate=payout,
108
+ protective_actions=actions,
109
+ emergency_contacts=contacts,
110
+ provider="claude",
111
+ zone_name=zone_obj.name,
112
+ city=zone_obj.city,
113
+ zone_context=get_zone_context(zone_id),
114
+ )
115
+ except Exception as exc:
116
+ log.warning("Claude explanation failed, using template: %s", exc)
117
+
118
+ english = _template_english(event, zone_obj, payout)
119
+ swahili = _template_swahili(event, zone_obj, payout)
120
+ return ExplanationResult(
121
+ zone_id=zone_id,
122
+ trigger_level=trigger_level,
123
+ english_text=english,
124
+ swahili_text=swahili,
125
+ payout_estimate=payout,
126
+ protective_actions=actions,
127
+ emergency_contacts=contacts,
128
+ provider="template",
129
+ zone_name=zone_obj.name,
130
+ city=zone_obj.city,
131
+ zone_context=get_zone_context(zone_id),
132
+ )
133
+
134
+ async def _generate_claude(
135
+ self, event, zone: UrbanZone, payout: Dict[str, Any]
136
+ ) -> tuple[str, str]:
137
+ context = get_zone_context(event.zone_id)
138
+
139
+ system = (
140
+ "You are a heat safety notification system for outdoor workers in East Africa. "
141
+ "Your audience is workers in markets, construction sites, and informal "
142
+ "settlements — many with limited formal education. Write clearly, simply, "
143
+ "and with empathy. Do not use jargon or technical abbreviations. "
144
+ "Explain what happened, what their payout is, and what they should do to "
145
+ "stay safe. Keep the explanation to 4-6 sentences."
146
+ )
147
+
148
+ max_temp = getattr(event, 'max_temp_c', 0)
149
+ max_wbgt = getattr(event, 'max_wbgt_c', 0)
150
+ consec = getattr(event, 'consecutive_days', getattr(event, 'consecutive_days_above', 0))
151
+ thresholds = HEAT_THRESHOLDS.get(event.trigger_level, {})
152
+
153
+ user = (
154
+ f"A {event.trigger_level.upper()} heat alert has been triggered for "
155
+ f"outdoor workers in {zone.name}, {zone.city}.\n\n"
156
+ f"What happened:\n"
157
+ f"- Maximum temperature: {max_temp:.1f}C "
158
+ f"(threshold: {thresholds.get('temp_c', 'N/A')}C)\n"
159
+ f"- Maximum WBGT (feels-like for workers): {max_wbgt:.1f}C "
160
+ f"(threshold: {thresholds.get('wbgt_c', 'N/A')}C)\n"
161
+ f"- Consecutive days above threshold: {consec} "
162
+ f"(required: {thresholds.get('consecutive_days', 'N/A')})\n"
163
+ f"- Settlement type: {zone.settlement_type}\n"
164
+ f"- Outdoor worker exposure: {zone.outdoor_exposure_pct:.0%}\n"
165
+ f"- Estimated workers affected: {zone.worker_population_est:,}\n\n"
166
+ f"Payout: {payout['currency_symbol']}{payout['amount']} per worker\n\n"
167
+ f"Knowledge base:\n{context}\n\n"
168
+ f"Write a 4-6 sentence explanation for the worker. Tell them what is "
169
+ f"happening with the heat, their payout amount, and the most important "
170
+ f"thing they should do right now to stay safe."
171
+ )
172
+
173
+ client = self._get_client()
174
+ if client is None:
175
+ raise RuntimeError("Anthropic client not available")
176
+
177
+ msg = await client.messages.create(
178
+ model=self.model,
179
+ max_tokens=500,
180
+ system=system,
181
+ messages=[{"role": "user", "content": user}],
182
+ )
183
+ english = msg.content[0].text.strip()
184
+ swahili = await self._translate_to_swahili(english, zone.name)
185
+ return english, swahili
186
+
187
+ async def _translate_to_swahili(self, english_text: str, zone_name: str) -> str:
188
+ system = (
189
+ "You are a professional translator specializing in Swahili (Kiswahili). "
190
+ "Translate the given English heat safety notification to simple, clear "
191
+ "Swahili that a non-technical person can understand. Keep numbers, "
192
+ "currency amounts, and proper nouns unchanged. Return only the translated text."
193
+ )
194
+ user = (
195
+ f"Translate this heat alert notification for workers in {zone_name} "
196
+ f"to Swahili:\n\n{english_text}"
197
+ )
198
+
199
+ client = self._get_client()
200
+ if client is None:
201
+ raise RuntimeError("Anthropic client not available")
202
+
203
+ msg = await client.messages.create(
204
+ model=self.model,
205
+ max_tokens=600,
206
+ system=system,
207
+ messages=[{"role": "user", "content": user}],
208
+ )
209
+ return msg.content[0].text.strip()
210
+
211
+
212
+ # -- Template-based fallback explainer (same interface) --------------------
213
+
214
+ class TemplateExplainer:
215
+ """Template-based fallback when Claude is unavailable."""
216
+
217
+ async def explain(self, event, zone=None, basis_risk=None) -> ExplanationResult:
218
+ zone_id = getattr(event, 'zone_id', '')
219
+ trigger_level = getattr(event, 'trigger_level', 'watch')
220
+ zone_obj = zone or ZONE_MAP.get(zone_id)
221
+ if zone_obj is None:
222
+ raise ValueError(f"Unknown zone: {zone_id}")
223
+
224
+ payout = _compute_payout(event, zone_obj)
225
+ actions = get_protective_actions(trigger_level)
226
+ contacts = get_emergency_contacts(zone_obj.city)
227
+
228
+ english = _template_english(event, zone_obj, payout)
229
+ swahili = _template_swahili(event, zone_obj, payout)
230
+
231
+ return ExplanationResult(
232
+ zone_id=zone_id,
233
+ trigger_level=trigger_level,
234
+ english_text=english,
235
+ swahili_text=swahili,
236
+ payout_estimate=payout,
237
+ protective_actions=actions,
238
+ emergency_contacts=contacts,
239
+ provider="template",
240
+ zone_name=zone_obj.name,
241
+ city=zone_obj.city,
242
+ zone_context=get_zone_context(zone_id),
243
+ )
244
+
245
+
246
+ # -- Payout computation ---------------------------------------------------
247
+
248
+ def _compute_payout(event, zone: UrbanZone) -> Dict[str, Any]:
249
+ trigger_level = getattr(event, 'trigger_level', 'watch')
250
+ amount = PAYOUT_PER_EVENT_USD.get(trigger_level, 0)
251
+
252
+ return {
253
+ "amount": amount,
254
+ "currency": "USD",
255
+ "currency_symbol": "$",
256
+ "settlement_type": zone.settlement_type,
257
+ "trigger_level": trigger_level,
258
+ "delivery_method": "M-Pesa" if zone.settlement_type in ("informal", "mixed") else "Mobile money",
259
+ "expected_delivery": "Within 48 hours of trigger verification",
260
+ "is_payout": amount > 0,
261
+ "workers_covered": zone.worker_population_est,
262
+ "total_payout": amount * zone.worker_population_est,
263
+ }
264
+
265
+
266
+ # -- Template-based fallback explanations ----------------------------------
267
+
268
+ _LEVEL_DESCRIPTIONS = {
269
+ "critical": (
270
+ "A CRITICAL heat alert has been triggered",
271
+ "Onyo la HATARI la joto kali limetolewa",
272
+ ),
273
+ "warning": (
274
+ "A WARNING heat alert has been issued",
275
+ "Onyo la joto limetolewa",
276
+ ),
277
+ "watch": (
278
+ "A WATCH heat advisory has been issued",
279
+ "Tahadhari ya joto imetolewa",
280
+ ),
281
+ }
282
+
283
+
284
+ def _template_english(event, zone: UrbanZone, payout: Dict[str, Any]) -> str:
285
+ trigger_level = getattr(event, 'trigger_level', 'watch')
286
+ level_en, _ = _LEVEL_DESCRIPTIONS.get(trigger_level, ("A heat alert has been issued", ""))
287
+
288
+ max_temp = getattr(event, 'max_temp_c', 0)
289
+ max_wbgt = getattr(event, 'max_wbgt_c', 0)
290
+ consec = getattr(event, 'consecutive_days', getattr(event, 'consecutive_days_above', 0))
291
+
292
+ lines = [f"{level_en} for outdoor workers in {zone.name}, {zone.city}."]
293
+
294
+ thresholds = HEAT_THRESHOLDS.get(trigger_level, {})
295
+ reasons: list[str] = []
296
+ if max_temp >= thresholds.get("temp_c", 999):
297
+ reasons.append(
298
+ f"temperatures reached {max_temp:.0f}C, above the "
299
+ f"{thresholds['temp_c']}C safety threshold"
300
+ )
301
+ if max_wbgt >= thresholds.get("wbgt_c", 999):
302
+ reasons.append(
303
+ f"the heat-humidity index (WBGT) reached {max_wbgt:.0f}C, "
304
+ f"above the {thresholds['wbgt_c']}C danger level"
305
+ )
306
+ if consec >= thresholds.get("consecutive_days", 999):
307
+ reasons.append(
308
+ f"these dangerous conditions lasted {consec} consecutive days"
309
+ )
310
+
311
+ if reasons:
312
+ lines.append("This was triggered because " + " and ".join(reasons) + ".")
313
+ else:
314
+ lines.append("Heat conditions have exceeded the safety threshold for your area.")
315
+
316
+ if payout["is_payout"]:
317
+ lines.append(
318
+ f"Your payout is {payout['currency_symbol']}{payout['amount']} per worker, "
319
+ f"which will be sent via {payout['delivery_method']} within 48 hours."
320
+ )
321
+
322
+ actions = get_protective_actions(trigger_level)
323
+ if actions:
324
+ lines.append(f"Most important: {actions[0]}")
325
+
326
+ return " ".join(lines)
327
+
328
+
329
+ def _template_swahili(event, zone: UrbanZone, payout: Dict[str, Any]) -> str:
330
+ trigger_level = getattr(event, 'trigger_level', 'watch')
331
+ _, level_sw = _LEVEL_DESCRIPTIONS.get(trigger_level, ("", "Onyo la joto limetolewa"))
332
+
333
+ max_temp = getattr(event, 'max_temp_c', 0)
334
+ consec = getattr(event, 'consecutive_days', getattr(event, 'consecutive_days_above', 0))
335
+
336
+ lines = [f"{level_sw} kwa wafanyakazi wa nje katika {zone.name}, {zone.city}."]
337
+
338
+ lines.append(
339
+ f"Joto limefika {max_temp:.0f}C kwa siku {consec} mfululizo."
340
+ )
341
+
342
+ if payout["is_payout"]:
343
+ lines.append(
344
+ f"Malipo yako ni {payout['currency_symbol']}{payout['amount']} kwa kila mfanyakazi. "
345
+ f"Utapokea kupitia {payout['delivery_method']} ndani ya masaa 48."
346
+ )
347
+
348
+ if trigger_level == "critical":
349
+ lines.append(
350
+ "Acha kazi za nje sasa hivi. Nenda kivulini au ndani ya nyumba. "
351
+ "Kunywa maji mengi."
352
+ )
353
+ elif trigger_level == "warning":
354
+ lines.append(
355
+ "Punguza kazi za nje. Fanya kazi nzito asubuhi mapema au jioni. "
356
+ "Pumzika kivulini kila saa."
357
+ )
358
+ else:
359
+ lines.append(
360
+ "Kuwa makini. Kunywa maji zaidi. Panga kazi nzito kwa masaa ya baridi."
361
+ )
362
+
363
+ return " ".join(lines)
src/explanation/knowledge_base.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Curated knowledge base for the heat alert explanation RAG system.
3
+
4
+ Contains real guidance from WHO, ILO, and East African occupational
5
+ health authorities, adapted for extreme heat contexts in Nairobi,
6
+ Dar es Salaam, Kampala, and Kigali.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, List
12
+
13
+
14
+ # -- WHO/ILO heat stress guidance for outdoor workers ----------------------
15
+
16
+ HEAT_SAFETY_GUIDANCE: list[str] = [
17
+ # Hydration
18
+ "Drink water every 15-20 minutes even if you are not thirsty. "
19
+ "Do not wait until you feel thirsty — by then you are already dehydrated.",
20
+
21
+ "Carry at least 1 litre of clean water for every 2 hours of outdoor work. "
22
+ "Avoid alcohol, strong tea, and sugary drinks which increase dehydration.",
23
+
24
+ "Add a pinch of salt and sugar to drinking water to replace minerals "
25
+ "lost through sweating (oral rehydration solution).",
26
+
27
+ # Rest breaks
28
+ "Take a 10-minute rest break in shade every 45 minutes when temperature "
29
+ "exceeds 35C. In extreme heat above 37C, rest 15 minutes every 30 minutes.",
30
+
31
+ "Schedule heavy physical work (carrying, digging, construction) for early "
32
+ "morning (before 10am) or late afternoon (after 4pm). Avoid peak heat hours.",
33
+
34
+ "If possible, rotate between outdoor and indoor tasks throughout the day "
35
+ "to reduce cumulative heat exposure.",
36
+
37
+ # Shade and clothing
38
+ "Wear loose, light-colored clothing that covers the skin. A wide-brimmed "
39
+ "hat or head covering protects against direct sun and reduces heat stroke risk.",
40
+
41
+ "Work in shade whenever possible. If shade is not available, create "
42
+ "temporary shade using tarps, umbrellas, or fabric canopies.",
43
+
44
+ # Emergency signs (heat stroke)
45
+ "DANGER SIGNS — seek medical help immediately if someone shows: confusion "
46
+ "or strange behavior, inability to sweat despite heat, hot dry red skin, "
47
+ "seizures, unconsciousness, or body temperature above 40C.",
48
+
49
+ "If a co-worker collapses from heat: move them to shade immediately, pour "
50
+ "cool water over their body, fan them, and call for emergency help. Do not "
51
+ "give water to an unconscious person.",
52
+
53
+ # Adjusted work hours
54
+ "Employers should implement a heat action plan: shift start times earlier, "
55
+ "provide free drinking water stations, and designate shaded rest areas.",
56
+
57
+ "New workers and those returning after absence are at higher risk during "
58
+ "the first 1-2 weeks. Gradually increase workload over 7-14 days "
59
+ "(heat acclimatization).",
60
+ ]
61
+
62
+
63
+ # -- Zone-specific heat context -------------------------------------------
64
+
65
+ ZONE_HEAT_CONTEXT: Dict[str, str] = {
66
+ # Nairobi
67
+ "NBO-KIB": (
68
+ "Kibera's informal structures have corrugated tin roofs that absorb and "
69
+ "re-radiate solar heat, creating indoor temperatures 10-15C above ambient. "
70
+ "Most workers are outdoor traders, jua kali artisans, and transport workers. "
71
+ "The zone's elevation (1720m) keeps ambient temperatures moderate, but the "
72
+ "urban heat island effect is significant in the densely packed settlement."
73
+ ),
74
+ "NBO-EAS": (
75
+ "Eastleigh is a busy commercial district with thousands of street vendors "
76
+ "and market porters working outdoors on concrete and asphalt surfaces. "
77
+ "Heat radiating from buildings and pavement raises effective temperature. "
78
+ "Nairobi's highland climate provides some relief, but midday temperatures "
79
+ "during the dry season (Jan-Mar) can reach 28-30C."
80
+ ),
81
+ "NBO-MAT": (
82
+ "Mathare valley traps heat due to its low-lying topography and dense "
83
+ "settlement. Air circulation is poor between tightly packed structures. "
84
+ "Many residents work as construction laborers, scrap collectors, and "
85
+ "informal transport operators — all with high outdoor exposure."
86
+ ),
87
+ "NBO-SBC": (
88
+ "South B/C is a formal residential area where most workers commute to "
89
+ "indoor offices. Heat risk is lower but still affects outdoor guards, "
90
+ "gardeners, and maintenance workers during peak afternoon hours."
91
+ ),
92
+ "NBO-WES": (
93
+ "Westlands commercial district has air-conditioned offices and shopping "
94
+ "centers. Heat risk is minimal for most workers but affects outdoor "
95
+ "security guards, construction crews, and delivery riders."
96
+ ),
97
+ "NBO-KAN": (
98
+ "Kangemi has a large population of jua kali (literally 'fierce sun') "
99
+ "artisans — welders, carpenters, and mechanics who work outdoors with "
100
+ "heat-generating equipment. Combined with ambient heat, occupational "
101
+ "heat exposure can be significant."
102
+ ),
103
+
104
+ # Dar es Salaam
105
+ "DAR-JAN": (
106
+ "Jangwani is Dar es Salaam's most heat-vulnerable zone. Located at sea "
107
+ "level in an informal settlement with minimal shade and tin roofing, "
108
+ "ground-level temperatures regularly exceed 40C during hot months. "
109
+ "Humidity of 75-85% pushes WBGT into dangerous territory. Most workers "
110
+ "are outdoor laborers, market vendors, and waste collectors."
111
+ ),
112
+ "DAR-MSA": (
113
+ "Msasani has a large fish market and coastal trading area where workers "
114
+ "are exposed to direct sun reflected off the ocean surface. Coastal "
115
+ "humidity compounds heat stress. Salt processing and fish drying are "
116
+ "outdoor occupations with extended sun exposure."
117
+ ),
118
+ "DAR-KIN": (
119
+ "Kinondoni is more mixed with some tree cover, but humidity remains "
120
+ "consistently high (75-85%). Street vendors and bodaboda riders face "
121
+ "extended outdoor exposure. The concrete urban surface amplifies heat."
122
+ ),
123
+ "DAR-TEM": (
124
+ "Temeke is Dar es Salaam's industrial zone. Port workers, construction "
125
+ "crews, and factory workers in poorly ventilated structures face extreme "
126
+ "heat exposure. Heavy physical labor combined with high humidity creates "
127
+ "the city's highest occupational heat stress."
128
+ ),
129
+ "DAR-KIG": (
130
+ "Kigamboni peninsula has fishing, salt panning, and coastal construction "
131
+ "as primary outdoor occupations. Workers face combined heat from direct "
132
+ "sun, sea surface reflection, and high humidity. Limited shade on the "
133
+ "exposed peninsula."
134
+ ),
135
+
136
+ # Kampala
137
+ "KLA-BWA": (
138
+ "Bwaise is built on former wetland, creating humid conditions even by "
139
+ "Kampala standards. The low-lying settlement traps heat, and the high "
140
+ "water table increases humidity. Market traders and small manufacturers "
141
+ "work outdoors in congested conditions with poor air circulation."
142
+ ),
143
+ "KLA-NAT": (
144
+ "Natete market area has high concentrations of outdoor workers — porters, "
145
+ "vendors, and boda-boda riders. Concrete market surfaces store and "
146
+ "re-radiate heat. Lake Victoria moderates temperatures but also "
147
+ "maintains high humidity."
148
+ ),
149
+ "KLA-NAK": (
150
+ "Nakivubo commercial corridor includes the main taxi park where drivers "
151
+ "and conductors wait outdoors for extended periods. The concrete channel "
152
+ "and surrounding paved areas create a heat sink."
153
+ ),
154
+ "KLA-LUB": (
155
+ "Lubaga is on higher ground with some hilltop breezes that provide relief. "
156
+ "Heat risk is moderate, mainly affecting construction workers and "
157
+ "outdoor service providers."
158
+ ),
159
+ "KLA-MAK": (
160
+ "Makindye is elevated and has better tree cover than lower-lying areas. "
161
+ "Heat risk is lower, but outdoor security guards and gardeners in the "
162
+ "residential area still face extended exposure."
163
+ ),
164
+
165
+ # Kigali
166
+ "KGL-NYA": (
167
+ "Nyabugogo valley traps heat despite Kigali's highland elevation. The bus "
168
+ "terminal and surrounding market create a hot, congested environment. "
169
+ "Workers loading and unloading vehicles face combined heat stress from "
170
+ "sun exposure and vehicle exhaust."
171
+ ),
172
+ "KGL-KIC": (
173
+ "Kicukiro is a growing residential area with moderate heat exposure. "
174
+ "Construction workers on new building sites are the primary at-risk group. "
175
+ "Kigali's elevation (1520m) keeps temperatures manageable most of the year."
176
+ ),
177
+ "KGL-GAS": (
178
+ "Gasabo is the administrative district on higher ground. Air-conditioned "
179
+ "offices keep most workers comfortable. Outdoor workers (guards, cleaners, "
180
+ "construction) face moderate heat during dry season peaks."
181
+ ),
182
+ "KGL-NYM": (
183
+ "Nyamirambo is a dense residential hillside neighborhood. Market traders "
184
+ "and informal workers face heat stress during the dry season, particularly "
185
+ "in the lower, more sheltered areas where air circulation is limited."
186
+ ),
187
+ }
188
+
189
+
190
+ # -- Insurance product description ----------------------------------------
191
+
192
+ INSURANCE_PRODUCT_INFO: str = (
193
+ "This is a parametric heat insurance product for outdoor workers in East Africa. "
194
+ "Unlike traditional insurance, payouts are triggered automatically when heat "
195
+ "conditions exceed pre-defined thresholds — there is no need to file a claim.\n\n"
196
+ "How it works:\n"
197
+ "1. We continuously monitor temperature and humidity for your work zone.\n"
198
+ "2. When heat conditions exceed the trigger threshold for the required number "
199
+ "of consecutive days, a payout is initiated automatically.\n"
200
+ "3. Payouts arrive via M-Pesa or mobile money within 48 hours of the trigger.\n\n"
201
+ "Trigger levels:\n"
202
+ "- WATCH: Temperature above 33C or WBGT above 28C for 1+ day. "
203
+ "Payout: $5 per worker. Take precautions.\n"
204
+ "- WARNING: Temperature above 35C or WBGT above 30C for 2+ consecutive days. "
205
+ "Payout: $10 per worker. Reduce outdoor work.\n"
206
+ "- CRITICAL: Temperature above 37C or WBGT above 32C for 3+ consecutive days. "
207
+ "Payout: $15 per worker. Stop outdoor work during peak hours.\n\n"
208
+ "Important: Parametric insurance pays based on the weather index, not on whether "
209
+ "you personally experienced heat illness. This means you may receive a payout "
210
+ "even if you felt fine (because conditions were dangerous), or you may not receive "
211
+ "a payout even if you felt unwell (if the index did not reach the threshold). "
212
+ "This gap is called 'basis risk' and we work continuously to minimize it."
213
+ )
214
+
215
+
216
+ # -- Emergency contacts by city -------------------------------------------
217
+
218
+ EMERGENCY_CONTACTS: Dict[str, Dict[str, str]] = {
219
+ "Nairobi": {
220
+ "National Emergency": "999 or 112",
221
+ "Kenya Red Cross": "1199",
222
+ "Kenyatta National Hospital": "+254 20 272 6300",
223
+ "Kenya Meteorological Department": "+254 20 386 7880",
224
+ "Ambulance (St John)": "+254 20 221 0000",
225
+ "Police Emergency": "999",
226
+ },
227
+ "Dar es Salaam": {
228
+ "Tanzania Emergency": "112 or 114",
229
+ "Tanzania Red Cross": "+255 22 215 0330",
230
+ "Muhimbili National Hospital": "+255 22 215 1599",
231
+ "Tanzania Meteorological Authority": "+255 22 246 0706",
232
+ "Ambulance": "114",
233
+ },
234
+ "Kampala": {
235
+ "Uganda Emergency": "999 or 112",
236
+ "Uganda Red Cross": "+256 31 225 8701",
237
+ "Mulago National Hospital": "+256 41 425 4106",
238
+ "Uganda National Meteorological Authority": "+256 41 425 1798",
239
+ "Ambulance": "911",
240
+ },
241
+ "Kigali": {
242
+ "Rwanda Emergency": "112",
243
+ "Rwanda Red Cross": "+250 78 830 0086",
244
+ "CHUK Hospital": "+250 78 830 1001",
245
+ "Rwanda Meteorology Agency": "+250 78 218 5230",
246
+ "Ambulance (SAMU)": "912",
247
+ },
248
+ }
249
+
250
+
251
+ # -- Swahili translations of key heat and insurance terms ----------------
252
+
253
+ SWAHILI_TERMS: Dict[str, str] = {
254
+ "heat": "joto",
255
+ "extreme heat": "joto kali",
256
+ "heat stroke": "kiharusi cha joto",
257
+ "heat stress": "msongo wa joto",
258
+ "temperature": "joto la hewa",
259
+ "humidity": "unyevunyevu",
260
+ "warning": "onyo",
261
+ "critical": "hali ya hatari",
262
+ "watch": "tahadhari",
263
+ "payout": "malipo",
264
+ "insurance": "bima",
265
+ "trigger": "kiwango cha hatari",
266
+ "shade": "kivuli",
267
+ "water": "maji",
268
+ "rest break": "mapumziko",
269
+ "dehydration": "upungufu wa maji mwilini",
270
+ "outdoor worker": "mfanyakazi wa nje",
271
+ "sun protection": "kinga ya jua",
272
+ "emergency": "dharura",
273
+ "ambulance": "gari la wagonjwa",
274
+ "M-Pesa": "M-Pesa",
275
+ "Red Cross": "Msalaba Mwekundu",
276
+ }
277
+
278
+
279
+ # -- Protective actions by trigger level ----------------------------------
280
+
281
+ PROTECTIVE_ACTIONS: Dict[str, list[str]] = {
282
+ "critical": [
283
+ "STOP all outdoor work during peak heat hours (10am-4pm).",
284
+ "Move to shade or indoors immediately. Do not continue working in direct sun.",
285
+ "Drink water constantly — at least 250ml every 15 minutes.",
286
+ "Watch for danger signs: confusion, no sweating, hot dry skin, dizziness.",
287
+ "If someone collapses: move to shade, pour water on them, fan, call ambulance.",
288
+ "Your payout of $15 will arrive via mobile money within 48 hours.",
289
+ "Do not return to outdoor work until the heat alert is downgraded.",
290
+ ],
291
+ "warning": [
292
+ "Reduce outdoor work to early morning (before 10am) and late afternoon (after 4pm).",
293
+ "Take 15-minute shade breaks every hour during outdoor work.",
294
+ "Drink at least 1 cup of water every 20 minutes.",
295
+ "Wear loose, light-colored clothing and a hat.",
296
+ "Your payout of $10 will arrive via mobile money within 48 hours.",
297
+ "Watch for heat illness signs in yourself and co-workers.",
298
+ "Set up shade structures if working outdoors is unavoidable.",
299
+ ],
300
+ "watch": [
301
+ "Stay alert — heat conditions are developing.",
302
+ "Increase water intake beyond normal levels.",
303
+ "Plan your heavy work for cooler hours of the day.",
304
+ "Ensure shade and water are available at your work site.",
305
+ "Your payout of $5 will arrive via mobile money within 48 hours.",
306
+ ],
307
+ }
308
+
309
+
310
+ # -- Retrieval function ---------------------------------------------------
311
+
312
+ def get_zone_context(zone_id: str) -> str:
313
+ """Assemble full knowledge base context for a zone.
314
+
315
+ Returns a single text block suitable for injection into a Claude prompt.
316
+ """
317
+ parts: list[str] = []
318
+
319
+ # Zone heat context
320
+ context = ZONE_HEAT_CONTEXT.get(zone_id, "No heat vulnerability data available for this zone.")
321
+ parts.append(f"ZONE HEAT VULNERABILITY:\n{context}")
322
+
323
+ # Product info
324
+ parts.append(f"INSURANCE PRODUCT:\n{INSURANCE_PRODUCT_INFO}")
325
+
326
+ # Heat safety (first 8 most relevant)
327
+ safety_text = "\n".join(f"- {s}" for s in HEAT_SAFETY_GUIDANCE[:8])
328
+ parts.append(f"HEAT SAFETY GUIDANCE (WHO/ILO):\n{safety_text}")
329
+
330
+ return "\n\n---\n\n".join(parts)
331
+
332
+
333
+ def get_emergency_contacts(city: str) -> Dict[str, str]:
334
+ """Get emergency contacts for a city."""
335
+ return EMERGENCY_CONTACTS.get(city, EMERGENCY_CONTACTS.get("Nairobi", {}))
336
+
337
+
338
+ def get_protective_actions(trigger_level: str) -> list[str]:
339
+ """Get recommended protective actions for a trigger level."""
340
+ return PROTECTIVE_ACTIONS.get(trigger_level, PROTECTIVE_ACTIONS["watch"])
src/healing/__init__.py ADDED
File without changes
src/healing/healer.py ADDED
@@ -0,0 +1,953 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Claude-powered AI healing agent + rule-based fallback for climate data.
3
+
4
+ HealingAgent: uses Claude Sonnet with 5 investigation tools to assess and
5
+ heal daily precipitation/temperature readings for East African urban zones.
6
+ Cross-validates CHIRPS vs NASA POWER, checks against climatological normals,
7
+ and compares neighboring zones.
8
+
9
+ RuleBasedFallback: deterministic anomaly detection and correction.
10
+ Used when the Anthropic API is unavailable.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import re
18
+ import time
19
+ import uuid
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timezone
22
+ from typing import Any
23
+
24
+ import anthropic
25
+
26
+ from config import ZONE_MAP, ZONES, RAINY_SEASONS
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Data models
33
+ # ---------------------------------------------------------------------------
34
+
35
+ @dataclass
36
+ class HealingAssessment:
37
+ zone_id: str
38
+ date: str
39
+ assessment: str # good, corrected, filled, flagged
40
+ original_precip: float | None
41
+ healed_precip: float | None
42
+ original_temp: float | None
43
+ healed_temp: float | None
44
+ reasoning: str
45
+ tools_used: list[str]
46
+ tokens_used: int
47
+ latency_ms: float
48
+
49
+
50
+ @dataclass
51
+ class HealedReading:
52
+ zone_id: str
53
+ date: str
54
+ precip_mm: float | None
55
+ temp_mean_c: float | None
56
+ temp_max_c: float | None
57
+ temp_min_c: float | None
58
+ humidity_pct: float | None
59
+ wind_speed_ms: float | None
60
+ heal_action: str
61
+ quality_score: float
62
+
63
+
64
+ @dataclass
65
+ class HealedData:
66
+ zone_id: str
67
+ readings: list[HealedReading]
68
+ quality_score: float # 0-1
69
+ assessments: list[HealingAssessment]
70
+ healer_used: str # "claude" or "rule_based"
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Climatological normals — real data for East African cities
75
+ # Monthly mean precipitation (mm), std dev (mm), typical daily range (mm)
76
+ # Sources: Kenya Met Department, Tanzania Met Agency, UNMA, Rwanda Meteorology
77
+ # ---------------------------------------------------------------------------
78
+
79
+ CLIMATOLOGICAL_NORMALS: dict[tuple[str, int], dict[str, Any]] = {
80
+ # ── Nairobi (bimodal: long rains MAM, short rains ON) ──
81
+ ("Nairobi", 1): {"mean_precip_mm": 58, "std_dev_mm": 35, "daily_range": (0, 15), "mean_temp_c": 19.0, "temp_range": (12, 26)},
82
+ ("Nairobi", 2): {"mean_precip_mm": 52, "std_dev_mm": 40, "daily_range": (0, 18), "mean_temp_c": 19.5, "temp_range": (12, 27)},
83
+ ("Nairobi", 3): {"mean_precip_mm": 95, "std_dev_mm": 50, "daily_range": (0, 30), "mean_temp_c": 20.0, "temp_range": (14, 26)},
84
+ ("Nairobi", 4): {"mean_precip_mm": 210, "std_dev_mm": 70, "daily_range": (0, 50), "mean_temp_c": 19.5, "temp_range": (14, 25)},
85
+ ("Nairobi", 5): {"mean_precip_mm": 165, "std_dev_mm": 60, "daily_range": (0, 40), "mean_temp_c": 18.5, "temp_range": (13, 24)},
86
+ ("Nairobi", 6): {"mean_precip_mm": 35, "std_dev_mm": 25, "daily_range": (0, 10), "mean_temp_c": 16.5, "temp_range": (11, 22)},
87
+ ("Nairobi", 7): {"mean_precip_mm": 18, "std_dev_mm": 15, "daily_range": (0, 5), "mean_temp_c": 15.5, "temp_range": (10, 21)},
88
+ ("Nairobi", 8): {"mean_precip_mm": 22, "std_dev_mm": 18, "daily_range": (0, 8), "mean_temp_c": 16.0, "temp_range": (10, 22)},
89
+ ("Nairobi", 9): {"mean_precip_mm": 28, "std_dev_mm": 20, "daily_range": (0, 10), "mean_temp_c": 17.5, "temp_range": (11, 24)},
90
+ ("Nairobi", 10): {"mean_precip_mm": 60, "std_dev_mm": 40, "daily_range": (0, 25), "mean_temp_c": 19.0, "temp_range": (13, 25)},
91
+ ("Nairobi", 11): {"mean_precip_mm": 150, "std_dev_mm": 65, "daily_range": (0, 45), "mean_temp_c": 18.5, "temp_range": (13, 24)},
92
+ ("Nairobi", 12): {"mean_precip_mm": 90, "std_dev_mm": 50, "daily_range": (0, 25), "mean_temp_c": 18.5, "temp_range": (13, 25)},
93
+
94
+ # ── Dar es Salaam (bimodal: masika MAM, vuli ND) ──
95
+ ("Dar es Salaam", 1): {"mean_precip_mm": 70, "std_dev_mm": 50, "daily_range": (0, 20), "mean_temp_c": 28.0, "temp_range": (24, 32)},
96
+ ("Dar es Salaam", 2): {"mean_precip_mm": 55, "std_dev_mm": 45, "daily_range": (0, 18), "mean_temp_c": 28.5, "temp_range": (24, 33)},
97
+ ("Dar es Salaam", 3): {"mean_precip_mm": 130, "std_dev_mm": 65, "daily_range": (0, 40), "mean_temp_c": 28.0, "temp_range": (24, 32)},
98
+ ("Dar es Salaam", 4): {"mean_precip_mm": 280, "std_dev_mm": 90, "daily_range": (0, 60), "mean_temp_c": 27.0, "temp_range": (23, 31)},
99
+ ("Dar es Salaam", 5): {"mean_precip_mm": 180, "std_dev_mm": 75, "daily_range": (0, 45), "mean_temp_c": 26.0, "temp_range": (22, 30)},
100
+ ("Dar es Salaam", 6): {"mean_precip_mm": 40, "std_dev_mm": 30, "daily_range": (0, 12), "mean_temp_c": 24.5, "temp_range": (20, 29)},
101
+ ("Dar es Salaam", 7): {"mean_precip_mm": 30, "std_dev_mm": 22, "daily_range": (0, 8), "mean_temp_c": 24.0, "temp_range": (19, 29)},
102
+ ("Dar es Salaam", 8): {"mean_precip_mm": 25, "std_dev_mm": 18, "daily_range": (0, 8), "mean_temp_c": 24.0, "temp_range": (19, 29)},
103
+ ("Dar es Salaam", 9): {"mean_precip_mm": 25, "std_dev_mm": 18, "daily_range": (0, 8), "mean_temp_c": 25.0, "temp_range": (20, 30)},
104
+ ("Dar es Salaam", 10): {"mean_precip_mm": 55, "std_dev_mm": 40, "daily_range": (0, 18), "mean_temp_c": 26.5, "temp_range": (22, 31)},
105
+ ("Dar es Salaam", 11): {"mean_precip_mm": 120, "std_dev_mm": 60, "daily_range": (0, 35), "mean_temp_c": 27.5, "temp_range": (23, 32)},
106
+ ("Dar es Salaam", 12): {"mean_precip_mm": 130, "std_dev_mm": 65, "daily_range": (0, 35), "mean_temp_c": 28.0, "temp_range": (24, 32)},
107
+
108
+ # ── Kampala (bimodal: first rains MAM, second rains SON) ──
109
+ ("Kampala", 1): {"mean_precip_mm": 50, "std_dev_mm": 35, "daily_range": (0, 15), "mean_temp_c": 22.0, "temp_range": (17, 28)},
110
+ ("Kampala", 2): {"mean_precip_mm": 60, "std_dev_mm": 40, "daily_range": (0, 18), "mean_temp_c": 22.5, "temp_range": (17, 29)},
111
+ ("Kampala", 3): {"mean_precip_mm": 130, "std_dev_mm": 55, "daily_range": (0, 35), "mean_temp_c": 22.0, "temp_range": (17, 27)},
112
+ ("Kampala", 4): {"mean_precip_mm": 175, "std_dev_mm": 65, "daily_range": (0, 50), "mean_temp_c": 21.5, "temp_range": (17, 26)},
113
+ ("Kampala", 5): {"mean_precip_mm": 145, "std_dev_mm": 55, "daily_range": (0, 40), "mean_temp_c": 21.5, "temp_range": (17, 26)},
114
+ ("Kampala", 6): {"mean_precip_mm": 65, "std_dev_mm": 35, "daily_range": (0, 15), "mean_temp_c": 21.0, "temp_range": (16, 26)},
115
+ ("Kampala", 7): {"mean_precip_mm": 55, "std_dev_mm": 30, "daily_range": (0, 12), "mean_temp_c": 20.5, "temp_range": (16, 25)},
116
+ ("Kampala", 8): {"mean_precip_mm": 75, "std_dev_mm": 40, "daily_range": (0, 20), "mean_temp_c": 21.0, "temp_range": (16, 26)},
117
+ ("Kampala", 9): {"mean_precip_mm": 90, "std_dev_mm": 45, "daily_range": (0, 25), "mean_temp_c": 21.5, "temp_range": (17, 27)},
118
+ ("Kampala", 10): {"mean_precip_mm": 120, "std_dev_mm": 55, "daily_range": (0, 35), "mean_temp_c": 21.5, "temp_range": (17, 27)},
119
+ ("Kampala", 11): {"mean_precip_mm": 140, "std_dev_mm": 60, "daily_range": (0, 40), "mean_temp_c": 21.5, "temp_range": (17, 26)},
120
+ ("Kampala", 12): {"mean_precip_mm": 80, "std_dev_mm": 45, "daily_range": (0, 22), "mean_temp_c": 21.5, "temp_range": (17, 27)},
121
+
122
+ # ── Kigali (bimodal: long rains MAM, short rains ON) ──
123
+ ("Kigali", 1): {"mean_precip_mm": 75, "std_dev_mm": 40, "daily_range": (0, 20), "mean_temp_c": 21.0, "temp_range": (15, 27)},
124
+ ("Kigali", 2): {"mean_precip_mm": 90, "std_dev_mm": 45, "daily_range": (0, 25), "mean_temp_c": 21.0, "temp_range": (15, 27)},
125
+ ("Kigali", 3): {"mean_precip_mm": 115, "std_dev_mm": 50, "daily_range": (0, 35), "mean_temp_c": 21.0, "temp_range": (15, 27)},
126
+ ("Kigali", 4): {"mean_precip_mm": 155, "std_dev_mm": 60, "daily_range": (0, 45), "mean_temp_c": 21.0, "temp_range": (15, 26)},
127
+ ("Kigali", 5): {"mean_precip_mm": 100, "std_dev_mm": 50, "daily_range": (0, 30), "mean_temp_c": 21.0, "temp_range": (15, 27)},
128
+ ("Kigali", 6): {"mean_precip_mm": 20, "std_dev_mm": 15, "daily_range": (0, 5), "mean_temp_c": 21.0, "temp_range": (15, 28)},
129
+ ("Kigali", 7): {"mean_precip_mm": 10, "std_dev_mm": 10, "daily_range": (0, 3), "mean_temp_c": 21.5, "temp_range": (14, 28)},
130
+ ("Kigali", 8): {"mean_precip_mm": 30, "std_dev_mm": 22, "daily_range": (0, 10), "mean_temp_c": 22.0, "temp_range": (15, 29)},
131
+ ("Kigali", 9): {"mean_precip_mm": 75, "std_dev_mm": 40, "daily_range": (0, 20), "mean_temp_c": 21.5, "temp_range": (15, 28)},
132
+ ("Kigali", 10): {"mean_precip_mm": 105, "std_dev_mm": 50, "daily_range": (0, 30), "mean_temp_c": 21.0, "temp_range": (15, 27)},
133
+ ("Kigali", 11): {"mean_precip_mm": 105, "std_dev_mm": 50, "daily_range": (0, 30), "mean_temp_c": 20.5, "temp_range": (15, 26)},
134
+ ("Kigali", 12): {"mean_precip_mm": 70, "std_dev_mm": 40, "daily_range": (0, 20), "mean_temp_c": 20.5, "temp_range": (15, 26)},
135
+ }
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Tool definitions (JSON Schema format for Claude tool-use API)
140
+ # ---------------------------------------------------------------------------
141
+
142
+ HEALING_TOOLS = [
143
+ {
144
+ "name": "zone_metadata",
145
+ "description": (
146
+ "Get metadata for an urban zone: name, city, country, settlement type, "
147
+ "elevation, flood susceptibility, drainage quality, primary flood type. "
148
+ "Use this to understand a zone's geography and vulnerability context."
149
+ ),
150
+ "input_schema": {
151
+ "type": "object",
152
+ "properties": {
153
+ "zone_id": {
154
+ "type": "string",
155
+ "description": "Zone ID, e.g. 'NBO-KIB' for Kibera or 'DAR-JAN' for Jangwani",
156
+ },
157
+ },
158
+ "required": ["zone_id"],
159
+ },
160
+ },
161
+ {
162
+ "name": "historical_norms",
163
+ "description": (
164
+ "Get climatological normals for a zone's city in a given month. Returns "
165
+ "mean monthly precipitation, standard deviation, typical daily range, "
166
+ "mean temperature, and temperature range. Based on long-term observational "
167
+ "records. Use this to check whether a reading is within normal bounds."
168
+ ),
169
+ "input_schema": {
170
+ "type": "object",
171
+ "properties": {
172
+ "zone_id": {"type": "string", "description": "Zone ID"},
173
+ "month": {"type": "integer", "description": "Calendar month (1-12)"},
174
+ },
175
+ "required": ["zone_id", "month"],
176
+ },
177
+ },
178
+ {
179
+ "name": "cross_source_check",
180
+ "description": (
181
+ "Compare CHIRPS and NASA POWER precipitation values for the same zone "
182
+ "and date. Returns both source values, the absolute difference, and an "
183
+ "agreement score. Use this when you need to assess which source is more "
184
+ "reliable for a given reading or when values look suspicious."
185
+ ),
186
+ "input_schema": {
187
+ "type": "object",
188
+ "properties": {
189
+ "zone_id": {"type": "string", "description": "Zone ID"},
190
+ "date": {"type": "string", "description": "Date in YYYY-MM-DD format"},
191
+ },
192
+ "required": ["zone_id", "date"],
193
+ },
194
+ },
195
+ {
196
+ "name": "neighbor_comparison",
197
+ "description": (
198
+ "Compare a zone's readings against other zones in the same city. Returns "
199
+ "neighboring zone values with distances. Use this to detect spatial "
200
+ "outliers — if one zone in Nairobi reads 200mm while all others read <20mm, "
201
+ "that's suspicious."
202
+ ),
203
+ "input_schema": {
204
+ "type": "object",
205
+ "properties": {
206
+ "zone_id": {"type": "string", "description": "Zone ID to compare"},
207
+ "date": {"type": "string", "description": "Date in YYYY-MM-DD format"},
208
+ },
209
+ "required": ["zone_id", "date"],
210
+ },
211
+ },
212
+ {
213
+ "name": "seasonal_context",
214
+ "description": (
215
+ "Get seasonal rainfall context for a zone: whether the date falls in a "
216
+ "rainy season, what the season is called locally, and expected rainfall "
217
+ "patterns. Use this to judge whether extreme values are seasonally normal."
218
+ ),
219
+ "input_schema": {
220
+ "type": "object",
221
+ "properties": {
222
+ "zone_id": {"type": "string", "description": "Zone ID"},
223
+ "month": {"type": "integer", "description": "Calendar month (1-12)"},
224
+ },
225
+ "required": ["zone_id", "month"],
226
+ },
227
+ },
228
+ ]
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # System prompt for the healing agent
233
+ # ---------------------------------------------------------------------------
234
+
235
+ SYSTEM_PROMPT_TEMPLATE = """You are a climate data quality agent for an urban flood parametric insurance system covering {n_zones} zones across Nairobi, Dar es Salaam, Kampala, and Kigali.
236
+
237
+ ## Data Sources You're Working With
238
+
239
+ Each zone has daily readings from two independent satellite/reanalysis sources:
240
+ 1. **CHIRPS** (Climate Hazards Group InfraRed Precipitation with Station data) — 0.05° resolution precipitation estimates combining satellite imagery with station data
241
+ 2. **NASA POWER** — 0.5° resolution reanalysis providing precipitation, temperature, humidity, and wind speed
242
+
243
+ Your job is Step 2 of a 6-step parametric insurance pipeline: heal and quality-score daily readings BEFORE they feed into SPI calculation and flood risk indexing (Step 3), which ultimately triggers insurance payouts.
244
+
245
+ ## What You're Checking
246
+
247
+ For each zone's daily reading:
248
+ - **Impossible values**: negative precipitation, precipitation >500mm/day, temperatures outside -5 to 55°C
249
+ - **Statistical outliers**: daily precip more than 3 standard deviations above the monthly mean for that city
250
+ - **Source disagreements**: CHIRPS and NASA POWER differing by more than 20mm on the same day
251
+ - **Missing data**: NULL precipitation or temperature values that need filling
252
+ - **Spatial inconsistency**: one zone in a city reading wildly differently from its neighbors
253
+
254
+ ## Your Task
255
+
256
+ You receive a batch of {n_zones} daily readings. For each reading:
257
+ 1. Assess data quality — check for impossible values, outliers, source disagreements, missing data
258
+ 2. Use your tools selectively to investigate suspicious readings
259
+ 3. Produce a structured assessment for EVERY reading
260
+
261
+ ## East African Climate Context
262
+
263
+ These are tropical cities at varying elevations with distinct rainy seasons:
264
+ - **Nairobi** (1620-1780m): Bimodal — long rains MAM (heavy: 95-210mm/month), short rains ON (60-150mm/month). Highland climate, cooler than expected for equator.
265
+ - **Dar es Salaam** (8-35m): Bimodal — masika MAM (130-280mm/month), vuli ND (120-130mm/month). Hot coastal, consistent high humidity.
266
+ - **Kampala** (1140-1260m): Bimodal with less pronounced dry season. Peak rains MAM and SON (90-175mm/month).
267
+ - **Kigali** (1420-1580m): Bimodal — long rains MAM, short rains ON. Pronounced dry JJ (10-20mm/month). "Land of a thousand hills" — elevation matters.
268
+
269
+ Daily rainfall of 50-80mm during peak rainy season is heavy but not impossible. 100mm+ in a single day is extreme and warrants investigation. >200mm/day is very rare outside tropical cyclone influence.
270
+
271
+ ## Key Rules
272
+ - NEVER fabricate data. If you can't confidently correct a value, flag it.
273
+ - For missing precipitation: use the other source (CHIRPS or NASA POWER) if available; if both missing, use neighbor average.
274
+ - For missing temperature: use NASA POWER value (CHIRPS is precip-only).
275
+ - When CHIRPS and NASA POWER agree within 10mm, use CHIRPS as primary (higher resolution). When they disagree >20mm, investigate.
276
+ - Quality score: 0.95+ for dual-source agreement, 0.7-0.9 for corrected, 0.5-0.7 for single-source filled, <0.5 for flagged.
277
+
278
+ ## Efficiency — IMPORTANT
279
+ Be selective with tools:
280
+ - For readings with dual-source agreement and reasonable values: mark as "good" without tool calls.
281
+ - Only call `historical_norms` or `neighbor_comparison` when a value looks suspicious.
282
+ - Only call `seasonal_context` when you need to judge whether an extreme value is normal for the season.
283
+ - Batch tool calls in a single turn when possible.
284
+ - Do NOT investigate a reading more than 2 rounds.
285
+
286
+ ## Output Format
287
+
288
+ Return your final assessment as a JSON array wrapped in ```json fences. Each object must have:
289
+ - zone_id (string)
290
+ - date (string): YYYY-MM-DD
291
+ - assessment (string): "good" | "corrected" | "filled" | "flagged"
292
+ - original_precip (number or null)
293
+ - healed_precip (number or null)
294
+ - original_temp (number or null)
295
+ - healed_temp (number or null)
296
+ - reasoning (string): 1-2 sentences
297
+ - tools_used (array of strings)"""
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Tool implementations
302
+ # ---------------------------------------------------------------------------
303
+
304
+ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
305
+ """Great-circle distance between two points in km."""
306
+ import math
307
+ R = 6371.0
308
+ dlat = math.radians(lat2 - lat1)
309
+ dlon = math.radians(lon2 - lon1)
310
+ a = (math.sin(dlat / 2) ** 2 +
311
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
312
+ math.sin(dlon / 2) ** 2)
313
+ return R * 2 * math.asin(math.sqrt(a))
314
+
315
+
316
+ def _tool_zone_metadata(zone_id: str) -> dict[str, Any]:
317
+ zone = ZONE_MAP.get(zone_id)
318
+ if zone is None:
319
+ return {"error": f"Unknown zone_id: {zone_id}"}
320
+ return {
321
+ "zone_id": zone.zone_id,
322
+ "name": zone.name,
323
+ "city": zone.city,
324
+ "country": zone.country,
325
+ "latitude": zone.latitude,
326
+ "longitude": zone.longitude,
327
+ "elevation_m": zone.elevation_m,
328
+ "settlement_type": zone.settlement_type,
329
+ "flood_susceptibility": zone.flood_susceptibility,
330
+ "drainage_quality": zone.drainage_quality,
331
+ "primary_flood_type": zone.primary_flood_type,
332
+ "notes": zone.notes,
333
+ }
334
+
335
+
336
+ def _tool_historical_norms(zone_id: str, month: int) -> dict[str, Any]:
337
+ zone = ZONE_MAP.get(zone_id)
338
+ if zone is None:
339
+ return {"error": f"Unknown zone_id: {zone_id}"}
340
+
341
+ key = (zone.city, month)
342
+ norms = CLIMATOLOGICAL_NORMALS.get(key)
343
+ if norms is None:
344
+ return {"error": f"No climatological normals for city={zone.city}, month={month}"}
345
+
346
+ return {
347
+ "zone_id": zone_id,
348
+ "city": zone.city,
349
+ "month": month,
350
+ **norms,
351
+ }
352
+
353
+
354
+ def _tool_cross_source_check(zone_id: str, date: str,
355
+ batch_readings: list[dict]) -> dict[str, Any]:
356
+ zone = ZONE_MAP.get(zone_id)
357
+ if zone is None:
358
+ return {"error": f"Unknown zone_id: {zone_id}"}
359
+
360
+ # Find this zone's reading in the batch
361
+ reading = None
362
+ for r in batch_readings:
363
+ if r.get("zone_id") == zone_id and r.get("date") == date:
364
+ reading = r
365
+ break
366
+
367
+ if reading is None:
368
+ return {"error": f"No reading found for zone {zone_id} on {date}"}
369
+
370
+ chirps = reading.get("precip_chirps_mm")
371
+ nasa = reading.get("precip_nasa_mm")
372
+
373
+ result: dict[str, Any] = {
374
+ "zone_id": zone_id,
375
+ "date": date,
376
+ "chirps_precip_mm": chirps,
377
+ "nasa_precip_mm": nasa,
378
+ }
379
+
380
+ if chirps is not None and nasa is not None:
381
+ diff = abs(chirps - nasa)
382
+ agreement = max(0.0, 1.0 - diff / 50.0) # 0mm diff = 1.0, 50mm diff = 0.0
383
+ result["difference_mm"] = round(diff, 1)
384
+ result["agreement_score"] = round(agreement, 2)
385
+ result["recommendation"] = (
386
+ "Sources agree well" if diff < 10 else
387
+ "Moderate disagreement — investigate" if diff < 20 else
388
+ "Significant disagreement — check neighbors and norms"
389
+ )
390
+ else:
391
+ available = "CHIRPS" if chirps is not None else "NASA POWER" if nasa is not None else "neither"
392
+ result["note"] = f"Only {available} available for cross-check"
393
+
394
+ return result
395
+
396
+
397
+ def _tool_neighbor_comparison(zone_id: str, date: str,
398
+ batch_readings: list[dict]) -> dict[str, Any]:
399
+ zone = ZONE_MAP.get(zone_id)
400
+ if zone is None:
401
+ return {"error": f"Unknown zone_id: {zone_id}"}
402
+
403
+ # Build lookup from batch readings for this date
404
+ readings_by_zone = {}
405
+ for r in batch_readings:
406
+ if r.get("date") == date:
407
+ readings_by_zone[r["zone_id"]] = r
408
+
409
+ # Find zones in the same city
410
+ neighbors = []
411
+ for z in ZONES:
412
+ if z.zone_id == zone_id or z.city != zone.city:
413
+ continue
414
+ dist = _haversine_km(zone.latitude, zone.longitude, z.latitude, z.longitude)
415
+ reading = readings_by_zone.get(z.zone_id, {})
416
+ neighbors.append({
417
+ "zone_id": z.zone_id,
418
+ "name": z.name,
419
+ "distance_km": round(dist, 1),
420
+ "precip_mm": reading.get("precip_mm"),
421
+ "precip_chirps_mm": reading.get("precip_chirps_mm"),
422
+ "precip_nasa_mm": reading.get("precip_nasa_mm"),
423
+ "temp_mean_c": reading.get("temp_mean_c"),
424
+ "settlement_type": z.settlement_type,
425
+ })
426
+
427
+ neighbors.sort(key=lambda x: x["distance_km"])
428
+
429
+ # Compute city average for context
430
+ precip_vals = [n["precip_mm"] for n in neighbors if n.get("precip_mm") is not None]
431
+ city_avg = round(sum(precip_vals) / len(precip_vals), 1) if precip_vals else None
432
+
433
+ return {
434
+ "zone_id": zone_id,
435
+ "city": zone.city,
436
+ "date": date,
437
+ "neighbors_found": len(neighbors),
438
+ "city_avg_precip_mm": city_avg,
439
+ "neighbors": neighbors,
440
+ }
441
+
442
+
443
+ def _tool_seasonal_context(zone_id: str, month: int) -> dict[str, Any]:
444
+ zone = ZONE_MAP.get(zone_id)
445
+ if zone is None:
446
+ return {"error": f"Unknown zone_id: {zone_id}"}
447
+
448
+ city = zone.city
449
+ seasons = RAINY_SEASONS.get(city, {})
450
+
451
+ in_rainy_season = False
452
+ season_name = "dry season"
453
+ for name, months in seasons.items():
454
+ if month in months:
455
+ in_rainy_season = True
456
+ season_name = name
457
+ break
458
+
459
+ norms = CLIMATOLOGICAL_NORMALS.get((city, month), {})
460
+
461
+ return {
462
+ "zone_id": zone_id,
463
+ "city": city,
464
+ "month": month,
465
+ "in_rainy_season": in_rainy_season,
466
+ "season_name": season_name,
467
+ "rainy_months": zone.rainy_seasons,
468
+ "expected_monthly_precip_mm": norms.get("mean_precip_mm"),
469
+ "expected_daily_max_mm": norms.get("daily_range", (0, 0))[1],
470
+ "expected_temp_range": norms.get("temp_range"),
471
+ "context": (
472
+ f"{'Peak' if month in zone.rainy_seasons else 'Off-season'} for {city}. "
473
+ f"Zone {zone.name} is a {zone.settlement_type} settlement with "
474
+ f"{zone.flood_susceptibility} flood susceptibility and "
475
+ f"{zone.drainage_quality} drainage."
476
+ ),
477
+ }
478
+
479
+
480
+ # ---------------------------------------------------------------------------
481
+ # HealingAgent — Claude-powered agentic healer
482
+ # ---------------------------------------------------------------------------
483
+
484
+ class HealingAgent:
485
+ """Uses Claude Sonnet with 5 investigation tools to assess and heal
486
+ a batch of daily climate readings for East African urban zones."""
487
+
488
+ MAX_TOOL_ROUNDS = 8
489
+
490
+ def __init__(self, api_key: str, model: str = "claude-sonnet-4-6"):
491
+ self.api_key = api_key
492
+ self.model = model
493
+ self._client: anthropic.Anthropic | None = None
494
+
495
+ def _get_client(self) -> anthropic.Anthropic:
496
+ if self._client is None:
497
+ self._client = anthropic.Anthropic(api_key=self.api_key)
498
+ return self._client
499
+
500
+ def _execute_tool(self, name: str, tool_input: dict[str, Any],
501
+ context: dict[str, Any]) -> str:
502
+ """Dispatch a tool call. Returns JSON string."""
503
+ try:
504
+ if name == "zone_metadata":
505
+ result = _tool_zone_metadata(tool_input["zone_id"])
506
+ elif name == "historical_norms":
507
+ result = _tool_historical_norms(
508
+ tool_input["zone_id"], tool_input["month"])
509
+ elif name == "cross_source_check":
510
+ result = _tool_cross_source_check(
511
+ tool_input["zone_id"], tool_input["date"],
512
+ context["batch_readings"])
513
+ elif name == "neighbor_comparison":
514
+ result = _tool_neighbor_comparison(
515
+ tool_input["zone_id"], tool_input["date"],
516
+ context["batch_readings"])
517
+ elif name == "seasonal_context":
518
+ result = _tool_seasonal_context(
519
+ tool_input["zone_id"], tool_input["month"])
520
+ else:
521
+ result = {"error": f"Unknown tool: {name}"}
522
+ except Exception as exc:
523
+ result = {"error": f"Tool execution failed: {exc}"}
524
+
525
+ return json.dumps(result, default=str)
526
+
527
+ def _build_system_prompt(self, n_zones: int) -> str:
528
+ return SYSTEM_PROMPT_TEMPLATE.format(n_zones=n_zones)
529
+
530
+ def _parse_assessments(self, text: str) -> list[dict[str, Any]]:
531
+ """Extract JSON assessment array from Claude's response."""
532
+ match = re.search(r'```json\s*([\s\S]*?)```', text)
533
+ if match:
534
+ return json.loads(match.group(1))
535
+
536
+ match = re.search(r'\[[\s\S]*\]', text)
537
+ if match:
538
+ return json.loads(match.group(0))
539
+
540
+ raise ValueError("Could not find JSON assessment array in response")
541
+
542
+ BATCH_SIZE = 10
543
+
544
+ def heal(self, readings: list[dict[str, Any]]) -> HealedData:
545
+ """Main entry point: assess and heal a batch of daily readings.
546
+
547
+ Args:
548
+ readings: list of dicts with keys matching DailyReading fields:
549
+ zone_id, date, precip_mm, precip_chirps_mm, precip_nasa_mm,
550
+ temp_mean_c, temp_max_c, temp_min_c, humidity_pct, wind_speed_ms,
551
+ source, source_agreement, data_quality
552
+
553
+ Returns:
554
+ HealedData with healed readings, quality score, and assessments.
555
+ """
556
+ t0 = time.time()
557
+
558
+ # Split into sub-batches
559
+ batches = [readings[i:i + self.BATCH_SIZE]
560
+ for i in range(0, len(readings), self.BATCH_SIZE)]
561
+
562
+ all_healed: list[HealedReading] = []
563
+ all_assessments: list[HealingAssessment] = []
564
+ total_tokens = 0
565
+ fallback_used = False
566
+
567
+ for batch_num, batch in enumerate(batches):
568
+ log.info("AI healing batch %d/%d (%d readings)",
569
+ batch_num + 1, len(batches), len(batch))
570
+ try:
571
+ result = self._heal_sub_batch(batch, readings)
572
+ all_healed.extend(result["healed"])
573
+ all_assessments.extend(result["assessments"])
574
+ total_tokens += result["tokens"]
575
+ except Exception as exc:
576
+ log.warning("AI healing batch %d failed: %s — using rule-based fallback",
577
+ batch_num + 1, exc)
578
+ fallback_used = True
579
+ fb = RuleBasedFallback()
580
+ for r in batch:
581
+ healed, assessment = fb.heal_reading(r)
582
+ all_healed.append(healed)
583
+ all_assessments.append(assessment)
584
+
585
+ # Compute overall quality score
586
+ quality_scores = [h.quality_score for h in all_healed]
587
+ avg_quality = sum(quality_scores) / len(quality_scores) if quality_scores else 0.0
588
+
589
+ zone_id = readings[0]["zone_id"] if readings else "unknown"
590
+ latency = (time.time() - t0) * 1000
591
+
592
+ return HealedData(
593
+ zone_id=zone_id,
594
+ readings=all_healed,
595
+ quality_score=round(avg_quality, 3),
596
+ assessments=all_assessments,
597
+ healer_used="rule_based" if fallback_used else "claude",
598
+ )
599
+
600
+ def _heal_sub_batch(self, batch: list[dict], all_readings: list[dict]) -> dict:
601
+ """Process a single sub-batch through Claude."""
602
+ t0 = time.time()
603
+ total_tokens_in = 0
604
+ total_tokens_out = 0
605
+
606
+ client = self._get_client()
607
+ context = {"batch_readings": all_readings}
608
+
609
+ # Build user message
610
+ readings_payload = []
611
+ for r in batch:
612
+ readings_payload.append({
613
+ "zone_id": r["zone_id"],
614
+ "date": r.get("date", ""),
615
+ "precip_mm": r.get("precip_mm"),
616
+ "precip_chirps_mm": r.get("precip_chirps_mm"),
617
+ "precip_nasa_mm": r.get("precip_nasa_mm"),
618
+ "temp_mean_c": r.get("temp_mean_c"),
619
+ "temp_max_c": r.get("temp_max_c"),
620
+ "temp_min_c": r.get("temp_min_c"),
621
+ "humidity_pct": r.get("humidity_pct"),
622
+ "wind_speed_ms": r.get("wind_speed_ms"),
623
+ "source": r.get("source", "unknown"),
624
+ "source_agreement": r.get("source_agreement"),
625
+ })
626
+
627
+ now = datetime.now(timezone.utc)
628
+ user_msg = (
629
+ f"Current date/time: {now.isoformat()} UTC (month={now.month})\n\n"
630
+ f"Here are {len(batch)} daily climate readings to assess and heal:\n\n"
631
+ f"```json\n{json.dumps(readings_payload, indent=2, default=str)}\n```\n\n"
632
+ "Investigate any suspicious readings using your tools, then return "
633
+ "your assessment for ALL readings."
634
+ )
635
+
636
+ messages: list[dict[str, Any]] = [{"role": "user", "content": user_msg}]
637
+ system_prompt = self._build_system_prompt(len(batch))
638
+
639
+ # Agentic tool-use loop
640
+ for round_num in range(self.MAX_TOOL_ROUNDS):
641
+ response = client.messages.create(
642
+ model=self.model,
643
+ max_tokens=8192,
644
+ system=system_prompt,
645
+ tools=HEALING_TOOLS,
646
+ messages=messages,
647
+ )
648
+
649
+ total_tokens_in += getattr(response.usage, "input_tokens", 0)
650
+ total_tokens_out += getattr(response.usage, "output_tokens", 0)
651
+
652
+ if response.stop_reason == "end_turn":
653
+ text_blocks = [b.text for b in response.content if hasattr(b, "text")]
654
+ full_text = "\n".join(text_blocks)
655
+
656
+ try:
657
+ raw_assessments = self._parse_assessments(full_text)
658
+ except (ValueError, json.JSONDecodeError) as exc:
659
+ log.warning("Failed to parse AI healing response: %s", exc)
660
+ raise
661
+
662
+ latency_ms = (time.time() - t0) * 1000
663
+ total_tokens = total_tokens_in + total_tokens_out
664
+
665
+ healed_readings: list[HealedReading] = []
666
+ assessments: list[HealingAssessment] = []
667
+ readings_by_zone = {r["zone_id"]: r for r in batch}
668
+
669
+ for a in raw_assessments:
670
+ zid = a.get("zone_id", "")
671
+ original = readings_by_zone.get(zid)
672
+ if original is None:
673
+ continue
674
+
675
+ assessment = HealingAssessment(
676
+ zone_id=zid,
677
+ date=a.get("date", original.get("date", "")),
678
+ assessment=a.get("assessment", "flagged"),
679
+ original_precip=original.get("precip_mm"),
680
+ healed_precip=a.get("healed_precip"),
681
+ original_temp=original.get("temp_mean_c"),
682
+ healed_temp=a.get("healed_temp"),
683
+ reasoning=a.get("reasoning", ""),
684
+ tools_used=a.get("tools_used", []),
685
+ tokens_used=total_tokens,
686
+ latency_ms=latency_ms,
687
+ )
688
+ assessments.append(assessment)
689
+
690
+ # Determine healed values
691
+ healed_precip = a.get("healed_precip", original.get("precip_mm"))
692
+ healed_temp = a.get("healed_temp", original.get("temp_mean_c"))
693
+
694
+ heal_action = f"ai_{a.get('assessment', 'flagged')}"
695
+ quality = {
696
+ "good": 0.95,
697
+ "corrected": 0.8,
698
+ "filled": 0.6,
699
+ "flagged": 0.3,
700
+ }.get(a.get("assessment", "flagged"), 0.5)
701
+
702
+ healed_readings.append(HealedReading(
703
+ zone_id=zid,
704
+ date=a.get("date", original.get("date", "")),
705
+ precip_mm=healed_precip,
706
+ temp_mean_c=healed_temp,
707
+ temp_max_c=original.get("temp_max_c"),
708
+ temp_min_c=original.get("temp_min_c"),
709
+ humidity_pct=original.get("humidity_pct"),
710
+ wind_speed_ms=original.get("wind_speed_ms"),
711
+ heal_action=heal_action,
712
+ quality_score=quality,
713
+ ))
714
+
715
+ return {
716
+ "healed": healed_readings,
717
+ "assessments": assessments,
718
+ "tokens": total_tokens,
719
+ }
720
+
721
+ elif response.stop_reason == "tool_use":
722
+ messages.append({"role": "assistant", "content": response.content})
723
+ tool_results = []
724
+ for block in response.content:
725
+ if block.type == "tool_use":
726
+ result_str = self._execute_tool(block.name, block.input, context)
727
+ tool_results.append({
728
+ "type": "tool_result",
729
+ "tool_use_id": block.id,
730
+ "content": result_str,
731
+ })
732
+ messages.append({"role": "user", "content": tool_results})
733
+ else:
734
+ log.warning("Unexpected stop_reason: %s", response.stop_reason)
735
+ break
736
+
737
+ raise RuntimeError(
738
+ f"AI healing exhausted {self.MAX_TOOL_ROUNDS} tool rounds without completing"
739
+ )
740
+
741
+
742
+ # ---------------------------------------------------------------------------
743
+ # RuleBasedFallback — deterministic healing when Claude is unavailable
744
+ # ---------------------------------------------------------------------------
745
+
746
+ class RuleBasedFallback:
747
+ """Deterministic anomaly detection, correction, and cross-validation.
748
+ Used when the Anthropic API is unavailable."""
749
+
750
+ # Physical limits
751
+ PRECIP_MIN = 0.0
752
+ PRECIP_MAX = 500.0 # mm/day — absolute physical maximum
753
+ TEMP_MIN = -5.0
754
+ TEMP_MAX = 55.0
755
+
756
+ # Cross-validation threshold
757
+ SOURCE_DISAGREE_MM = 20.0
758
+
759
+ def heal_reading(self, reading: dict[str, Any]) -> tuple[HealedReading, HealingAssessment]:
760
+ """Heal a single reading using deterministic rules.
761
+
762
+ Returns (HealedReading, HealingAssessment) tuple.
763
+ """
764
+ t0 = time.time()
765
+ zone_id = reading["zone_id"]
766
+ date = reading.get("date", "")
767
+ original_precip = reading.get("precip_mm")
768
+ original_temp = reading.get("temp_mean_c")
769
+
770
+ healed_precip = original_precip
771
+ healed_temp = original_temp
772
+ assessment_type = "good"
773
+ reasoning_parts: list[str] = []
774
+ tools_used: list[str] = []
775
+
776
+ # --- Check precipitation ---
777
+ chirps = reading.get("precip_chirps_mm")
778
+ nasa = reading.get("precip_nasa_mm")
779
+
780
+ if original_precip is not None:
781
+ # Impossible: negative
782
+ if original_precip < self.PRECIP_MIN:
783
+ healed_precip = 0.0
784
+ assessment_type = "corrected"
785
+ reasoning_parts.append(
786
+ f"Negative precipitation ({original_precip}mm) corrected to 0."
787
+ )
788
+
789
+ # Impossible: >500mm/day
790
+ elif original_precip > self.PRECIP_MAX:
791
+ # Try to use the other source
792
+ if chirps is not None and 0 <= chirps <= self.PRECIP_MAX:
793
+ healed_precip = chirps
794
+ elif nasa is not None and 0 <= nasa <= self.PRECIP_MAX:
795
+ healed_precip = nasa
796
+ else:
797
+ healed_precip = None # can't salvage
798
+ assessment_type = "corrected"
799
+ reasoning_parts.append(
800
+ f"Extreme precipitation ({original_precip}mm) exceeds 500mm/day physical limit."
801
+ )
802
+
803
+ # Check source disagreement
804
+ elif chirps is not None and nasa is not None:
805
+ diff = abs(chirps - nasa)
806
+ if diff > self.SOURCE_DISAGREE_MM:
807
+ # Use CHIRPS as primary (higher resolution)
808
+ healed_precip = chirps
809
+ assessment_type = "corrected"
810
+ reasoning_parts.append(
811
+ f"Source disagreement: CHIRPS={chirps}mm vs NASA={nasa}mm "
812
+ f"(diff={diff:.1f}mm). Using CHIRPS."
813
+ )
814
+
815
+ # Check against climatological norms
816
+ zone = ZONE_MAP.get(zone_id)
817
+ if zone and healed_precip is not None and date:
818
+ try:
819
+ month = int(date.split("-")[1])
820
+ norms = CLIMATOLOGICAL_NORMALS.get((zone.city, month))
821
+ if norms:
822
+ daily_max = norms["daily_range"][1]
823
+ std = norms["std_dev_mm"]
824
+ # Flag if daily reading > 3x the monthly std dev
825
+ if healed_precip > 3 * std and assessment_type == "good":
826
+ assessment_type = "flagged"
827
+ reasoning_parts.append(
828
+ f"Daily precip ({healed_precip}mm) exceeds 3x monthly "
829
+ f"std dev ({std}mm) for {zone.city} in month {month}."
830
+ )
831
+ except (ValueError, IndexError):
832
+ pass
833
+
834
+ elif original_precip is None:
835
+ # Missing precipitation — try to fill
836
+ if chirps is not None:
837
+ healed_precip = chirps
838
+ assessment_type = "filled"
839
+ reasoning_parts.append("Missing precipitation filled from CHIRPS.")
840
+ elif nasa is not None:
841
+ healed_precip = nasa
842
+ assessment_type = "filled"
843
+ reasoning_parts.append("Missing precipitation filled from NASA POWER.")
844
+ else:
845
+ assessment_type = "flagged"
846
+ reasoning_parts.append("Missing precipitation with no source available for fill.")
847
+
848
+ # --- Check temperature ---
849
+ if original_temp is not None:
850
+ if not (self.TEMP_MIN <= original_temp <= self.TEMP_MAX):
851
+ # Possible decimal typo (e.g., 325 -> 32.5)
852
+ if original_temp > 100:
853
+ healed_temp = original_temp / 10.0
854
+ if assessment_type == "good":
855
+ assessment_type = "corrected"
856
+ reasoning_parts.append(
857
+ f"Temperature typo ({original_temp}°C) corrected to {healed_temp}°C."
858
+ )
859
+ else:
860
+ healed_temp = None
861
+ if assessment_type == "good":
862
+ assessment_type = "flagged"
863
+ reasoning_parts.append(
864
+ f"Temperature ({original_temp}°C) outside valid range."
865
+ )
866
+ elif original_temp is None:
867
+ # NASA POWER provides temperature even when CHIRPS doesn't
868
+ nasa_temp = reading.get("temp_mean_c") # already None
869
+ if nasa_temp is None:
870
+ # Try to use city norms
871
+ zone = ZONE_MAP.get(zone_id)
872
+ if zone and date:
873
+ try:
874
+ month = int(date.split("-")[1])
875
+ norms = CLIMATOLOGICAL_NORMALS.get((zone.city, month))
876
+ if norms:
877
+ healed_temp = norms["mean_temp_c"]
878
+ if assessment_type == "good":
879
+ assessment_type = "filled"
880
+ reasoning_parts.append(
881
+ f"Missing temperature filled from climatological mean "
882
+ f"({healed_temp}°C) for {zone.city}."
883
+ )
884
+ except (ValueError, IndexError):
885
+ pass
886
+
887
+ # Determine quality score
888
+ quality_map = {"good": 0.95, "corrected": 0.75, "filled": 0.55, "flagged": 0.3}
889
+ quality = quality_map.get(assessment_type, 0.5)
890
+
891
+ if not reasoning_parts:
892
+ reasoning_parts.append("Values within expected ranges; dual-source agreement.")
893
+
894
+ latency_ms = (time.time() - t0) * 1000
895
+
896
+ healed = HealedReading(
897
+ zone_id=zone_id,
898
+ date=date,
899
+ precip_mm=healed_precip,
900
+ temp_mean_c=healed_temp,
901
+ temp_max_c=reading.get("temp_max_c"),
902
+ temp_min_c=reading.get("temp_min_c"),
903
+ humidity_pct=reading.get("humidity_pct"),
904
+ wind_speed_ms=reading.get("wind_speed_ms"),
905
+ heal_action=f"rule_{assessment_type}",
906
+ quality_score=quality,
907
+ )
908
+
909
+ assessment = HealingAssessment(
910
+ zone_id=zone_id,
911
+ date=date,
912
+ assessment=assessment_type,
913
+ original_precip=original_precip,
914
+ healed_precip=healed_precip,
915
+ original_temp=original_temp,
916
+ healed_temp=healed_temp,
917
+ reasoning=" ".join(reasoning_parts),
918
+ tools_used=tools_used,
919
+ tokens_used=0,
920
+ latency_ms=latency_ms,
921
+ )
922
+
923
+ return healed, assessment
924
+
925
+ def heal_batch(self, readings: list[dict[str, Any]]) -> HealedData:
926
+ """Heal a batch of readings using rule-based logic.
927
+
928
+ Args:
929
+ readings: list of dicts with DailyReading fields.
930
+
931
+ Returns:
932
+ HealedData with healed readings and assessments.
933
+ """
934
+ all_healed: list[HealedReading] = []
935
+ all_assessments: list[HealingAssessment] = []
936
+
937
+ for r in readings:
938
+ healed, assessment = self.heal_reading(r)
939
+ all_healed.append(healed)
940
+ all_assessments.append(assessment)
941
+
942
+ quality_scores = [h.quality_score for h in all_healed]
943
+ avg_quality = sum(quality_scores) / len(quality_scores) if quality_scores else 0.0
944
+
945
+ zone_id = readings[0]["zone_id"] if readings else "unknown"
946
+
947
+ return HealedData(
948
+ zone_id=zone_id,
949
+ readings=all_healed,
950
+ quality_score=round(avg_quality, 3),
951
+ assessments=all_assessments,
952
+ healer_used="rule_based",
953
+ )