# Consequence Ring Calibration

> **Status:** current as of 2026-08-09. Every constant below was read from
> `lib/consequence-rings/*.ts`, not transcribed from a prior document.
> `tests/lib/consequence-ring-calibration-drift.test.ts` fails if this file and
> the code disagree, so the two cannot drift apart silently.

Consequence rings are the zone geometries drawn around an active event — the
thing a civilian sees their address inside, and the thing an ILS trigger
evaluates against. This document states where each ring's geometry comes from,
what it means, and how it was validated. It exists because "the model says so"
is not an answer an actuary or an acquirer's diligence team accepts.

---

## The governing principle: authoritative first, honest gap second

Every peril follows the same two-tier rule:

1. **Use the authoritative agency geometry when it exists.** USGS ShakeMap
   contours, NHC wind radii, NWS alert polygons, NIFC perimeters, SPC polygons.
   These are modeled by the agencies that own the peril, calibrated to local
   geology and observation networks. Nothing we compute improves on them.
2. **When it does not exist, say so — do not fabricate.** The fallback is
   either a clearly-typed synthetic geometry or, for hurricanes, an explicit
   "pending" marker carrying no ring at all.

Which tier produced a given ring is recorded on the ring itself, in
`parameters.methodology`. That string is returned by the API, so a consumer can
always tell an agency contour from a synthetic circle without asking us.

**Why this matters more than the constants:** a platform that silently
substitutes a made-up circle when the real data is late is worse than one with
a coarser model, because the consumer cannot tell the two apart. The zone
labels are identical either way.

---

## Zone semantics

Zones are consistent across perils, so a Zone 2 means the same severity class
whether the event is a hurricane or an earthquake.

| Zone | Meaning | Color |
|---|---|---|
| 1 | Catastrophic — severe structural damage expected, immediate danger to life | `#C0392B` |
| 2 | Severe — significant structural damage possible | `#E67E22` |
| 3 | Moderate — light to moderate damage | `#F1C40F` |
| 4 | Affected — effects felt, minor damage possible in vulnerable structures | varies by peril |

---

## Earthquake

**Source:** `lib/consequence-rings/earthquake.ts`

**Tier 1 — USGS ShakeMap.** For events with a USGS id and **M ≥ 4.0**, rings
are the actual ShakeMap MMI contour polygons: observed and modeled shaking,
calibrated to local geology, fault geometry, and station data. Where a contour
polygon is unavailable but radii are, a circle is built from the radius and the
methodology string records the downgrade.

Zone-to-intensity mapping:

| Zone | MMI threshold |
|---|---|
| 1 | VIII+ |
| 2 | VI–VII |
| 3 | IV–V |
| 4 | II–III |

**Tier 2 — ground-motion estimate (since 2026-10-21).** With no ShakeMap,
each zone's edge is where the median shaking predicted by a ground-motion
model falls to the zone's MMI (VIII, VI, IV, II), and a zone whose MMI is never
reached is not drawn. Median PGA is BSSA14 (Boore, Stewart, Seyhan & Atkinson
2014) on reference rock, Vs30 760 m/s, converted to MMI by Worden et al. (2012);
the distance is from the rupture, not the epicentre — half the Wells &
Coppersmith (1994) subsurface rupture length (log10 L = −2.44 + 0.59 M) is
taken off the epicentral distance — and a hypocentre deeper than 30 km adds
its excess depth. Nothing is drawn beyond 400 km, BSSA14's range. This is the
relation ShakeMap itself falls back on where it has no stations.

Checked before it replaced the old formula: against the 37 M4+ events of the
60 days to 2026-10-21 with ShakeMap rings, Zone 1 is drawn for none (ShakeMap
had none), Zone 3's radius is a median 0.91× ShakeMap's and Zone 4's 0.74×.
Against three historical ShakeMaps (Zone 1 / Zone 2, km, estimate vs ShakeMap):
Northridge M 6.7, 18 / 39 vs 26 / 65; Napa M 6.0, none / 23 vs none / 27;
Ridgecrest M 7.1, 31 / 56 vs 31 / 58. The 30 km depth offset was chosen from
three values on the 37 events — an in-sample fit, stated as such. Outer zones
of large events are smaller than ShakeMap's farthest contour reaches.

**What it replaced.** `z1_km = min(10 × 10^(0.5 × (M − 6.0)), 50)`, outer zones
×1, 2.5, 5, 10, labelled "USGS ShakeMap scaling formula" with no source. It drew
all four zones from M 2.5 up, so every earthquake had a "Catastrophic Impact
Zone" (MMI VIII+): 1.0 km at M 4.0, 5.6 km at M 5.5 — and ShakeMap had MMI
VIII+ for none of the 37 (an M 5.5 in Alaska on 2026-10-06 peaked at MMI 3.5).
Rings drawn by it before 2026-10-21 keep their stored values until ShakeMap
replaces them.

**Tier 2 is replaced by Tier 1 when ShakeMap arrives (since 2026-10-21).**
Rings are computed when the usgs worker first inserts an event, minutes after
the origin time, and ShakeMap is usually published later — so until
2026-10-21 most earthquakes kept Tier 2 forever: 295 of the 329 M5+ events of
the previous 60 days. Every run, the worker now retries ShakeMap for the M4+
earthquakes of the last 72 hours whose rings are not ShakeMap-calibrated (at
most 8 a run, strongest first; "not published" is cached an hour) and, when
it is published, replaces the rings and recomputes the housing exposure
(`lib/consequence-rings/recalibrate-earthquakes.ts`). Older events can be
backfilled with `scripts/recalibrate-earthquake-rings.ts --days 60` (dry run
by default; `--apply` writes). A dry run on 2026-10-21 found ShakeMap published
for 71 of 957 M4+ events of the previous 60 days still on Tier 2 (a lower
bound: USGS rate-limited part of the run).

Events below **M 2.5** produce no rings.

**Validation.** The module checks itself at load time against the 1994
Northridge M6.7 event (18.2 km deep): ShakeMap draws MMI VIII+ to 26 km, and
the estimate's Zone 1 (18 km) must land within 15–30 km. The assertion throws
outside that range, so a bad edit to the constants fails at import rather
than shipping.

---

## Hurricane

**Source:** `lib/consequence-rings/hurricane.ts`

**Tier 1 — NHC asymmetric wind radii.** Real hurricane wind fields are not
circular; NHC publishes quadrant radii per advisory, and the rings are built
asymmetrically from them (via ATCF, `lib/data-sources/atcf-radii.ts`).

**Tier 2 — no ring at all.** When an advisory carries no wind radii, the module
returns a single **`wind_pending`** marker:

- geometry is a **Point**, not a polygon — it renders no area
- labeled "Wind Field Geometry Pending"
- described as "NHC wind radii not yet available for this advisory"

This is deliberate. A Saffir-Simpson category-derived circle is a plausible
guess, and plausible guesses are exactly what a hurricane consequence ring must
not be. The category-based fallback exists in the module as
`computeFallbackCircularRingsAdmin`, is documented as QUARANTINED from public
surfaces, and **currently has no callers anywhere in the tree** — it produces
nothing in production.

**Downstream enforcement.** `trigger-eval` excludes both `wind_pending` and
`wind_fallback` ring types when evaluating ILS triggers:

```sql
.not("ring_type", "in", '("wind_pending","wind_fallback")')
```

So a parametric trigger can never fire against a non-authoritative geometry.
This is the property that matters most for the ILS product: a trigger is a
financial instrument, and it may only evaluate against agency data.

Storm surge is a separate ring layer (`ring_type = "surge"`), rendered
distinctly, and is not part of the wind field.

---

## Flood

**Source:** `lib/consequence-rings/flood.ts`

**Tier 1 — NWS alert polygon.** Authoritative. When present it is used as-is,
and **no synthetic outer rings are generated around it** — the methodology
string reads "NWS alert polygon (authoritative — no synthetic outer rings)".
Inventing concentric zones around a real flood polygon would imply a gradient
the source does not assert.

**Tier 2 — circular fallback.** Base radius by severity label:

| Severity label | Base km |
|---|---|
| MAJOR FLOOD | 8 |
| FLASH FLOOD | 5 |
| MODERATE FLOOD | 3 |
| MINOR FLOOD | 1.5 |
| FLOOD WATCH | 1 |

Unrecognized labels default to 5 km. Zone multipliers are **1, 2, 3, 5** —
deliberately tighter than earthquake or hurricane, because floods follow river
valleys rather than radiating from a point. The code's own note: a river flood
affects 2–5 km of floodplain, not a 50 km radius.

---

## Wildfire

**Source:** `lib/consequence-rings/wildfire.ts`

**Tier 1 — NIFC perimeter.** The official fire perimeter is Zone 1 when
available.

**Tier 2 — equivalent radius from acreage.** Treating the burn area as a
circle:

```
equivalent_radius_km = sqrt(acres × 0.00404686 / π) + buffer_km
```

(0.00404686 is km² per acre.) Zone buffers beyond the fire area:

| Zone | Label | Buffer km |
|---|---|---|
| 1 | Active Fire Perimeter | 0 |
| 2 | Evacuation Zone | 3 |
| 3 | Smoke & Ember Zone | 10 |
| 4 | Air Quality Impact Zone | 30 |

A 1 km minimum radius applies so very small fires still render. Worked example
from the module: 221,835 acres → sqrt(897.8 / π) ≈ **16.9 km** equivalent
radius.

The outer zones are explicitly *not* fire-spread predictions — they are smoke,
ember, and air-quality exposure bands, which is why Zone 4 extends 30 km.

---

## Severe weather

**Source:** `lib/consequence-rings/severe-weather.ts`

**Tier 1 — NOAA SPC polygon.** Watch and warning polygons are used directly.

**Tier 2 — circular buffer** from the SPC event centroid, stepped outward from
whichever zone the event's own severity establishes as primary:

| Steps from primary zone | Buffer km |
|---|---|
| ≤ 1 | 20 (inner) |
| 2 | 40 (middle) |
| 3+ | 80 (outer) |

The buffer is relative to the primary zone rather than absolute, so a tornado
warning and a marginal-risk outlook do not produce identically sized rings.

---

## Known gap: rings do not reference `model_parameters`

Every constant above is a hardcoded literal in TypeScript, cited in a code
comment. **None of the five ring modules reference a `model_parameters`
record** — verified by grep, zero matches across all five.

CLAUDE.md requires that "every calibration coefficient in every scoring
function must reference a `model_parameters` record." Ring calibration
currently does not meet that bar. This is the same class of gap as
**known-debt #75** (Hazus vulnerability tables), and the same remedy applies:
seed the constants into `model_parameters` with source citations, and enforce
equivalence with a drift test rather than converting the hot path to
per-event async lookups.

What *is* already true, and is most of what the requirement is protecting:

- every ring records its own methodology and source on the ring itself
- the API returns that provenance to the consumer
- the earthquake formula self-validates against a named historical event
- non-authoritative geometries are excluded from ILS trigger evaluation
- this document exists, and a drift test keeps it honest

What is missing is the queryable, versioned, `superseded_at` lineage — the
ability to ask the database "what was the Zone 2 multiplier on this date, and
who changed it?" That question currently has to be answered from git history.

---

## Where to look

| Peril | Module | Primary source |
|---|---|---|
| Earthquake | `lib/consequence-rings/earthquake.ts` | USGS ShakeMap |
| Hurricane | `lib/consequence-rings/hurricane.ts` | NOAA NHC / ATCF |
| Flood | `lib/consequence-rings/flood.ts` | NWS alert polygons |
| Wildfire | `lib/consequence-rings/wildfire.ts` | NIFC perimeters |
| Severe weather | `lib/consequence-rings/severe-weather.ts` | NOAA SPC |

Dispatch is `lib/consequence-rings/index.ts`. Ring types are `RingType` in
`lib/types.ts`.
