// NYC enrollment, capacity and utilization — analysis packet. // // Writes data/analysis/nyc-enrollment-capacity.json, the committed audit trail // behind docs/design/10_nyc_enrollment_capacity_utilization.md and the page that // doc plans. Anything that displays these figures reads the packet; it does not // recompute them. // // npx tsx scripts/analysis/nyc-capacity-packet.ts regenerate // npx tsx scripts/analysis/nyc-capacity-packet.ts --check diff against the // committed file, // exit 1 on drift // npx tsx scripts/analysis/nyc-capacity-packet.ts --as-of=2026-09-09 override the stamp // npx tsx scripts/analysis/nyc-capacity-packet.ts --refresh re-download the // cached spreadsheet // // No database. This script reads six published files plus the committed parse of // the SCA's PDFs (data/cities/nyc/reference/parsed/), and runs with no // DATABASE_URL set. The Blue Book parsing lives in // scripts/loaders/_bluebook.ts and is reused here rather than restated — the // per-building roll-up in particular (doc §3.2: a school split across buildings // gets one row per building, so reading any single row misstates the school). // // Four conventions, each of which moves the numbers: // // 1. UNIVERSE. School-grain figures cover organizations whose org code resolves // to exactly one DBN in our school universe — the 2019-20 School Locations // directory plus every year of the DOE Demographic Snapshot across its three // publication vintages. That is 99.6-99.7% of organizations carrying an // enrollment. The six or seven dropped each year are named in // `universe.by_year[].dropped`, and `universe.sensitivity_all_orgs` repeats // the headline statistics without the filter: measured, it moves the share // under 50% by at most 0.1 pp and the median not at all. // // 2. QUANTILES. Percentiles use the exclusive definition that Python's // statistics.quantiles applies by default, which is what produced the // percentile table in doc §4.1. The inclusive definition used elsewhere in // this repo can differ from it after rounding at p90, so the two are not // interchangeable. // // 3. BUILDING GRAIN. A building's enrollment and capacity repeat on every // organization row it hosts, so the figures are deduped by building id. // `rollUpBuildings` keeps the FIRST row it sees for a building, and in // 2013-14 and 2016-17 that row is sometimes an annex-style row carrying no // capacity — 13 and 10 buildings. Rows with complete building figures are // therefore handed to it first. Where two rows for one building are both // complete they never disagree; `qa.building_row_consistency` checks that. // // 4. IRREGULAR PANEL. Twelve school years, 2013-14 to 2025-26, with one gap: // 2020-21, which the SCA never published. Seven come from Socrata; the // other five are read from the SCA's own PDFs (pipeline_nyc/parse/bluebook.py) // and reach this script through the same gate the loader checks, so the // packet and the warehouse see one panel. `meta.missing_years` carries the // gap, so a time-series figure breaks its line at 2020-21. // // Direction is NEUTRAL throughout. Utilization is how full a building is, not a // rating of the school inside it, and target capacity is the SCA's planning // construct rather than a count of how many children fit. import fs from "node:fs"; import path from "node:path"; import { fetchSocrataAll, fetchAndCache, readSheet } from "../loaders/_fetch"; import { readRecoveredYears } from "../loaders/_bluebook_recovered"; import { SNAPSHOT_YEAR, buildDbnIndex, groupBySnapshot, num, resolveDbn, rollUpBuildings, rollUpOrgs, schoolYearForSnapshot, utilizationAgreement, type BlueBookRow, type BuildingRecord, type OrgRecord, } from "../loaders/_bluebook"; const OUT = path.join(process.cwd(), "data", "analysis", "nyc-enrollment-capacity.json"); // ── sources ────────────────────────────────────────────────────────────────── const BLUEBOOK = "gkd7-3vk7"; // live: enrollment, target capacity, published utilization const BLUEBOOK_HIST_ORG = "q9xk-w9iv"; // retired 2015: the pre-2014 capacity formula const DIRECTORY = "wg9x-4ke6"; // 2019-20 School Locations — the DBN seed const DEMO_2013_2018 = "s52a-8aq6"; const DEMO_2018_2022 = "c7ru-d68s"; const DEMO_XLSX_URL = "https://infohub.nyced.org/docs/default-source/default-document-library/" + "demographic-snapshot-2021-22-to-2025-26-public.xlsx"; const socrataUrl = (id: string) => `https://data.cityofnewyork.us/resource/${id}.json`; const DECOMP_FROM = "2018-19"; const DECOMP_TO = "2025-26"; // The school years strictly between the two endpoints that the panel lacks. // Only 2020-21 is missing, because the SCA never published it (doc §2), so the // span can be run segment by segment as well as end to end. `segments` splits // each consecutive published pair, which is what lets the page say whether the // endpoint reading hides a reversal instead of merely noting that it might. const DECOMP_SPAN_UNPUBLISHED = ["2020-21"]; const DISTRICT_YEAR = "2025-26"; const HISTORICAL_YEAR = "2013-14"; // the only year the retired capacity series covers // ── flags ──────────────────────────────────────────────────────────────────── const argv = process.argv.slice(2); function flagValue(name: string): string | null { const prefix = `--${name}=`; const inline = argv.find((a) => a.startsWith(prefix)); if (inline) return inline.slice(prefix.length); const i = argv.indexOf(`--${name}`); if (i >= 0 && i + 1 < argv.length && !argv[i + 1].startsWith("--")) return argv[i + 1]; return null; } const CHECK = argv.includes("--check"); // --check re-downloads the cached spreadsheet: a stale cache would hide an // upstream restatement, which is the one thing --check exists to catch. const REFRESH = argv.includes("--refresh") || CHECK; const AS_OF_OVERRIDE = flagValue("as-of") ?? process.env.NYC_PACKET_AS_OF ?? null; // ── statistics ─────────────────────────────────────────────────────────────── /** * Round half to even, which is what the published tables in the doc use — * they came out of Python, whose formatting rounds ties to the even digit, * while JavaScript's `Math.round` rounds them up. * * The difference is confined to exact ties, and exact ties are ordinary here * because a share is a small ratio of counts: 3 of District 20's 48 schools is * exactly 6.25%, which reads 6.2 under one convention and 6.3 under the other. * Half-even is also the unbiased choice — half-up drags every tie in one * direction — so the packet uses it and the doc's numbers reproduce. */ function roundTo(x: number, digits: number): number { const f = 10 ** digits; const scaled = x * f; const low = Math.floor(scaled); const frac = scaled - low; const n = frac > 0.5 ? low + 1 : frac < 0.5 ? low : low % 2 === 0 ? low : low + 1; return n / f; } const r1 = (n: number) => roundTo(n, 1); const r3 = (n: number) => roundTo(n, 3); const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0); /** * Exclusive quantile — the default of Python's `statistics.quantiles`, and the * definition behind the published percentile table (doc §4.1). Position is * p × (n + 1) in 1-based terms, the bracketing index is clamped into [1, n-1], * and the value is interpolated linearly between the two order statistics. */ function quantileExclusive(sorted: readonly number[], p: number): number { const n = sorted.length; if (n === 0) return NaN; if (n === 1) return sorted[0]; const pos = p * (n + 1); const j = Math.min(Math.max(Math.floor(pos), 1), n - 1); const frac = pos - j; return sorted[j - 1] + frac * (sorted[j] - sorted[j - 1]); } type Distribution = { n: number; p10: number | null; p25: number | null; median: number | null; p75: number | null; p90: number | null; share_under_50: number | null; share_under_33: number | null; share_over_100: number | null; sum_over_sum: number | null; enrollment: number; capacity: number; }; /** * The distribution block every grain reports. `utilizations` are integer * percents as the source states them (published at building grain, summed then * divided at school grain); `enrollment`/`capacity` are the totals behind * `sum_over_sum`, which is a student-weighted figure and not the median. * * Thresholds are strict and one-sided, matching the doc: under 50, under 33.3 * (a third), over 100. */ function distribution(utilizations: number[], enrollment: number, capacity: number): Distribution { const s = [...utilizations].sort((a, b) => a - b); const n = s.length; const share = (pred: (x: number) => boolean) => (n ? r1((100 * s.filter(pred).length) / n) : null); return { n, p10: n ? r1(quantileExclusive(s, 0.1)) : null, p25: n ? r1(quantileExclusive(s, 0.25)) : null, median: n ? r1(quantileExclusive(s, 0.5)) : null, p75: n ? r1(quantileExclusive(s, 0.75)) : null, p90: n ? r1(quantileExclusive(s, 0.9)) : null, share_under_50: share((x) => x < 50), share_under_33: share((x) => x < 33.3), share_over_100: share((x) => x > 100), sum_over_sum: capacity > 0 ? r1((100 * enrollment) / capacity) : null, enrollment, capacity, }; } // ── shapes the packet is assembled from ────────────────────────────────────── /** A building with complete figures: enrollment, capacity and a published utilization. */ type CompleteBuilding = { id: string; enrollment: number; capacity: number; utilization: number }; /** * Buildings with complete figures, from `rollUpBuildings`. * * `rollUpBuildings` keeps the first row it encounters for a building id, so the * rows are ordered complete-first: otherwise an annex-style row carrying no * capacity wins the id and the building drops out of the grain entirely (13 * buildings in 2013-14, 10 in 2016-17 — measured, see the header). */ function completeBuildings(rows: readonly BlueBookRow[]): { records: Map; complete: CompleteBuilding[]; } { const isComplete = (r: BlueBookRow) => { const e = num(r.bldg_enroll); const c = num(r.target_bldg_cap); return e !== null && e > 0 && c !== null && c > 0; }; const ordered = [...rows.filter(isComplete), ...rows.filter((r) => !isComplete(r))]; const records = rollUpBuildings(ordered); const complete: CompleteBuilding[] = []; for (const b of records.values()) { if (b.enrollment === null || b.enrollment <= 0) continue; if (b.targetCapacity === null || b.targetCapacity <= 0) continue; if (b.utilizationPct === null) continue; complete.push({ id: b.buildingId, enrollment: b.enrollment, capacity: b.targetCapacity, utilization: b.utilizationPct, }); } complete.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); return { records, complete }; } /** * Buildings hosting more than one organization with students in them. * * Counted from the raw rows rather than `BuildingRecord.orgCodes`, which admits * an organization whose enrollment in the building is zero. The two definitions * differ by 0.5-0.6 pp; `qa.multi_org_definition` carries both so the choice is * visible rather than implied. */ function multiOrgBuildings(rows: readonly BlueBookRow[], ids: ReadonlySet): number { const occupants = new Map>(); for (const r of rows) { const b = (r.bldg_id ?? "").trim(); const org = (r.org_id ?? "").trim().toUpperCase(); const e = num(r.org_enroll); if (!b || !org || e === null || e <= 0 || !ids.has(b)) continue; const bucket = occupants.get(b); if (bucket) bucket.add(org); else occupants.set(b, new Set([org])); } let multi = 0; for (const set of occupants.values()) if (set.size > 1) multi += 1; return multi; } /** * Does this organization describe how full a building is? * * Students and seats are both required, and the students strictly. `rollUpOrgs` * reports an organization that reported no enrollment against a real capacity as * 0% utilized, which is an organization that did not report rather than an empty * school; admitting them adds 20 schools at 0% to 2013-14 and moves the share * under 50% by a point. */ function hasUtilization(o: OrgRecord | undefined): boolean { return ( !!o && o.enrollment !== null && o.enrollment > 0 && o.targetCapacity !== null && o.targetCapacity > 0 && o.utilizationPct !== null ); } /** Organizations carrying students and seats — the school-grain population before the DBN filter. */ function orgsWithUtilization(orgs: Iterable): OrgRecord[] { return [...orgs].filter(hasUtilization); } function schoolDistribution(orgs: readonly OrgRecord[]): Distribution { return distribution( orgs.map((o) => o.utilizationPct as number), sum(orgs.map((o) => o.enrollment as number)), sum(orgs.map((o) => o.targetCapacity as number)), ); } // ── the DBN universe the school grain is filtered to ───────────────────────── type DemoSocrataRow = { dbn?: string; year?: string; total_enrollment?: string }; type DirectoryRow = { system_code?: string }; type DemoXlsxRow = { DBN?: unknown; Year?: unknown; "Total Enrollment"?: unknown }; async function loadDbnUniverse(): Promise<{ dbns: Set; parts: { source: string; dbns: number }[]; }> { const parts: { source: string; dbns: number }[] = []; const dbns = new Set(); const add = (raw: unknown) => { const d = String(raw ?? "").trim().toUpperCase(); if (d.length >= 3) dbns.add(d); }; const directory = await fetchSocrataAll(DIRECTORY); const before0 = dbns.size; for (const r of directory) if (r.system_code) add(r.system_code); parts.push({ source: `socrata:${DIRECTORY}`, dbns: dbns.size - before0 }); for (const id of [DEMO_2013_2018, DEMO_2018_2022]) { const rows = await fetchSocrataAll(id); const before = dbns.size; // An enrollment is required, matching the probe: a row without one is not // evidence that the school reported in that year. for (const r of rows) if (r.dbn && r.year && num(r.total_enrollment) !== null) add(r.dbn); parts.push({ source: `socrata:${id}`, dbns: dbns.size - before }); } const xlsxPath = await fetchAndCache(DEMO_XLSX_URL, REFRESH); const sheet = readSheet(xlsxPath, "School"); const before = dbns.size; for (const r of sheet) { if (r.DBN && r.Year && num(r["Total Enrollment"]) !== null) add(r.DBN); } parts.push({ source: "infohub:demographic-snapshot-2021-22-to-2025-26", dbns: dbns.size - before }); if (dbns.size < 2000) { throw new Error( `DBN universe came back with only ${dbns.size} schools, which is far short of the ~2,236 ` + `measured in doc §3.1. One of the four inputs returned less than it should — regenerating ` + `on a short universe would silently restate every school-grain figure in this packet.`, ); } return { dbns, parts }; } // ── diffing, for --check ───────────────────────────────────────────────────── const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); function diffPaths(committed: unknown, fresh: unknown, at = "$", out: string[] = []): string[] { if (out.length >= 40) return out; if (isRecord(committed) && isRecord(fresh)) { for (const k of new Set([...Object.keys(committed), ...Object.keys(fresh)])) { if (!(k in committed)) out.push(`${at}.${k}: absent from the committed file`); else if (!(k in fresh)) out.push(`${at}.${k}: no longer produced`); else diffPaths(committed[k], fresh[k], `${at}.${k}`, out); } return out; } if (Array.isArray(committed) && Array.isArray(fresh)) { if (committed.length !== fresh.length) { out.push(`${at}: length ${committed.length} → ${fresh.length}`); return out; } for (let i = 0; i < committed.length; i++) diffPaths(committed[i], fresh[i], `${at}[${i}]`, out); return out; } if (committed !== fresh) { out.push(`${at}: committed ${JSON.stringify(committed)} → regenerated ${JSON.stringify(fresh)}`); } return out; } // ── build ──────────────────────────────────────────────────────────────────── async function build() { // Socrata carries seven years; the other five come from the committed parse // of the SCA's own PDFs, through the same gate the loader checks. Both must // see one panel — a year in the warehouse and not in the packet is a figure // the page and the database disagree about. const recovered = readRecoveredYears(); const socrataRows = await fetchSocrataAll(BLUEBOOK); const bb = [...socrataRows, ...recovered.flatMap((r) => r.rows)]; const hist = await fetchSocrataAll>(BLUEBOOK_HIST_ORG); const { dbns, parts } = await loadDbnUniverse(); const dbnIndex = buildDbnIndex(dbns); const bySnapshot = groupBySnapshot(bb); // schoolYearForSnapshot throws on an unmapped vintage rather than guessing // from the posting date; the lag runs three to eighteen months (doc §2). const snapshots = [...bySnapshot.keys()].sort(); const yearOf = new Map(snapshots.map((s) => [s, schoolYearForSnapshot(s)])); const years = [...yearOf.values()].sort(); const snapshotOf = new Map([...yearOf].map(([s, y]) => [y, s])); const maxDataAsOf = bb .map((r) => (r.data_as_of ?? "").trim()) .filter(Boolean) .sort() .at(-1); const generated_at = AS_OF_OVERRIDE ?? maxDataAsOf ?? null; // ── by_year ─────────────────────────────────────────────────────────────── const by_year: Record = {}; const universe_by_year: Record = {}; const sensitivity_all_orgs: Record = {}; const utilization_agreement: Record = {}; const matchedByYear = new Map(); const completeByYear = new Map(); let inconsistentBuildingRows = 0; const multiOrgAlternative: Record = {}; for (const year of years) { const snapshot = snapshotOf.get(year) as string; const rows = bySnapshot.get(snapshot) as BlueBookRow[]; const orgs = rollUpOrgs(rows); // School grain. Filter to organizations whose org code resolves to exactly // one DBN; an ambiguous code is dropped rather than assigned (doc §3.1). const withUtil = orgsWithUtilization(orgs.values()); const matched: OrgRecord[] = []; const dropped: { org_code: string; name: string | null; reason: string }[] = []; for (const o of withUtil) { const res = resolveDbn(o.orgCode, dbnIndex); if (res.kind === "resolved") matched.push(o); else dropped.push({ org_code: o.orgCode, name: o.name, reason: res.kind }); } dropped.sort((a, b) => (a.org_code < b.org_code ? -1 : 1)); matchedByYear.set(year, matched); // Building grain. const { records, complete } = completeBuildings(rows); completeByYear.set(year, complete); const ids = new Set(complete.map((b) => b.id)); const multi = multiOrgBuildings(rows, ids); multiOrgAlternative[year] = [...records.values()].filter( (b) => ids.has(b.buildingId) && b.orgCodes.length > 1, ).length; // Do two complete rows for the same building ever disagree? (They do not.) const seen = new Map(); for (const r of rows) { const id = (r.bldg_id ?? "").trim(); const e = num(r.bldg_enroll); const c = num(r.target_bldg_cap); const u = num(r.target_bldg_util); if (!id || e === null || e <= 0 || c === null || c <= 0) continue; const key = `${e}|${c}|${u}`; const prior = seen.get(id); if (prior === undefined) seen.set(id, key); else if (prior !== key) inconsistentBuildingRows += 1; } by_year[year] = { snapshot, school: schoolDistribution(matched), building: { ...distribution( complete.map((b) => b.utilization), sum(complete.map((b) => b.enrollment)), sum(complete.map((b) => b.capacity)), ), multi_org_buildings: multi, multi_org_share: complete.length ? r1((100 * multi) / complete.length) : null, }, }; const orgsTotal = orgs.size; universe_by_year[year] = { organizations: orgsTotal, with_enrollment_and_capacity: withUtil.length, matched: matched.length, match_rate: withUtil.length ? r1((100 * matched.length) / withUtil.length) : null, dropped, }; sensitivity_all_orgs[year] = schoolDistribution(withUtil); utilization_agreement[year] = utilizationAgreement(orgs.values()); } // ── decomposition ───────────────────────────────────────────────────────── // Continuing buildings only, so seats added in NEW buildings sit outside it. // Additive split of the change in Σenrollment / Σcapacity: the enrollment // effect holds capacity at the first year, the capacity effect is the // remainder. Building grain, because a building is what gets emptied or // closed; a school moving between buildings is not a change in seats. // One span, run on the buildings complete in BOTH of its endpoint years. The // continuing set is a function of the pair, so a sub-span is not a restriction // of the whole-span set and the segments below do not have to sum to it. function splitSpan(from: string, to: string) { const a = new Map((completeByYear.get(from) ?? []).map((b) => [b.id, b])); const z = new Map((completeByYear.get(to) ?? []).map((b) => [b.id, b])); const ids = [...a.keys()].filter((id) => z.has(id)).sort(); const e0 = sum(ids.map((id) => (a.get(id) as CompleteBuilding).enrollment)); const e1 = sum(ids.map((id) => (z.get(id) as CompleteBuilding).enrollment)); const c0 = sum(ids.map((id) => (a.get(id) as CompleteBuilding).capacity)); const c1 = sum(ids.map((id) => (z.get(id) as CompleteBuilding).capacity)); const u0 = (100 * e0) / c0; const u1 = (100 * e1) / c1; const counterfactual = (100 * e1) / c0; // later students in earlier seats return { from, to, ids, fromSize: a.size, toSize: z.size, e0, e1, c0, c1, u0, u1, counterfactual, }; } const whole = splitSpan(DECOMP_FROM, DECOMP_TO); const { ids: continuing, e0, e1, c0, c1, u0, u1, counterfactual } = whole; const fromB = { size: whole.fromSize }; const toB = { size: whole.toSize }; // The same split on every consecutive published pair across the span, so the // endpoint reading can be checked rather than trusted. const spanYears = years.filter((y) => y >= DECOMP_FROM && y <= DECOMP_TO); const segments = spanYears .slice(0, -1) .map((y, i) => splitSpan(y, spanYears[i + 1])) .map((g) => ({ from: g.from, to: g.to, continuing_buildings: g.ids.length, utilization_from_pct: r1(g.u0), utilization_to_pct: r1(g.u1), utilization_change_pp: r1(g.u1 - g.u0), enrollment_effect_pp: r1(g.counterfactual - g.u0), capacity_effect_pp: r1(g.u1 - g.counterfactual), enrollment_change_pct: r1(100 * (g.e1 / g.e0 - 1)), capacity_change_pct: r1(100 * (g.c1 / g.c0 - 1)), })); const decomposition = { from: DECOMP_FROM, to: DECOMP_TO, grain: "building", continuing_buildings: continuing.length, buildings_from: fromB.size, buildings_to: toB.size, enrollment_from: e0, enrollment_to: e1, enrollment_change_pct: r1(100 * (e1 / e0 - 1)), capacity_from: c0, capacity_to: c1, capacity_change_pct: r1(100 * (c1 / c0 - 1)), utilization_from_pct: r1(u0), utilization_to_pct: r1(u1), utilization_change_pp: r1(u1 - u0), enrollment_effect_pp: r1(counterfactual - u0), capacity_effect_pp: r1(u1 - counterfactual), span_years: 7, span_published_between: spanYears.slice(1, -1), span_unpublished: DECOMP_SPAN_UNPUBLISHED, span_note: `${DECOMP_FROM} and ${DECOMP_TO} are 7 school years apart and the panel carries ` + `${spanYears.length - 2} of the 6 years in between — every one except ` + `${DECOMP_SPAN_UNPUBLISHED.join(", ")}, which the SCA never published. Four of those ` + "intermediate years are not on NYC Open Data and are read from the SCA's own PDFs " + "(pipeline_nyc/parse/bluebook.py). `segments` is the path between the endpoints, so the " + "endpoint figures can be read against it.", segments, segments_note: "Each segment is computed on the buildings complete in both of ITS endpoint years, so the " + "continuing sets differ from segment to segment and the segment effects are not required to " + "sum to the whole-span effects. The 2019-20 → 2021-22 segment spans the unpublished " + "2020-21 and is two years wide; every other segment is one.", note: "Capacity fell over the period, which raised utilization; the decline is enrollment. " + "Continuing buildings only, so seats in buildings opened after 2018-19 are outside this.", rounding_note: "The two effects are additive before rounding (unrounded: -14.240 and +2.485 against a total " + "of -11.755). To one decimal they read -14.2 and +2.5, which sum to -11.7 against a printed " + "total of -11.8; the 0.1 pp is display rounding, not a residual term.", }; // ── by_district ─────────────────────────────────────────────────────────── // geo_dist is the GEOGRAPHIC district of the building, which is not the // administrative district for D75, D79 or charters (doc §3.1). These are // therefore districts of place, not of governance. const districtOrgs = new Map(); let unassigned = 0; for (const o of matchedByYear.get(DISTRICT_YEAR) ?? []) { if (o.geoDistrict === null) { unassigned += 1; continue; } const key = String(o.geoDistrict); const bucket = districtOrgs.get(key); if (bucket) bucket.push(o); else districtOrgs.set(key, [o]); } const districts: Record = {}; for (const key of [...districtOrgs.keys()].sort((a, b) => Number(a) - Number(b))) { const os = districtOrgs.get(key) as OrgRecord[]; const e = sum(os.map((o) => o.enrollment as number)); const c = sum(os.map((o) => o.targetCapacity as number)); districts[key] = { schools: os.length, utilization_pct: r1((100 * e) / c), share_under_50: r1((100 * os.filter((o) => (o.utilizationPct as number) < 50).length) / os.length), }; } const by_district = { year: DISTRICT_YEAR, grain: "school", statistic: "Σenrollment / Σcapacity within the district, and the share of its schools under 50%", schools_without_a_district: unassigned, districts, ranked_by_utilization: Object.keys(districts).sort( (a, b) => districts[a].utilization_pct - districts[b].utilization_pct, ), }; // ── two capacity definitions ────────────────────────────────────────────── // The retired series is the pre-2014 formula on the same schools and year, so // the comparison isolates the denominator. Its columns are named differently; // they are mapped onto the live shape so the same roll-up applies. const histRows: BlueBookRow[] = hist.map((r) => ({ org_id: r.org_id, organization_name: r.organization_name, bldg_id: r.bldg_id, geo_dist: r.geo_dist, org_enroll: r.enroll, org_target_cap: r.historical_capacity, org_target_util: r.historical_utilization, })); const historical = rollUpOrgs(histRows); const target = rollUpOrgs(bySnapshot.get(snapshotOf.get(HISTORICAL_YEAR) as string) as BlueBookRow[]); const bothBases = [...target.keys()] .filter((code) => { const h = historical.get(code); const t = target.get(code); return !!h && !!t && h.targetCapacity !== null && t.targetCapacity !== null; }) .sort(); const ratios = bothBases.map( (code) => (target.get(code) as OrgRecord).targetCapacity! / (historical.get(code) as OrgRecord).targetCapacity!, ); // The ratio needs a capacity on both bases; the utilization comparison needs // students as well, on both bases, for the reason in `hasUtilization`. const bothUtil = bothBases.filter( (code) => hasUtilization(target.get(code)) && hasUtilization(historical.get(code)), ); const gaps = bothUtil.map( (code) => (target.get(code) as OrgRecord).utilizationPct! - (historical.get(code) as OrgRecord).utilizationPct!, ); const sortedRatios = [...ratios].sort((a, b) => a - b); const sortedGaps = [...gaps].sort((a, b) => a - b); const capacity_definitions = { year: HISTORICAL_YEAR, schools_on_both_bases: bothBases.length, schools_with_both_utilizations: bothUtil.length, // Each statistic carries its OWN n. The two universes differ — a school can // have a capacity on both bases but a utilization on only one (it needs an // enrollment too). A bare pair of counts beside four unlabelled statistics // makes it easy to attach a statistic to the wrong universe, so the label // travels with the number instead. ratio_target_over_historical: { n: bothBases.length, median: r3(quantileExclusive(sortedRatios, 0.5)), p10: r3(quantileExclusive(sortedRatios, 0.1)), p90: r3(quantileExclusive(sortedRatios, 0.9)), }, utilization_gap_pp: { n: bothUtil.length, median: r1(quantileExclusive(sortedGaps, 0.5)), p10: r1(quantileExclusive(sortedGaps, 0.1)), p90: r1(quantileExclusive(sortedGaps, 0.9)), }, share_under_50_n: bothUtil.length, share_under_50_target: r1( (100 * bothUtil.filter((c) => (target.get(c) as OrgRecord).utilizationPct! < 50).length) / bothUtil.length, ), share_under_50_historical: r1( (100 * bothUtil.filter((c) => (historical.get(c) as OrgRecord).utilizationPct! < 50).length) / bothUtil.length, ), note: "Same schools, same year, two capacity formulas. The capacity RATIO is over the " + "schools carrying both capacity figures; the utilization gap and both under-50% " + "shares are over the smaller set that also carries a utilization on each basis, " + "which needs an enrollment as well. A figure naming no denominator is therefore " + "ambiguous.", }; // ── qa ──────────────────────────────────────────────────────────────────── // Check (iv) of the validation spec: does the published utilization equal the // reconstruction. For a single-building organization the comparison is // unambiguous; a split-site organization publishes a figure per building row, // so "matches any of them" is the weaker test and is reported apart. const singleBuildingWorst = Math.min( ...years.map((y) => { const t = utilization_agreement[y] as ReturnType; return t.singleBuilding.n ? (100 * t.singleBuilding.within1pp) / t.singleBuilding.n : 0; }), ); const worstMatchRate = Math.min( ...years.map((y) => (universe_by_year[y] as { match_rate: number }).match_rate), ); // Additivity is a property of the unrounded effects. Checking the rounded // fields instead would report the 0.1 pp of display rounding as a defect. const additivity = Math.abs((counterfactual - u0) + (u1 - counterfactual) - (u1 - u0)); const checks = [ { check: "iv_published_utilization_reconstructed", pass: singleBuildingWorst >= 100, detail: `single-building organizations within 1 pp of the published figure, worst year ${r1(singleBuildingWorst)}%`, }, { check: "panel_years_mapped", pass: years.length === Object.keys(SNAPSHOT_YEAR).length && snapshots.length === Object.keys(SNAPSHOT_YEAR).length, detail: `${snapshots.length} snapshots, ${years.length} school years; an unmapped vintage throws rather than guessing`, }, { check: "join_match_rate", pass: worstMatchRate >= 99.5, detail: `org code → DBN, worst year ${r1(worstMatchRate)}% of organizations carrying students and seats`, }, { check: "building_row_consistency", pass: inconsistentBuildingRows === 0, detail: `${inconsistentBuildingRows} buildings where two complete rows disagree on enrollment, capacity or utilization`, }, { check: "decomposition_additive", pass: additivity <= 1e-9, detail: `unrounded enrollment and capacity effects sum to the total change within ${additivity.toExponential(1)} pp`, }, ]; const qa = { verdict: checks.every((c) => c.pass) ? "PASS" : "FAIL", checks, utilization_agreement, multi_org_definition: { reported: "organizations with more than 0 students in the building", alternative: "organizations with a non-null enrollment in the building, zero included", alternative_counts: multiOrgAlternative, }, not_verified: [ "No second publisher states NYC school capacity; doc §5.2 tests it against the room inventory, which is the same publisher.", "2020-21 cannot be checked because the SCA never published it (doc §2).", "The five years read from the SCA PDFs are checked against Socrata only by proxy: run on the 2025-26 PDF, the parser reproduces the published 2025-26 extract on every row key and every organization-grain enrollment, target capacity and utilization (doc §2.1). The report prints no zero in the PK-capacity and cluster-room columns, so on about 2% of rows those read absent where Socrata reads 0.", "There is no public closure list for NYC, so nothing here supports a predictive claim (doc §6).", ], }; // ── packet ──────────────────────────────────────────────────────────────── const packet = { meta: { generated_at, generated_at_basis: AS_OF_OVERRIDE ? "--as-of / NYC_PACKET_AS_OF override" : `max data_as_of in ${BLUEBOOK}; a wall clock would churn the committed diff`, script: "scripts/analysis/nyc-capacity-packet.ts", years, missing_years: ["2020-21"], recovered_years: recovered.map((r) => ({ year: r.schoolYear, snapshot: r.snapshot, rows: r.rows.length, source: "SCA PDF, parsed by pipeline_nyc/parse/bluebook.py under a two-parser gate", })), panel_note: "Twelve school years, 2013-14 to 2025-26, with one gap: 2020-21, which the SCA never " + "published. Seven come from NYC Open Data; the other five are parsed from the SCA's own " + "PDFs and carry the same check (iv). A time-series figure plots twelve points and breaks " + "the line at 2020-21.", snapshot_year: Object.fromEntries(snapshots.map((s) => [s, yearOf.get(s) as string])), snapshot_year_note: "data_as_of identifies the vintage and is not a school year: for the seven Socrata vintages it " + "is a posting date, three to eighteen months after the count, and for the five PDF years it is " + "the audited-register date the report states. The school year is measured against the DOE " + "Demographic Snapshot (doc §2), not read off the date.", universe_note: "School grain covers organizations whose org code resolves to exactly one DBN in the 2019-20 " + "School Locations directory plus every year of the DOE Demographic Snapshot. Building grain " + "covers every building with an enrollment, a target capacity and a published utilization.", capacity_note: "Target capacity is the SCA's planning construct, not a fire-code occupancy limit. It counts " + "Pre-K rooms, each at up to 18 seats full-day or 36 half-day (capped by the room's area ÷ 35 " + "sq ft), and enrollment includes 3-K and Pre-K students, so Pre-K is on both sides of the " + "ratio; the Blue Book's separate PK capacity column restates the Pre-K share and is not " + "added to it. Charters in private space are absent from the Blue Book, so any citywide " + "figure is a DOE-buildings figure.", direction_note: "Every measure here is NEUTRAL. Utilization describes how full a building is; it does not rank " + "the school in it.", quantile_method: "exclusive (the default of Python's statistics.quantiles), which is what produced the " + "percentile table in doc §4.1", sources: [ { id: BLUEBOOK, url: socrataUrl(BLUEBOOK), name: "Enrollment Capacity And Utilization Reports (SCA Blue Book)", rows: socrataRows.length, role: "enrollment, target capacity and published utilization at both grains, seven school years", }, { id: "sca_bluebook_pdf", url: "https://www.nycsca.org/Community/Capital-Plan-Reports-Data", name: "SCA Enrollment, Capacity & Utilization Report (the annual PDF)", rows: recovered.reduce((n, r) => n + r.rows.length, 0), role: "the five school years Socrata does not carry — " + `${recovered.map((r) => r.schoolYear).join(", ")}. Parsed by ` + "pipeline_nyc/parse/bluebook.py under a two-parser gate and committed to " + "data/cities/nyc/reference/parsed/", }, { id: BLUEBOOK_HIST_ORG, url: socrataUrl(BLUEBOOK_HIST_ORG), name: "Enrollment Capacity And Utilization Reports - Historical by Organization", rows: hist.length, role: "the pre-2014 capacity formula, 2013-14 only — the second denominator", }, ...parts.map((p) => ({ id: p.source, url: p.source.startsWith("socrata:") ? socrataUrl(p.source.slice("socrata:".length)) : DEMO_XLSX_URL, name: p.source === `socrata:${DIRECTORY}` ? "2019-20 School Locations" : "Demographic Snapshot (school level)", dbns_contributed: p.dbns, role: "DBN universe the org-code join resolves against", })), ], source_dependency_note: "The InfoHub spreadsheet is the only input without a Socrata copy, and its filename carries " + "its year range, so it rolls annually. When it 404s this script fails rather than rebuilding a " + "shorter universe, because a shorter universe restates every school-grain figure quietly.", qa_verdict: qa.verdict, }, universe: { dbns: dbns.size, org_codes: dbnIndex.size, ambiguous_org_codes: [...dbnIndex.entries()].filter(([, v]) => v.length > 1).map(([k]) => k).sort(), by_year: universe_by_year, sensitivity_all_orgs, sensitivity_note: "sensitivity_all_orgs drops the DBN filter and keeps every organization with students and " + "seats. It is the same population plus the six or seven unmatched organizations a year, and " + "it is reported so the filter's effect is measurable rather than assumed.", }, by_year, decomposition, by_district, capacity_definitions, qa, }; return packet; } // ── main ───────────────────────────────────────────────────────────────────── async function main() { const packet = await build(); const serialized = JSON.stringify(packet, null, 1); if (CHECK) { if (!fs.existsSync(OUT)) { console.error(`[check] ${OUT} does not exist; run the script without --check first.`); process.exit(1); } const committed = JSON.parse(fs.readFileSync(OUT, "utf-8")) as unknown; const drift = diffPaths(committed, JSON.parse(serialized) as unknown); if (drift.length === 0) { console.log(`[check] ${path.relative(process.cwd(), OUT)} matches the live sources.`); return; } console.error(`[check] ${drift.length} difference(s) between the committed packet and the live sources:`); for (const d of drift) console.error(` ${d}`); console.error( "\nThe upstream data has been restated, or a convention in this script has changed. Re-read " + "docs/design/10_nyc_enrollment_capacity_utilization.md before regenerating: the doc quotes " + "these numbers and would need the same revision.", ); process.exit(1); } fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, serialized); console.log(`[packet] wrote ${path.relative(process.cwd(), OUT)} (${(serialized.length / 1024).toFixed(0)} KB)`); console.log(` as of ${packet.meta.generated_at} — ${packet.meta.generated_at_basis}`); console.log(` QA ${packet.qa.verdict}: ${packet.qa.checks.filter((c) => c.pass).length}/${packet.qa.checks.length} checks`); for (const c of packet.qa.checks) if (!c.pass) console.log(` FAILED ${c.check}: ${c.detail}`); console.log(` ${"year".padEnd(9)}${"n".padStart(6)}${"median".padStart(8)}${"<50%".padStart(8)}${"<33%".padStart(8)}${">100%".padStart(8)}${"Σ/Σ".padStart(8)}`); for (const year of packet.meta.years) { const s = (packet.by_year as Record)[year].school; console.log( ` ${year.padEnd(9)}${String(s.n).padStart(6)}${String(s.median).padStart(8)}` + `${String(s.share_under_50).padStart(8)}${String(s.share_under_33).padStart(8)}` + `${String(s.share_over_100).padStart(8)}${String(s.sum_over_sum).padStart(8)}`, ); } const d = packet.decomposition; console.log( ` decomposition ${d.from} → ${d.to}: ${d.continuing_buildings} buildings, ` + `${d.utilization_change_pp} pp = ${d.enrollment_effect_pp} enrollment ${d.capacity_effect_pp >= 0 ? "+" : ""}${d.capacity_effect_pp} capacity`, ); } main().catch((e) => { console.error(e); process.exit(1); });