Simplify: reuse shared WBGT+percentile utils, batch DB writes, memoize reads
Browse filesCode-reuse cleanup:
- src/pricing/zone_thresholds.py: drop local _calculate_wbgt + _percentile
helpers; use src.indexing.heat_index.calculate_wbgt and numpy.percentile.
- src/downscaling/uhi_lst_model.py: promote _load_zone_deltas to public
load_zone_deltas (with underscore-prefix alias retained); add module-level
cache so the features JSON is read once per process instead of per call.
- src/downscaling/__init__.py: factory now imports load_zone_deltas by its
public name (removing the underscore-prefix reach-across-modules).
Efficiency cleanup:
- src/pipeline.py::_step_predict: hoist compute_zone_thresholds out of the
per-zone loop (previously invoked on every get_zone_thresholds call via
both forecast + fallback paths, ~30 JSON reads per run).
- src/pricing/zone_thresholds.py: add in-process _IN_MEMORY_CACHE keyed on
UHI_MODEL so repeat get_zone_thresholds calls within a run skip disk.
- src/pricing/zone_thresholds.py::persist_to_neon: replace per-zone
cur.execute loop with cur.executemany (1 round-trip instead of 15).
Quality cleanup:
- frontend/src/pages/Dashboard.tsx: drop redundant "?? null" and unify
nullish-checking on alert_threshold_c.
Code paths verified end-to-end — DAR-JAN (LST-covered) δ=-2.05 / alert 31.8;
DAR-KIN (no LST coverage, synthetic fallback) δ=+0.78 / alert 35.36;
DAR-TAN δ=+1.34 / alert 36.1 — all match known-good numbers from
yesterday's validation run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- frontend/src/pages/Dashboard.tsx +5 -5
- src/downscaling/__init__.py +2 -2
- src/downscaling/uhi_lst_model.py +24 -4
- src/pipeline.py +10 -4
- src/pricing/zone_thresholds.py +51 -49
|
@@ -261,11 +261,11 @@ function StepOutput({
|
|
| 261 |
: 'GraphCast + QM-MOS'
|
| 262 |
// Zone-specific thresholds if the pipeline has written them; fall back
|
| 263 |
// to the legacy city-wide defaults otherwise.
|
| 264 |
-
const
|
| 265 |
-
const
|
| 266 |
-
const
|
| 267 |
-
|
| 268 |
-
|
| 269 |
const status =
|
| 270 |
wbgt >= payoutThreshold ? 'payout threshold' : wbgt >= alertThreshold ? 'alert threshold' : 'below alert'
|
| 271 |
return (
|
|
|
|
| 261 |
: 'GraphCast + QM-MOS'
|
| 262 |
// Zone-specific thresholds if the pipeline has written them; fall back
|
| 263 |
// to the legacy city-wide defaults otherwise.
|
| 264 |
+
const alertThreshold = kin?.alert_threshold_c ?? 35.1
|
| 265 |
+
const payoutThreshold = kin?.payout_threshold_c ?? 38.8
|
| 266 |
+
const thresholdLabel = kin?.alert_threshold_c != null
|
| 267 |
+
? `zone-specific · ${kin.name}`
|
| 268 |
+
: 'city-wide default'
|
| 269 |
const status =
|
| 270 |
wbgt >= payoutThreshold ? 'payout threshold' : wbgt >= alertThreshold ? 'alert threshold' : 'below alert'
|
| 271 |
return (
|
|
@@ -48,8 +48,8 @@ def get_zone_uhi_range(zone) -> Tuple[float, float]:
|
|
| 48 |
"""
|
| 49 |
if _model_env() == "lst":
|
| 50 |
from src.downscaling.uhi_model import UHI_RANGES
|
| 51 |
-
from src.downscaling.uhi_lst_model import
|
| 52 |
-
zone_deltas =
|
| 53 |
if zone.zone_id in zone_deltas:
|
| 54 |
center = zone_deltas[zone.zone_id]
|
| 55 |
# Scale uncertainty: LST_TO_AIR_SCALE literature range [0.3, 0.7]
|
|
|
|
| 48 |
"""
|
| 49 |
if _model_env() == "lst":
|
| 50 |
from src.downscaling.uhi_model import UHI_RANGES
|
| 51 |
+
from src.downscaling.uhi_lst_model import load_zone_deltas
|
| 52 |
+
zone_deltas = load_zone_deltas()
|
| 53 |
if zone.zone_id in zone_deltas:
|
| 54 |
center = zone_deltas[zone.zone_id]
|
| 55 |
# Scale uncertainty: LST_TO_AIR_SCALE literature range [0.3, 0.7]
|
|
@@ -61,10 +61,30 @@ def _build_features(zone, hour: int, month: int) -> np.ndarray:
|
|
| 61 |
).reshape(1, -1)
|
| 62 |
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
def _diurnal_factor(hour: int) -> float:
|
|
|
|
| 61 |
).reshape(1, -1)
|
| 62 |
|
| 63 |
|
| 64 |
+
_ZONE_DELTAS_CACHE: dict[str, float] | None = None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def load_zone_deltas() -> dict[str, float]:
|
| 68 |
+
"""Per-zone LST-hot-anomaly × LST_TO_AIR_SCALE — the data-anchored UHI delta.
|
| 69 |
+
|
| 70 |
+
Module-level-cached on first call. Features file is small (~5 KB) but read
|
| 71 |
+
only once per process: the factory in ``src.downscaling`` and the corrector
|
| 72 |
+
both call this, and in a loop over zones the file would otherwise be re-read
|
| 73 |
+
15+ times per pipeline run.
|
| 74 |
+
"""
|
| 75 |
+
global _ZONE_DELTAS_CACHE
|
| 76 |
+
if _ZONE_DELTAS_CACHE is None:
|
| 77 |
+
feats = json.loads(FEATURES_PATH.read_text())
|
| 78 |
+
_ZONE_DELTAS_CACHE = {
|
| 79 |
+
zid: round(f["lst_hot_anomaly"] * LST_TO_AIR_SCALE, 3)
|
| 80 |
+
for zid, f in feats.items()
|
| 81 |
+
}
|
| 82 |
+
return _ZONE_DELTAS_CACHE
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# Legacy alias retained for any external callers (the factory used to reach the
|
| 86 |
+
# underscore-prefixed name directly).
|
| 87 |
+
_load_zone_deltas = load_zone_deltas
|
| 88 |
|
| 89 |
|
| 90 |
def _diurnal_factor(hour: int) -> float:
|
|
@@ -545,13 +545,19 @@ class HeatRiskPipeline:
|
|
| 545 |
ALERT_PAYOUT_USD, FULL_PAYOUT_USD,
|
| 546 |
)
|
| 547 |
|
| 548 |
-
#
|
| 549 |
-
# per-zone values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
try:
|
| 551 |
-
from src.pricing.zone_thresholds import persist_to_neon
|
| 552 |
if self.db is not None:
|
| 553 |
conn = self.db._conn()
|
| 554 |
-
n = persist_to_neon(conn)
|
| 555 |
if n:
|
| 556 |
log.info("zone_thresholds persisted to Neon: %d zones", n)
|
| 557 |
except Exception as exc:
|
|
|
|
| 545 |
ALERT_PAYOUT_USD, FULL_PAYOUT_USD,
|
| 546 |
)
|
| 547 |
|
| 548 |
+
# Load zone thresholds once for the whole step, then push to Neon so the
|
| 549 |
+
# Vercel frontend can read per-zone values. Both operations are no-ops
|
| 550 |
+
# under THRESHOLD_MODE=global. Hoisting out of the zone loop avoids
|
| 551 |
+
# ~30 redundant cache reads per run (15 zones × 2 call sites).
|
| 552 |
+
from src.pricing.zone_thresholds import (
|
| 553 |
+
compute_zone_thresholds, get_zone_thresholds, persist_to_neon,
|
| 554 |
+
)
|
| 555 |
+
_zone_threshold_cache = compute_zone_thresholds(use_cache=True)
|
| 556 |
+
|
| 557 |
try:
|
|
|
|
| 558 |
if self.db is not None:
|
| 559 |
conn = self.db._conn()
|
| 560 |
+
n = persist_to_neon(conn, thresholds=_zone_threshold_cache)
|
| 561 |
if n:
|
| 562 |
log.info("zone_thresholds persisted to Neon: %d zones", n)
|
| 563 |
except Exception as exc:
|
|
@@ -16,42 +16,33 @@ recompute after any UHI model change.
|
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import json
|
| 19 |
-
import math
|
| 20 |
import os
|
| 21 |
from pathlib import Path
|
| 22 |
from typing import Tuple
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 25 |
ERA5_PATH = _REPO_ROOT / "data" / "era5land_dar_es_salaam.json"
|
| 26 |
CACHE_PATH = _REPO_ROOT / "data" / "zone_thresholds.json"
|
| 27 |
|
| 28 |
-
ALERT_PERCENTILE =
|
| 29 |
-
|
| 30 |
-
PAYOUT_PERCENTILE =
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _calculate_wbgt(temp_c: float, humidity_pct: float) -> float:
|
| 34 |
-
"""Liljegren simplified outdoor — matches CRE src.indexing.heat_index."""
|
| 35 |
-
es = 6.112 * math.exp((17.67 * temp_c) / (temp_c + 243.5))
|
| 36 |
-
e = es * (humidity_pct / 100.0)
|
| 37 |
-
return 0.567 * temp_c + 0.393 * e + 3.94
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def _percentile(values: list[float], p: float) -> float:
|
| 41 |
-
if not values:
|
| 42 |
-
return 0.0
|
| 43 |
-
s = sorted(values)
|
| 44 |
-
idx = p * (len(s) - 1)
|
| 45 |
-
lo, hi = int(math.floor(idx)), int(math.ceil(idx))
|
| 46 |
-
if lo == hi:
|
| 47 |
-
return s[lo]
|
| 48 |
-
return s[lo] + (s[hi] - s[lo]) * (idx - lo)
|
| 49 |
|
| 50 |
|
| 51 |
def _threshold_mode() -> str:
|
| 52 |
return os.environ.get("THRESHOLD_MODE", "global").lower()
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def compute_zone_thresholds(use_cache: bool = True) -> dict[str, dict[str, float]]:
|
| 56 |
"""Return {zone_id: {alert_c, payout_c, n_days, mean_wbgt_c}} for Dar zones.
|
| 57 |
|
|
@@ -59,14 +50,20 @@ def compute_zone_thresholds(use_cache: bool = True) -> dict[str, dict[str, float
|
|
| 59 |
to the 20-year ERA5-Land DAR-JAN grid-cell series, computes per-day WBGT,
|
| 60 |
and extracts percentiles.
|
| 61 |
|
| 62 |
-
Cached
|
| 63 |
"""
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
from config import ZONES
|
| 68 |
from src.downscaling import get_uhi_corrector
|
| 69 |
-
from datetime import datetime
|
| 70 |
|
| 71 |
era5 = json.loads(ERA5_PATH.read_text())
|
| 72 |
grid_rows = era5["DAR-JAN"] # all 15 Dar zones resolve to this grid cell
|
|
@@ -85,18 +82,20 @@ def compute_zone_thresholds(use_cache: bool = True) -> dict[str, dict[str, float
|
|
| 85 |
month = int(r["date"][5:7])
|
| 86 |
# Apply UHI correction at this zone for this month (mid-day)
|
| 87 |
corrected_t, _, _ = corrector.correct_temperature(z, float(t), hour=14, month=month)
|
| 88 |
-
wbgts.append(
|
| 89 |
if not wbgts:
|
| 90 |
continue
|
|
|
|
| 91 |
out[z.zone_id] = {
|
| 92 |
-
"alert_c": round(
|
| 93 |
-
"payout_c": round(
|
| 94 |
-
"n_days":
|
| 95 |
-
"mean_wbgt_c": round(
|
| 96 |
"uhi_model": os.environ.get("UHI_MODEL", "synthetic").lower(),
|
| 97 |
}
|
| 98 |
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 99 |
CACHE_PATH.write_text(json.dumps(out, indent=2))
|
|
|
|
| 100 |
return out
|
| 101 |
|
| 102 |
|
|
@@ -146,27 +145,30 @@ def persist_to_neon(conn, thresholds: dict[str, dict[str, float]] | None = None)
|
|
| 146 |
return 0
|
| 147 |
uhi_model = os.environ.get("UHI_MODEL", "synthetic").lower()
|
| 148 |
mode = _threshold_mode()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
cur = conn.cursor()
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
(zid, float(v["alert_c"]), float(v["payout_c"]), uhi_model, mode),
|
| 165 |
-
)
|
| 166 |
-
n += 1
|
| 167 |
conn.commit()
|
| 168 |
cur.close()
|
| 169 |
-
return
|
| 170 |
|
| 171 |
|
| 172 |
if __name__ == "__main__":
|
|
|
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import json
|
|
|
|
| 19 |
import os
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Tuple
|
| 22 |
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
from src.indexing.heat_index import calculate_wbgt
|
| 26 |
+
|
| 27 |
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 28 |
ERA5_PATH = _REPO_ROOT / "data" / "era5land_dar_es_salaam.json"
|
| 29 |
CACHE_PATH = _REPO_ROOT / "data" / "zone_thresholds.json"
|
| 30 |
|
| 31 |
+
ALERT_PERCENTILE = 90 # P90 for alert-tier trigger (matches grid-cell
|
| 32 |
+
# 35.1°C historical origin on raw ERA5-Land)
|
| 33 |
+
PAYOUT_PERCENTILE = 97 # P97 for payout-tier peak severity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def _threshold_mode() -> str:
|
| 37 |
return os.environ.get("THRESHOLD_MODE", "global").lower()
|
| 38 |
|
| 39 |
|
| 40 |
+
# In-memory cache so the pipeline per-zone loop doesn't re-read the JSON cache
|
| 41 |
+
# file on every get_zone_thresholds(zone) call. Keyed by UHI_MODEL so a flag
|
| 42 |
+
# change within the same process (e.g. tests) still picks up fresh values.
|
| 43 |
+
_IN_MEMORY_CACHE: dict[str, dict[str, dict[str, float]]] = {}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
def compute_zone_thresholds(use_cache: bool = True) -> dict[str, dict[str, float]]:
|
| 47 |
"""Return {zone_id: {alert_c, payout_c, n_days, mean_wbgt_c}} for Dar zones.
|
| 48 |
|
|
|
|
| 50 |
to the 20-year ERA5-Land DAR-JAN grid-cell series, computes per-day WBGT,
|
| 51 |
and extracts percentiles.
|
| 52 |
|
| 53 |
+
Cached in-process + on disk (``CACHE_PATH``) to avoid recomputing.
|
| 54 |
"""
|
| 55 |
+
uhi_key = os.environ.get("UHI_MODEL", "synthetic").lower()
|
| 56 |
+
if use_cache:
|
| 57 |
+
cached = _IN_MEMORY_CACHE.get(uhi_key)
|
| 58 |
+
if cached is not None:
|
| 59 |
+
return cached
|
| 60 |
+
if CACHE_PATH.exists():
|
| 61 |
+
cached = json.loads(CACHE_PATH.read_text())
|
| 62 |
+
_IN_MEMORY_CACHE[uhi_key] = cached
|
| 63 |
+
return cached
|
| 64 |
|
| 65 |
from config import ZONES
|
| 66 |
from src.downscaling import get_uhi_corrector
|
|
|
|
| 67 |
|
| 68 |
era5 = json.loads(ERA5_PATH.read_text())
|
| 69 |
grid_rows = era5["DAR-JAN"] # all 15 Dar zones resolve to this grid cell
|
|
|
|
| 82 |
month = int(r["date"][5:7])
|
| 83 |
# Apply UHI correction at this zone for this month (mid-day)
|
| 84 |
corrected_t, _, _ = corrector.correct_temperature(z, float(t), hour=14, month=month)
|
| 85 |
+
wbgts.append(calculate_wbgt(corrected_t, float(h)))
|
| 86 |
if not wbgts:
|
| 87 |
continue
|
| 88 |
+
arr = np.asarray(wbgts, dtype=np.float32)
|
| 89 |
out[z.zone_id] = {
|
| 90 |
+
"alert_c": round(float(np.percentile(arr, ALERT_PERCENTILE)), 2),
|
| 91 |
+
"payout_c": round(float(np.percentile(arr, PAYOUT_PERCENTILE)), 2),
|
| 92 |
+
"n_days": int(arr.size),
|
| 93 |
+
"mean_wbgt_c": round(float(arr.mean()), 2),
|
| 94 |
"uhi_model": os.environ.get("UHI_MODEL", "synthetic").lower(),
|
| 95 |
}
|
| 96 |
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 97 |
CACHE_PATH.write_text(json.dumps(out, indent=2))
|
| 98 |
+
_IN_MEMORY_CACHE[uhi_key] = out
|
| 99 |
return out
|
| 100 |
|
| 101 |
|
|
|
|
| 145 |
return 0
|
| 146 |
uhi_model = os.environ.get("UHI_MODEL", "synthetic").lower()
|
| 147 |
mode = _threshold_mode()
|
| 148 |
+
rows = [
|
| 149 |
+
(zid, float(v["alert_c"]), float(v["payout_c"]), uhi_model, mode)
|
| 150 |
+
for zid, v in thresholds.items()
|
| 151 |
+
]
|
| 152 |
+
if not rows:
|
| 153 |
+
return 0
|
| 154 |
cur = conn.cursor()
|
| 155 |
+
cur.executemany(
|
| 156 |
+
"""
|
| 157 |
+
INSERT INTO zone_thresholds
|
| 158 |
+
(zone_id, alert_threshold_c, payout_threshold_c, uhi_model, threshold_mode, computed_at)
|
| 159 |
+
VALUES (%s, %s, %s, %s, %s, NOW())
|
| 160 |
+
ON CONFLICT (zone_id) DO UPDATE SET
|
| 161 |
+
alert_threshold_c = EXCLUDED.alert_threshold_c,
|
| 162 |
+
payout_threshold_c = EXCLUDED.payout_threshold_c,
|
| 163 |
+
uhi_model = EXCLUDED.uhi_model,
|
| 164 |
+
threshold_mode = EXCLUDED.threshold_mode,
|
| 165 |
+
computed_at = NOW()
|
| 166 |
+
""",
|
| 167 |
+
rows,
|
| 168 |
+
)
|
|
|
|
|
|
|
|
|
|
| 169 |
conn.commit()
|
| 170 |
cur.close()
|
| 171 |
+
return len(rows)
|
| 172 |
|
| 173 |
|
| 174 |
if __name__ == "__main__":
|