← back to Nationalrealestate
src/ingest/hud_fmr.ts
83 lines
/**
* HUD Fair Market Rents — FY2026 county-level, source 'hud_fmr'.
* File: huduser.gov/portal/datasets/fmr/fmr2026/FY26_FMRs_revised.xlsx (XLSX-ONLY — HUD stopped
* publishing a CSV variant after FY2024; parsed with the `xlsx` package, choice recorded in
* SOURCES.md). huduser.gov bot-challenges the default fetch UA (202 + html), so we send a
* browser UA. Period = FY2026 start = 2025-10-01.
* fips column is 10-digit: county FIPS(5) + '99999' for whole-county rows; New England rows are
* per-town (real sub-codes) — aggregated to county by pop2023-weighted mean.
* Metrics: fmr_0br .. fmr_4br.
*/
import XLSX from 'xlsx';
import { openRun, closeRun, download } from './run.ts';
import { loadRegionMaps, upsertMetricRows, type MetricRow } from './crosswalk.ts';
import { pool } from '../../db/pool.ts';
const FMR_URL = 'https://www.huduser.gov/portal/datasets/fmr/fmr2026/FY26_FMRs_revised.xlsx';
const FY_PERIOD = '2025-10-01'; // FY2026 starts Oct 1 2025
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
interface FmrRow {
fips: string | number | null;
pop2023: number | null;
fmr_0: number | null; fmr_1: number | null; fmr_2: number | null; fmr_3: number | null; fmr_4: number | null;
}
async function main() {
const runId = await openRun('hud_fmr', FMR_URL);
try {
const { path, sha256 } = await download(FMR_URL, 'FY26_FMRs_revised.xlsx', {
reuseCached: true, headers: { 'User-Agent': UA },
});
const wb = XLSX.readFile(path);
const rows = XLSX.utils.sheet_to_json<FmrRow>(wb.Sheets[wb.SheetNames[0]], { defval: null });
if (!rows.length || !('fips' in rows[0]) || !('fmr_0' in rows[0])) {
throw new Error('HUD FMR format drift: expected columns fips + fmr_0..fmr_4');
}
// aggregate per 5-digit county FIPS (pop-weighted across New England town rows)
const byCounty = new Map<string, { wsum: number[]; pop: number; n: number }>();
for (const r of rows) {
const fips10 = String(r.fips ?? '').padStart(10, '0');
const county = fips10.slice(0, 5);
if (!/^\d{5}$/.test(county)) continue;
const vals = [r.fmr_0, r.fmr_1, r.fmr_2, r.fmr_3, r.fmr_4].map(Number);
if (vals.some(v => !Number.isFinite(v))) continue;
const w = Math.max(Number(r.pop2023) || 0, 1);
let agg = byCounty.get(county);
if (!agg) { agg = { wsum: [0, 0, 0, 0, 0], pop: 0, n: 0 }; byCounty.set(county, agg); }
for (let i = 0; i < 5; i++) agg.wsum[i] += vals[i] * w;
agg.pop += w;
agg.n++;
}
const maps = await loadRegionMaps();
const metricRows: MetricRow[] = [];
let skipped = 0;
for (const [fips, agg] of byCounty) {
const region = maps.byFips.get(fips);
if (!region) { skipped++; continue; }
for (let i = 0; i < 5; i++) {
metricRows.push({
regionId: region.id, source: 'hud_fmr', metric: `fmr_${i}br`,
period: FY_PERIOD, value: Math.round(agg.wsum[i] / agg.pop),
});
}
}
const upserted = await upsertMetricRows(metricRows, runId);
if (upserted === 0) throw new Error('0 metric rows parsed — HUD FMR format drift');
await closeRun(runId, 'ok', {
upserted, skipped, fileHash: sha256,
notes: `FY2026 revised, period ${FY_PERIOD}; ${byCounty.size} counties (${rows.length} source rows incl. NE towns)`,
});
console.log(`hud_fmr: ${byCounty.size} 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); });