| """Empirical burn analysis for parametric heat insurance pricing. |
| |
| Industry-standard actuarial approach: apply the trigger rules to 20 years |
| of historical weather data, count how often each tier would have paid out, |
| and price accordingly. This is how ARC, CCRIF, and the SEWA/Arsht-Rockefeller |
| pilot actually set premiums — no neural network, just historical frequency |
| × expected payout × loading factors. |
| |
| Usage: |
| from src.pricing.burn_analysis import BurnAnalysisPricer |
| pricer = BurnAnalysisPricer() |
| result = pricer.price_zone(zone) |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import logging |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| from config import UrbanZone, PRIMARY_CITY_SLUG |
| from src.downscaling import get_zone_uhi_range |
| from src.indexing.heat_index import calculate_wbgt |
| from src.pricing.actuarial import ActuarialResult |
|
|
| log = logging.getLogger(__name__) |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[2] |
|
|
| |
| WBGT_THRESHOLD_C = 35.1 |
| ALERT_CONSECUTIVE_DAYS = 2 |
| PAYOUT_CONSECUTIVE_DAYS = 5 |
|
|
| |
| ALERT_PAYOUT_USD = 3.50 |
| FULL_PAYOUT_USD = 14.00 |
|
|
| |
| ADMIN_RATE = 0.15 |
| CONTINGENCY_RATE = 0.05 |
| INFLATION_BUFFER = 1.05 |
|
|
| |
| WORKER_CAP_USD = 3.00 |
| PHILANTHROPY_SHARE = 0.45 |
| INSURER_SHARE = 0.40 |
|
|
|
|
| @dataclass |
| class BurnResult: |
| """Burn analysis result for a single zone.""" |
| zone_id: str |
| years_analyzed: int |
| alert_events_total: int |
| payout_events_total: int |
| alert_freq_per_year: float |
| payout_freq_per_year: float |
| mean_alert_duration_days: float |
| mean_payout_duration_days: float |
| expected_annual_loss_usd: float |
| basis_risk_score: float |
|
|
|
|
| def _load_era5_data() -> dict[str, list[dict]]: |
| """Load ERA5-Land daily records for the primary city.""" |
| path = PROJECT_ROOT / "data" / f"era5land_{PRIMARY_CITY_SLUG}.json" |
| return json.loads(path.read_text()) |
|
|
|
|
| def compute_burn( |
| daily_records: list[dict], |
| uhi_delta_c: float, |
| threshold_c: float = WBGT_THRESHOLD_C, |
| ) -> BurnResult: |
| """Run burn analysis on daily records for one zone. |
| |
| Applies UHI correction, computes WBGT, counts alert and payout events |
| over the full history. |
| |
| Args: |
| daily_records: List of daily dicts with temp_max_c, humidity_pct, date. |
| uhi_delta_c: Mean UHI temperature correction for this zone (°C). |
| threshold_c: WBGT threshold for triggering (default 35.1°C). |
| |
| Returns: |
| BurnResult with event counts and frequencies. |
| """ |
| |
| wbgts = [] |
| for day in daily_records: |
| temp = (day.get("temp_max_c") or 30.0) + uhi_delta_c |
| hum = day.get("humidity_pct") or 70.0 |
| wbgts.append(calculate_wbgt(temp, hum)) |
|
|
| |
| |
| |
| alert_only_durations = [] |
| payout_durations = [] |
| run_length = 0 |
|
|
| for w in wbgts: |
| if w >= threshold_c: |
| run_length += 1 |
| else: |
| if run_length >= PAYOUT_CONSECUTIVE_DAYS: |
| payout_durations.append(run_length) |
| elif run_length >= ALERT_CONSECUTIVE_DAYS: |
| alert_only_durations.append(run_length) |
| run_length = 0 |
| |
| if run_length >= PAYOUT_CONSECUTIVE_DAYS: |
| payout_durations.append(run_length) |
| elif run_length >= ALERT_CONSECUTIVE_DAYS: |
| alert_only_durations.append(run_length) |
|
|
| n_years = len(daily_records) / 365.25 |
|
|
| return BurnResult( |
| zone_id="", |
| years_analyzed=round(n_years), |
| alert_events_total=len(alert_only_durations), |
| payout_events_total=len(payout_durations), |
| alert_freq_per_year=len(alert_only_durations) / n_years if n_years > 0 else 0, |
| payout_freq_per_year=len(payout_durations) / n_years if n_years > 0 else 0, |
| mean_alert_duration_days=( |
| sum(alert_only_durations) / len(alert_only_durations) |
| if alert_only_durations else 0 |
| ), |
| mean_payout_duration_days=( |
| sum(payout_durations) / len(payout_durations) |
| if payout_durations else 0 |
| ), |
| expected_annual_loss_usd=0, |
| basis_risk_score=0, |
| ) |
|
|
|
|
| def _basis_risk_for_zone(zone: UrbanZone, uhi_delta_c: float) -> float: |
| """Estimate basis risk from UHI uncertainty. |
| |
| Basis risk arises because the index (ERA5 grid + UHI model) doesn't |
| perfectly match actual conditions at each worker's location. Higher |
| UHI correction = more uncertainty = higher basis risk. |
| |
| Returns a score 0-1 where higher means more basis risk. |
| """ |
| uhi_lo, uhi_hi = get_zone_uhi_range(zone) |
| uhi_range = uhi_hi - uhi_lo |
|
|
| |
| |
| uncertainty_component = min(0.20, uhi_range * 0.05) |
|
|
| |
| |
| exposure_component = zone.outdoor_exposure_pct * 0.05 |
|
|
| return round(uncertainty_component + exposure_component + 0.05, 3) |
|
|
|
|
| class BurnAnalysisPricer: |
| """Price parametric heat coverage using empirical burn analysis. |
| |
| Loads 20 years of ERA5-Land data once, runs burn analysis per zone |
| with UHI correction, and computes loaded premiums. |
| """ |
|
|
| def __init__(self): |
| self._era5_data: dict[str, list[dict]] | None = None |
| self._burn_cache: dict[str, BurnResult] = {} |
|
|
| def _ensure_loaded(self) -> None: |
| if self._era5_data is None: |
| self._era5_data = _load_era5_data() |
| log.info("Loaded ERA5-Land data: %d zones", len(self._era5_data)) |
|
|
| def burn_for_zone(self, zone: UrbanZone) -> BurnResult: |
| """Get or compute burn analysis for a zone.""" |
| if zone.zone_id in self._burn_cache: |
| return self._burn_cache[zone.zone_id] |
|
|
| self._ensure_loaded() |
|
|
| records = self._era5_data.get(zone.zone_id, []) |
| if not records: |
| log.warning("No ERA5 data for zone %s", zone.zone_id) |
| result = BurnResult( |
| zone_id=zone.zone_id, years_analyzed=0, |
| alert_events_total=0, payout_events_total=0, |
| alert_freq_per_year=0, payout_freq_per_year=0, |
| mean_alert_duration_days=0, mean_payout_duration_days=0, |
| expected_annual_loss_usd=0, basis_risk_score=0.1, |
| ) |
| self._burn_cache[zone.zone_id] = result |
| return result |
|
|
| |
| uhi_lo, uhi_hi = get_zone_uhi_range(zone) |
| mean_uhi = (uhi_lo + uhi_hi) / 2.0 |
|
|
| |
| |
| from src.pricing.zone_thresholds import get_zone_thresholds |
| alert_c, _payout_peak_c = get_zone_thresholds(zone) |
|
|
| result = compute_burn(records, mean_uhi, threshold_c=alert_c) |
| result.zone_id = zone.zone_id |
| result.basis_risk_score = _basis_risk_for_zone(zone, mean_uhi) |
|
|
| |
| result.expected_annual_loss_usd = ( |
| result.alert_freq_per_year * ALERT_PAYOUT_USD |
| + result.payout_freq_per_year * FULL_PAYOUT_USD |
| ) |
|
|
| self._burn_cache[zone.zone_id] = result |
| return result |
|
|
| def price_zone( |
| self, |
| zone: UrbanZone, |
| **kwargs, |
| ) -> ActuarialResult: |
| """Price coverage for a single zone using burn analysis. |
| |
| Accepts **kwargs for compatibility with NeuralActuarialPricer's |
| interface (predicted_frequency, climate_history, etc. are ignored). |
| """ |
| burn = self.burn_for_zone(zone) |
| enrolled = max(zone.worker_population_est, 1) |
|
|
| |
| eal_per_worker = burn.expected_annual_loss_usd |
|
|
| |
| basis_risk_loading = eal_per_worker * burn.basis_risk_score |
| vulnerability_loading = eal_per_worker * (zone.outdoor_exposure_pct * 0.10) |
| subtotal = eal_per_worker + basis_risk_loading + vulnerability_loading |
| admin_loading = subtotal * ADMIN_RATE |
| contingency = subtotal * CONTINGENCY_RATE |
| loaded_premium = (subtotal + admin_loading + contingency) * INFLATION_BUFFER |
|
|
| |
| worker_share = min(WORKER_CAP_USD, loaded_premium) |
| remaining = loaded_premium - worker_share |
| philanthropy = remaining * (PHILANTHROPY_SHARE / (PHILANTHROPY_SHARE + INSURER_SHARE)) |
| insurer = remaining - philanthropy |
|
|
| cost_breakdown = { |
| |
| "alert_freq_per_year": round(burn.alert_freq_per_year, 2), |
| "payout_freq_per_year": round(burn.payout_freq_per_year, 2), |
| "alert_payout_usd": ALERT_PAYOUT_USD, |
| "full_payout_usd": FULL_PAYOUT_USD, |
| "years_analyzed": burn.years_analyzed, |
| "alert_events_total": burn.alert_events_total, |
| "payout_events_total": burn.payout_events_total, |
| "mean_alert_duration_days": round(burn.mean_alert_duration_days, 1), |
| "mean_payout_duration_days": round(burn.mean_payout_duration_days, 1), |
| |
| "expected_annual_loss": round(eal_per_worker, 2), |
| "basis_risk_loading": round(basis_risk_loading, 2), |
| "basis_risk_score": burn.basis_risk_score, |
| "vulnerability_loading": round(vulnerability_loading, 2), |
| "admin_loading": round(admin_loading, 2), |
| "contingency_loading": round(contingency, 2), |
| "loaded_premium": round(loaded_premium, 2), |
| |
| "worker_contribution": round(worker_share, 2), |
| "philanthropy_share": round(philanthropy, 2), |
| "insurer_premium": round(insurer, 2), |
| |
| "learned_frequency": round( |
| burn.alert_freq_per_year + burn.payout_freq_per_year, 2), |
| "trigger_prob": 0, |
| "payout_factor": round( |
| burn.payout_freq_per_year / max(burn.alert_freq_per_year, 0.1), 2), |
| } |
|
|
| total_zone_cost = loaded_premium * enrolled |
|
|
| result = ActuarialResult( |
| zone_id=zone.zone_id, |
| zone_name=zone.name, |
| city=zone.city, |
| cost_per_worker_year=round(loaded_premium, 2), |
| expected_annual_payouts=round(burn.expected_annual_loss_usd * enrolled, 2), |
| frequency_component=round(eal_per_worker, 2), |
| basis_risk_loading=round(basis_risk_loading * enrolled, 2), |
| vulnerability_loading=round(vulnerability_loading * enrolled, 2), |
| admin_loading=round((admin_loading + contingency) * enrolled, 2), |
| cost_breakdown=cost_breakdown, |
| enrolled_workers=enrolled, |
| ) |
|
|
| log.info( |
| "Priced %s (%s): $%.2f/worker/yr | alert=%.1f/yr payout=%.1f/yr | " |
| "EAL=$%.2f | basis_risk=%.1f%%", |
| zone.name, zone.settlement_type, loaded_premium, |
| burn.alert_freq_per_year, burn.payout_freq_per_year, |
| eal_per_worker, burn.basis_risk_score * 100, |
| ) |
|
|
| return result |
|
|