← back to Nationalrealestate
src/ingest/fema_nri.ts
115 lines
/**
* FEMA National Risk Index — county table, source 'fema_nri'.
* URL DRIFT (2026-07-21): the old hazards.fema.gov NRI site 301s to a generic FEMA page.
* Current home is OpenFEMA: fema.gov/about/reports-and-data/openfema/nri/v120/NRI_Table_Counties.zip
* (v1.20.0, December 2025 release — period stored as 2025-12-01). See SOURCES.md.
* Metrics: nri_risk_score, nri_eal_score, nri_sovi_score, nri_resl_score.
* Display strings (rating + top-3 hazards by <PFX>_RISKS score) go to nri_ratings.
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { openRun, closeRun, download, DOWNLOADS } from './run.ts';
import { loadRegionMaps, upsertMetricRows, type MetricRow } from './crosswalk.ts';
import { query, pool } from '../../db/pool.ts';
const NRI_URL = 'https://www.fema.gov/about/reports-and-data/openfema/nri/v120/NRI_Table_Counties.zip';
const RELEASE_PERIOD = '2025-12-01'; // NRI v1.20.0, December 2025 release
const HAZARDS: Record<string, string> = {
AVLN: 'Avalanche', CFLD: 'Coastal Flooding', CWAV: 'Cold Wave', DRGT: 'Drought',
ERQK: 'Earthquake', HAIL: 'Hail', HWAV: 'Heat Wave', HRCN: 'Hurricane',
ISTM: 'Ice Storm', IFLD: 'Inland Flooding', LNDS: 'Landslide', LTNG: 'Lightning',
SWND: 'Strong Wind', TRND: 'Tornado', TSUN: 'Tsunami', VLCN: 'Volcanic Activity',
WFIR: 'Wildfire', WNTW: 'Winter Weather',
};
function parseCsvLine(line: string): string[] {
const out: string[] = [];
let cur = '', inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQ) {
if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; }
else cur += ch;
} else if (ch === '"') inQ = true;
else if (ch === ',') { out.push(cur); cur = ''; }
else cur += ch;
}
out.push(cur);
return out;
}
async function main() {
const runId = await openRun('fema_nri', NRI_URL);
try {
const { path: zipPath, sha256 } = await download(NRI_URL, 'NRI_Table_Counties.zip', { reuseCached: true });
const extractDir = join(DOWNLOADS, 'nri_counties');
mkdirSync(extractDir, { recursive: true });
execFileSync('unzip', ['-o', '-q', zipPath, '-d', extractDir]);
const text = readFileSync(join(extractDir, 'NRI_Table_Counties.csv'), 'utf8');
const lines = text.split('\n');
const header = parseCsvLine(lines[0].replace(/^/, '').replace(/\r$/, ''));
const col = new Map(header.map((h, i) => [h, i]));
for (const c of ['STCOFIPS', 'RISK_SCORE', 'RISK_RATNG', 'EAL_SCORE', 'SOVI_SCORE', 'RESL_SCORE']) {
if (!col.has(c)) throw new Error(`NRI header drift: missing column ${c}`);
}
const hazardCols = Object.keys(HAZARDS)
.map(pfx => ({ pfx, idx: col.get(pfx + '_RISKS') }))
.filter((h): h is { pfx: string; idx: number } => h.idx != null);
const maps = await loadRegionMaps();
const metricRows: MetricRow[] = [];
const ratingRows: { fips: string; rating: string | null; top: { hazard: string; score: number }[] }[] = [];
let skipped = 0;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].replace(/\r$/, '');
if (!line) continue;
const f = parseCsvLine(line);
const fips = (f[col.get('STCOFIPS')!] || '').padStart(5, '0');
const region = maps.byFips.get(fips);
if (!region) { skipped++; continue; }
const metrics: [string, string][] = [
['nri_risk_score', 'RISK_SCORE'], ['nri_eal_score', 'EAL_SCORE'],
['nri_sovi_score', 'SOVI_SCORE'], ['nri_resl_score', 'RESL_SCORE'],
];
for (const [metric, colName] of metrics) {
const v = Number(f[col.get(colName)!]);
if (!Number.isFinite(v)) continue;
metricRows.push({ regionId: region.id, source: 'fema_nri', metric, period: RELEASE_PERIOD, value: v });
}
const top = hazardCols
.map(h => ({ hazard: HAZARDS[h.pfx], score: Number(f[h.idx]) }))
.filter(h => Number.isFinite(h.score))
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(h => ({ hazard: h.hazard, score: Math.round(h.score * 100) / 100 }));
ratingRows.push({ fips, rating: f[col.get('RISK_RATNG')!] || null, top });
}
const upserted = await upsertMetricRows(metricRows, runId);
for (const r of ratingRows) {
await query(
`INSERT INTO nri_ratings (county_fips, risk_rating, top_hazards) VALUES ($1, $2, $3)
ON CONFLICT (county_fips) DO UPDATE SET risk_rating = EXCLUDED.risk_rating, top_hazards = EXCLUDED.top_hazards`,
[r.fips, r.rating, JSON.stringify(r.top)],
);
}
if (upserted === 0) throw new Error('0 metric rows parsed — NRI format drift');
await closeRun(runId, 'ok', {
upserted, skipped, fileHash: sha256,
notes: `v1.20.0 Dec-2025 release, period ${RELEASE_PERIOD}; ${ratingRows.length} counties, ${upserted} metric rows`,
});
console.log(`fema_nri: ${ratingRows.length} counties, ${upserted} metric rows, ${skipped} unmatched`);
} catch (e: any) {
await closeRun(runId, 'failed', { notes: String(e.message || e) });
throw e;
} finally {
await pool.end();
}
}
main().catch((e) => { console.error(e); process.exit(1); });