# Historical advisories — NHC's operational record, 1998 onward, as issued

**Since:** migration 228 (written 2026-10-31). **Table:** `historical_advisories`. **Parser:** `lib/historical/atcf-adeck.ts`. **Load:** `scripts/historical-ingest/run-nhc-adeck.ts` (one-time, manual; re-runnable). **Source:** NOAA NHC ATCF archive, `ftp.nhc.noaa.gov/atcf/archive/<year>/a<basin><nn><year>.dat.gz` (public domain, US Government work; the register's NOAA NHC entry).

## Why

The as-issued archive began on 2026-10-28, so the platform's own record of what NHC said *at the time* — the advisory position, intensity and wind radii, and the official forecast — reaches back days. The reference catalog's hurricane terms are written on the advisory as first reported, and the catalog's loss history (`reference_replays`, 223) is therefore read on the post-season best track instead and says so (`substituted`). NHC's own archive holds the operational record for every storm since 1998: the ATCF **a-deck**, whose `CARQ` lines carry the operational estimate of the current and recent positions each forecast was built on, and whose `OFCL` lines carry the official forecast at each lead time (tau 3 is the position the public advisory states), with wind radii per 34/50/64 kt threshold. Loaded, it gives:

- the catalog's terms a record to be settled on **as issued**, removing the substitution for every structure the record covers (next step: the replay reads `historical_advisories` where its terms name the advisory);
- every official forecast its error against what happened (the best track), by lead time, by basin and era — the hurricane side's analogue of the revision record, with the one difference the record states: advisories are successive forecasts, not revisions of one reading.

## What it is and is not

The **content** is NHC's operational record, unchanged. The **receipt** is not ours: `ingested_at` says when Calchis loaded a row, not when it had the advisory, and `backfilled = true` says so on every row. Rows here never enter the as-issued archive, its continuity figure or its lags; they are historical, like IBTrACS, and are read as such. Anything derived from them is labeled as read on the archive, not received.

## Shape

One row per (storm, synoptic time, tech, lead time): `atcf_id` (`AL142024`), `basin`, `cyclone_number`, `season`, `synoptic_time`, `tech` (`CARQ` | `OFCL`), `tau` (hours; CARQ carries 0 and negative taus for the recent past, OFCL 0…120), `valid_time`, `lat`, `lng`, `max_wind_kt`, `mslp_mb`, `dev_type` (TD, TS, HU, EX, …), `radii` (`{"34":[NE,SE,SW,NW], "50":[…], "64":[…]}` in nautical miles; only thresholds with a non-zero quadrant), `storm_name` (as the line carried it; `INVEST` is null), `source_url` (the a-deck file). A position of exactly 0°,0° is the ATCF "no position" and is not a row. Only CARQ and OFCL are kept: the other techs are model guidance, not what NHC said.

## Loading

```
node --env-file=.env.local --import tsx scripts/historical-ingest/run-nhc-adeck.ts            # 1998 through last year, AL+EP+CP
node --env-file=.env.local --import tsx scripts/historical-ingest/run-nhc-adeck.ts --years 2024 --basins AL
node --env-file=.env.local --import tsx scripts/historical-ingest/run-nhc-adeck.ts --storm AL142024 --years 2024
```

Roughly an hour for everything (about 1,500 files, 300 ms apart). Per the historical-layer rule, the script does not trust its own tally: after each storm it counts the rows the database holds for that storm against the records it parsed, lists every storm stored short, and exits 1 if any is.

## Verifying

```sql
SELECT season, basin, count(DISTINCT atcf_id) AS storms,
       count(*) FILTER (WHERE tech = 'CARQ' AND tau = 0) AS synoptic_fixes,
       count(*) FILTER (WHERE tech = 'OFCL') AS forecast_points
FROM historical_advisories GROUP BY 1, 2 ORDER BY 1, 2;

-- Milton 2024 at peak: CARQ tau 0, 2024-10-07 12Z, 155 kt / 897 mb
SELECT synoptic_time, lat, lng, max_wind_kt, mslp_mb, dev_type, radii
FROM historical_advisories WHERE atcf_id = 'AL142024' AND tech = 'CARQ' AND tau = 0 ORDER BY synoptic_time;
```

## API

Two public reads, no credential, cached an hour, both `backfilled: true` on every answer because the content is NHC's and the receipt is not ours:

- `GET /api/v1/storms?season=2024[&basin=AL]` — the season's storms as one line each (id, name, first and last synoptic time, fixes, operational peak wind, minimum pressure). 404 with `loaded: false` when the season has no rows: every season since 1998 had storms, so an empty one is a season the load has not reached.
- `GET /api/v1/storms/{atcf_id}/advisories` — one storm as issued: the operational track (CARQ tau 0 fixes with 34/50/64-kt radii by quadrant, nautical miles) and every official forecast grouped per advisory by lead time with its radii, plus the position the forecast was built on and whether it came from the fix or the forecast's own tau 0. 404 with `loaded: false` for a storm not loaded, never an empty storm. Reads are paged by row id (`lib/historical/advisories-read.ts`); a long-lived storm has more rows than PostgREST's 1,000.

First consumer: the hindcast page on Levee (the consumer product built on this API), which replays a storm's official forecasts against a list of addresses and until this endpoint carried its own copy of the a-deck.
