Commit ·
e2d3383
0
Parent(s):
Initial commit: Extreme Heat Risk Engine
Browse files6-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
- .env.example +2 -0
- .gitattributes +4 -0
- .gitignore +12 -0
- Dockerfile +40 -0
- config.py +181 -0
- frontend/index.html +16 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +30 -0
- frontend/postcss.config.js +6 -0
- frontend/public/vite.svg +5 -0
- frontend/src/App.tsx +143 -0
- frontend/src/components/Layout.tsx +17 -0
- frontend/src/components/LoadingState.tsx +33 -0
- frontend/src/components/MetricCard.tsx +77 -0
- frontend/src/components/Sidebar.tsx +73 -0
- frontend/src/components/StatusBadge.tsx +33 -0
- frontend/src/index.css +308 -0
- frontend/src/lib/api.ts +340 -0
- frontend/src/lib/tour.ts +198 -0
- frontend/src/main.tsx +25 -0
- frontend/src/pages/Dashboard.tsx +168 -0
- frontend/src/pages/HeatMonitor.tsx +334 -0
- frontend/src/pages/Notifications.tsx +106 -0
- frontend/src/pages/Pipeline.tsx +387 -0
- frontend/src/pages/ProgramDesigner.tsx +296 -0
- frontend/src/pages/Zones.tsx +244 -0
- frontend/src/vite-env.d.ts +9 -0
- frontend/tailwind.config.js +50 -0
- frontend/tsconfig.json +23 -0
- frontend/tsconfig.tsbuildinfo +1 -0
- frontend/vercel.json +8 -0
- frontend/vite.config.ts +19 -0
- models/heat_predictor_xgb.json +0 -0
- models/uhi_xgb.json +0 -0
- requirements.txt +9 -0
- run_pipeline.py +91 -0
- src/__init__.py +0 -0
- src/api.py +591 -0
- src/calibration/__init__.py +0 -0
- src/calibration/basis_risk.py +386 -0
- src/database/__init__.py +0 -0
- src/database/crud.py +651 -0
- src/database/schema.py +319 -0
- src/downscaling/__init__.py +0 -0
- src/downscaling/uhi_model.py +274 -0
- src/explanation/__init__.py +0 -0
- src/explanation/explainer.py +363 -0
- src/explanation/knowledge_base.py +340 -0
- src/healing/__init__.py +0 -0
- 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 |
+
)
|