jtlevine Claude Opus 4.7 (1M context) commited on
Commit
47548c2
·
1 Parent(s): c7d99b0

Simplify: reuse shared WBGT+percentile utils, batch DB writes, memoize reads

Browse files

Code-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 CHANGED
@@ -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 zoneAlert = kin?.alert_threshold_c ?? null
265
- const zonePayout = kin?.payout_threshold_c ?? null
266
- const alertThreshold = zoneAlert ?? 35.1
267
- const payoutThreshold = zonePayout ?? 38.8
268
- const thresholdLabel = zoneAlert != null ? `zone-specific · ${kin?.name ?? ''}` : 'city-wide default'
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 (
src/downscaling/__init__.py CHANGED
@@ -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 _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]
 
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]
src/downscaling/uhi_lst_model.py CHANGED
@@ -61,10 +61,30 @@ def _build_features(zone, hour: int, month: int) -> np.ndarray:
61
  ).reshape(1, -1)
62
 
63
 
64
- def _load_zone_deltas() -> dict[str, float]:
65
- """Per-zone LST-hot-anomaly × LST_TO_AIR_SCALE — the data-anchored UHI delta."""
66
- feats = json.loads(FEATURES_PATH.read_text())
67
- return {zid: round(f["lst_hot_anomaly"] * LST_TO_AIR_SCALE, 3) for zid, f in feats.items()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
src/pipeline.py CHANGED
@@ -545,13 +545,19 @@ class HeatRiskPipeline:
545
  ALERT_PAYOUT_USD, FULL_PAYOUT_USD,
546
  )
547
 
548
- # Push zone-specific thresholds to Neon so the Vercel frontend can read
549
- # per-zone values via the zones endpoint. No-op under THRESHOLD_MODE=global.
 
 
 
 
 
 
 
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:
src/pricing/zone_thresholds.py CHANGED
@@ -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 = 0.90 # P90 for alert-tier trigger (matches grid-cell
29
- # 35.1°C historical origin on raw ERA5-Land)
30
- PAYOUT_PERCENTILE = 0.97 # P97 for payout-tier peak severity
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 to ``CACHE_PATH`` to avoid re-computing on every pipeline run.
63
  """
64
- if use_cache and CACHE_PATH.exists():
65
- return json.loads(CACHE_PATH.read_text())
 
 
 
 
 
 
 
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(_calculate_wbgt(corrected_t, float(h)))
89
  if not wbgts:
90
  continue
 
91
  out[z.zone_id] = {
92
- "alert_c": round(_percentile(wbgts, ALERT_PERCENTILE), 2),
93
- "payout_c": round(_percentile(wbgts, PAYOUT_PERCENTILE), 2),
94
- "n_days": len(wbgts),
95
- "mean_wbgt_c": round(sum(wbgts) / len(wbgts), 2),
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
- n = 0
151
- for zid, v in thresholds.items():
152
- cur.execute(
153
- """
154
- INSERT INTO zone_thresholds
155
- (zone_id, alert_threshold_c, payout_threshold_c, uhi_model, threshold_mode, computed_at)
156
- VALUES (%s, %s, %s, %s, %s, NOW())
157
- ON CONFLICT (zone_id) DO UPDATE SET
158
- alert_threshold_c = EXCLUDED.alert_threshold_c,
159
- payout_threshold_c = EXCLUDED.payout_threshold_c,
160
- uhi_model = EXCLUDED.uhi_model,
161
- threshold_mode = EXCLUDED.threshold_mode,
162
- computed_at = NOW()
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 n
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__":