// Analyses on hic_panel.csv. Usage: node analyze_hic.js -> prints markdown tables, writes hic_results.json const fs = require('fs'), path = require('path'); const { readCSV, num } = require('./csvlib'); const DIR = path.resolve(process.argv[2]); const rows = readCSV(path.join(DIR, 'hic_panel.csv')).filter(r => r.length > 1); const H = rows[0]; const numeric = new Set(H.slice(4).filter(h => h !== 'pit_count_type')); const D = rows.slice(1).map(r => Object.fromEntries(H.map((h, i) => [h, numeric.has(h) ? num(r[i]) : r[i]]))); const get = (c, y) => D.find(d => d.coc_code === c && d.year === y); const f = (v, d = 0) => v == null ? '–' : Number(v).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); const pct = v => v == null ? '–' : (100 * v).toFixed(1) + '%'; const results = {}; // 1. King County table console.log('## King County (WA-500)\n'); console.log('| Year | ES | TH | SH | RRH | PSH | OPH | ES seasonal | ES overflow | Temp (ES+TH+SH) | Perm (RRH+PSH+OPH) | PSH+OPH | PIT total | Sheltered | Unsheltered | Count | Temp beds / homeless | Sheltered / temp beds |'); console.log('|' + '---|'.repeat(18)); for (let y = 2012; y <= 2025; y++) { const d = get('WA-500', y); console.log(`| ${y} | ${f(d.es_beds)} | ${f(d.th_beds)} | ${f(d.sh_beds)} | ${f(d.rrh_beds)} | ${f(d.psh_beds)} | ${f(d.oph_beds)} | ${f(d.es_seasonal_beds)} | ${f(d.es_overflow_beds)} | ${f(d.temp_beds)} | ${f(d.perm_beds)} | ${f(d.psh_beds + (d.oph_beds || 0))} | ${f(d.pit_total)} | ${f(d.pit_sheltered)} | ${f(d.pit_unsheltered)} | ${/Only/i.test(d.pit_count_type) ? 'sheltered-only' : 'full'} | ${f(d.temp_beds_per_homeless, 2)} | ${f(d.pit_sheltered / d.temp_beds, 2)} |`); } results.king = D.filter(d => d.coc_code === 'WA-500'); // 2. OLS function ols(xs, ys) { const n = xs.length, mx = xs.reduce((a, b) => a + b) / n, my = ys.reduce((a, b) => a + b) / n; let sxx = 0, sxy = 0, syy = 0; for (let i = 0; i < n; i++) { sxx += (xs[i] - mx) ** 2; sxy += (xs[i] - mx) * (ys[i] - my); syy += (ys[i] - my) ** 2; } const b = sxy / sxx, a = my - b * mx; let sse = 0; for (let i = 0; i < n; i++) sse += (ys[i] - a - b * xs[i]) ** 2; return { n, a, b, se_b: Math.sqrt(sse / (n - 2) / sxx), r2: 1 - sse / syy, rmse: Math.sqrt(sse / n) }; } const NAMED = { 'WA-500': 'King County', 'NY-600': 'NYC', 'MA-500': 'Boston', 'TX-700': 'Houston', 'CA-601': 'San Diego', 'CO-503': 'Denver', 'TX-600': 'Dallas', 'CA-600': 'Los Angeles', 'CA-501': 'San Francisco', 'OR-501': 'Portland' }; results.fits = {}; for (const y of [2019, 2024]) { const all = D.filter(d => d.year === y && d.unsheltered_share != null && d.pit_total > 0 && d.population); console.log(`\n## ${y} cross-CoC (full-count CoCs, n=${all.length})\n`); const specs = [['temp_beds_per_homeless', 'Temp beds per homeless person'], ['temp_beds_per_10k', 'Temp beds per 10k residents']]; const samples = { all: all, excl_NYC: all.filter(d => d.coc_code !== 'NY-600'), major_city: all.filter(d => d.hud_category_2025 === 'Major City CoC') }; console.log('| Predictor | Sample | n | Slope (SE) | Intercept | R² |\n|---|---|---|---|---|---|'); for (const [x, lab] of specs) for (const [sn, s] of Object.entries(samples)) { const m = ols(s.map(d => d[x]), s.map(d => d.unsheltered_share)); results.fits[`${y}|${x}|${sn}`] = m; const scale = x === 'temp_beds_per_10k' ? 10 : 0.1; console.log(`| ${lab} | ${sn} | ${m.n} | ${(m.b * scale * 100).toFixed(1)} pts per ${scale} (${(m.se_b * scale * 100).toFixed(1)}) | ${(m.a * 100).toFixed(1)}% | ${m.r2.toFixed(2)} |`); } const m1 = results.fits[`${y}|temp_beds_per_homeless|all`], m2 = results.fits[`${y}|temp_beds_per_10k|all`]; const rk = (k, desc = true) => { const s = [...all].sort((a, b) => desc ? b[k] - a[k] : a[k] - b[k]); return c => s.findIndex(d => d.coc_code === c) + 1; }; const rTB = rk('temp_beds_per_homeless'), rT10 = rk('temp_beds_per_10k'), rU = rk('unsheltered_share'); console.log(`\n| CoC | PIT total | Temp beds | Temp beds / homeless (rank of ${all.length}) | Temp beds / 10k (rank) | Unsheltered share (rank, 1=highest) | Predicted share from beds/homeless | Residual (pts) |\n|---|---|---|---|---|---|---|---|`); for (const [c, nm] of Object.entries(NAMED)) { const d = get(c, y); if (d.unsheltered_share == null || !all.includes(d)) { console.log(`| ${nm} | ${f(d.pit_total)} | ${f(d.temp_beds)} | ${f(d.temp_beds_per_homeless, 2)} | ${f(d.temp_beds_per_10k, 1)} | sheltered-only count, not in fit | – | – |`); continue; } const p = m1.a + m1.b * d.temp_beds_per_homeless; console.log(`| ${nm} | ${f(d.pit_total)} | ${f(d.temp_beds)} | ${f(d.temp_beds_per_homeless, 2)} (${rTB(c)}) | ${f(d.temp_beds_per_10k, 1)} (${rT10(c)}) | ${pct(d.unsheltered_share)} (${rU(c)}) | ${pct(p)} | ${((d.unsheltered_share - p) * 100).toFixed(1)} |`); } const med = k => { const s = all.map(d => d[k]).sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; }; console.log(`\nMedians: temp beds/homeless ${f(med('temp_beds_per_homeless'), 2)}, temp beds/10k ${f(med('temp_beds_per_10k'), 1)}, unsheltered share ${pct(med('unsheltered_share'))}.`); } // 3. Permanent beds per 10k, 2015 vs 2024 console.log('\n## Permanent beds (RRH+PSH+OPH) per 10k residents, 2015 vs 2024\n'); const P = Object.values(NAMED).length; const per = [...new Set(D.map(d => d.coc_code))].map(c => { const a = get(c, 2015), b = get(c, 2024); return { c, nm: NAMED[c] || a.short_name, p15: a.perm_beds_per_10k, p24: b.perm_beds_per_10k, psh15: a.psh_beds / a.population * 1e4, psh24: b.psh_beds / b.population * 1e4, t15: a.temp_beds_per_10k, t24: b.temp_beds_per_10k }; }); const r24 = [...per].sort((x, y) => y.p24 - x.p24), r15 = [...per].sort((x, y) => y.p15 - x.p15), rch = [...per].sort((x, y) => (y.p24 - y.p15) - (x.p24 - x.p15)); console.log(`| CoC | Perm/10k 2015 (rank) | Perm/10k 2024 (rank) | Change | PSH only/10k 2015 → 2024 | Temp/10k 2015 → 2024 |\n|---|---|---|---|---|---|`); for (const c of Object.keys(NAMED)) { const p = per.find(x => x.c === c); console.log(`| ${p.nm} | ${f(p.p15, 1)} (${r15.indexOf(p) + 1}) | ${f(p.p24, 1)} (${r24.indexOf(p) + 1}) | ${(p.p24 - p.p15 >= 0 ? '+' : '') + f(p.p24 - p.p15, 1)} (${rch.indexOf(p) + 1}) | ${f(p.psh15, 1)} → ${f(p.psh24, 1)} | ${f(p.t15, 1)} → ${f(p.t24, 1)} |`); } const medp = k => { const s = per.map(d => d[k]).sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; }; console.log(`\nMedian of 67 CoCs: perm/10k 2015 ${f(medp('p15'), 1)}, 2024 ${f(medp('p24'), 1)}; temp/10k 2015 ${f(medp('t15'), 1)}, 2024 ${f(medp('t24'), 1)}.`); results.perm = per; fs.writeFileSync(path.join(DIR, 'hic_results.json'), JSON.stringify(results, null, 1));