← back to Nationalrealestate

src/ingest/census_acs.ts

117 lines

/**
 * Census ACS 5-year (2023 vintage) — one call for all counties.
 * Metrics -> metric_series source 'acs', period 2023-07-01 (mid-vintage anchor).
 * Also updates region.population from B01003.
 * Keyless was probed 2026-07-21 and REDIRECTS to missing_key.html — a key is required;
 * we read CENSUS_API_KEY from ./.env then ~/Projects/secrets-manager/.env.
 */
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { query, pool } from '../../db/pool.ts';
import { openRun, closeRun } from './run.ts';
import { loadRegionMaps, upsertMetricRows, UnmatchedCollector, type MetricRow } from './crosswalk.ts';

const ACS_PERIOD = '2023-07-01';
const SOURCE = 'acs';
const VARS = 'NAME,B19013_001E,B25077_001E,B25064_001E,B01003_001E,B25002_001E,B25002_003E,B25003_001E,B25003_003E';
const BASE = `https://api.census.gov/data/2023/acs/acs5?get=${VARS}&for=county:*`;

function findCensusKey(): string | null {
  if (process.env.CENSUS_API_KEY) return process.env.CENSUS_API_KEY;
  for (const p of [join(process.cwd(), '.env'), join(homedir(), 'Projects', 'secrets-manager', '.env')]) {
    if (!existsSync(p)) continue;
    const m = readFileSync(p, 'utf8').match(/^CENSUS_API_KEY=(.+)$/m);
    if (m) return m[1].trim().replace(/^["']|["']$/g, '');
  }
  return null;
}

/** Census sentinel negatives (-666666666 etc.) and null -> undefined. */
function num(v: string | null): number | undefined {
  if (v == null || v === '') return undefined;
  const n = Number(v);
  if (!Number.isFinite(n) || n < 0) return undefined;
  return n;
}

async function main() {
  const key = findCensusKey();
  const url = key ? `${BASE}&key=${key}` : BASE;
  const runId = await openRun(SOURCE, BASE);
  try {
    const res = await fetch(url, { redirect: 'follow' });
    if (!res.ok) throw new Error(`ACS fetch failed: ${res.status} ${res.statusText}`);
    if (res.url.includes('missing_key')) throw new Error('ACS redirected to missing_key.html — CENSUS_API_KEY required/invalid');
    const data: string[][] = await res.json();
    if (!Array.isArray(data) || data.length < 2) throw new Error(`ACS returned ${Array.isArray(data) ? data.length : 0} rows`);

    const header = data[0];
    const col = (name: string) => {
      const i = header.indexOf(name);
      if (i < 0) throw new Error(`ACS header missing ${name}: ${header.join(',')}`);
      return i;
    };
    const iIncome = col('B19013_001E'), iValue = col('B25077_001E'), iRent = col('B25064_001E'),
      iPop = col('B01003_001E'), iUnits = col('B25002_001E'), iVacant = col('B25002_003E'),
      iOccTot = col('B25003_001E'), iRenter = col('B25003_003E'),
      iState = col('state'), iCounty = col('county'), iName = col('NAME');

    const maps = await loadRegionMaps();
    const unmatched = new UnmatchedCollector();
    const rows: MetricRow[] = [];
    const popUpdates: { regionId: number; population: number }[] = [];
    let skipped = 0;

    for (const r of data.slice(1)) {
      const fips = r[iState].padStart(2, '0') + r[iCounty].padStart(3, '0');
      const region = maps.byFips.get(fips);
      if (!region) {
        unmatched.add(SOURCE, { fips, name: r[iName] });
        skipped++;
        continue;
      }
      const push = (metric: string, value: number | undefined) => {
        if (value !== undefined) rows.push({ regionId: region.id, source: SOURCE, metric, period: ACS_PERIOD, value });
      };
      push('median_hh_income', num(r[iIncome]));
      push('median_home_value_acs', num(r[iValue]));
      push('median_gross_rent', num(r[iRent]));
      const pop = num(r[iPop]);
      push('population', pop);
      if (pop !== undefined) popUpdates.push({ regionId: region.id, population: pop });
      const units = num(r[iUnits]), vacant = num(r[iVacant]);
      if (units !== undefined && units > 0 && vacant !== undefined) push('vacancy_rate', vacant / units);
      const occ = num(r[iOccTot]), renter = num(r[iRenter]);
      if (occ !== undefined && occ > 0 && renter !== undefined) push('renter_share', renter / occ);
    }

    if (rows.length === 0) throw new Error('ACS parse produced 0 metric rows');

    const upserted = await upsertMetricRows(rows, runId);
    for (let i = 0; i < popUpdates.length; i += 500) {
      const batch = popUpdates.slice(i, i + 500);
      const params: unknown[] = [];
      const values = batch.map((u, j) => { params.push(u.regionId, u.population); return `($${j * 2 + 1}::int,$${j * 2 + 2}::int)`; });
      await query(
        `UPDATE region rg SET population = v.pop FROM (VALUES ${values.join(',')}) AS v(id, pop) WHERE rg.id = v.id`,
        params,
      );
    }
    unmatched.flush();
    await closeRun(runId, 'ok', {
      upserted, skipped,
      notes: `counties=${data.length - 1} key=${key ? 'used' : 'keyless'} pop_updates=${popUpdates.length} unmatched=${unmatched.count(SOURCE)}`,
    });
    console.log(`[acs] ok: ${upserted} metric rows, ${popUpdates.length} region.population updates, ${skipped} skipped`);
  } catch (e) {
    await closeRun(runId, 'failed', { notes: String(e) });
    console.error('[acs] FAILED:', e);
    process.exitCode = 1;
  } finally {
    await pool.end();
  }
}

main();