Handoff — the capacity and utilization work
Orientation for a developer picking up the enrollment, capacity and utilization work for NYC and Philadelphia, which sits beside the Miami-Dade build it was modelled on. It covers what exists and where it lives, how to run and verify it, and the things in it that look like bugs and are not.
Read this, then capacity-utilization-gaps.md,
which is the register of what is still open.
What exists
- NYC. The SCA's Enrollment, Capacity & Utilization Report (the Blue
Book): twelve school years, 2013-14 through 2025-26, with 2020-21 absent
because the SCA never published it. Seven years come from NYC Open Data
(
gkd7-3vk7); five (2014-15, 2019-20, 2022-23, 2023-24, 2024-25) are parsed from the SCA's PDFs under a two-parser gate and committed as CSVs with a gate report beside each. Loaded at school grain, as six keys inschool_year_metrics; the building-grain rows are rolled up in memory and not stored, and the packet computes its building figures from the source. Utilization is Blue Book enrollment (3-K and Pre-K included) over SCA target capacity (Pre-K rooms included). Shown as rows in the NYC school page's Access section (utilization badged "derived", with a one-line basis note), and on/methodology/analysis/enrollment-capacity. - Philadelphia. The SDP Facilities Master Plan dashboard, one vintage
(SY2024-25), pulled from a Qlik app into two committed CSVs (sites and
buildings) and loaded at site grain (
philly_sites) and school grain (four keys inphilly_school_year_metrics), withphilly_site_programsjoining schools to sites. The building CSV is read by the packet, not loaded. A school's figure is its enrollment counted once, plus the Pre-K seats allotted across its own sites, over capacity summed across them; a school sharing a site with another directory school gets no school figure. Shown in a Facilities section on each Philadelphia school page, and on/philly/methodology/analysis/enrollment-capacity. - Miami-Dade. Built separately (
docs/cities/miami/06_enrollment_capacity_closures.md,data/analysis/miami-enrollment.json). Its SY2024-25utilizationblock is one of the three rows the Philadelphia packet compares. - Across the three. The comparison is on SY2024-25, in the Philadelphia
packet's
cross_cityblock and Philadelphia doc §4.2. The cities differ in denominator, universe and Pre-K basis, and each row names all three. No common-basis figure is computed. - Descriptive, not ranked. Every capacity key carries
subdomain = 'Facilities'and directionNEUTRAL. The subdomain keeps it out of both percentile batches, so it carries no percentile and no outlier ranking. The NYC movers page, which sorts by year-over-year change rather than by percentile, does list it like any other metric.
Where things are
| what | where |
|---|---|
| The gap register — what is still open, and the grades | docs/capacity-utilization-gaps.md |
| What the sources carry that the database does not store (the building panels, six Philadelphia site fields), and how to add it | docs/capacity-utilization-gaps.md §10 |
| NYC findings, method, and the schema rationale | docs/design/10_nyc_enrollment_capacity_utilization.md |
| Philadelphia findings | docs/cities/philly/06_enrollment_capacity_utilization.md |
| Miami — the precedent both cities were modelled on | docs/cities/miami/06_enrollment_capacity_closures.md |
| NYC transform (pure, no I/O) and its hazard notes | scripts/loaders/_bluebook.ts |
| NYC loader (Socrata fetch, the parsed years, reporting, check (iv)) | scripts/loaders/nyc-bluebook.ts |
| NYC metric keys | scripts/loaders/metric-definitions-capacity.ts |
NYC Blue Book PDF parser and its gate — parser A is pdfplumber word boxes against per-page column anchors and is canonical, parser B is camelot stream; they must agree on the record count and on every identifier and number, in order | pipeline_nyc/parse/bluebook.py |
| The five committed parses and their gate reports | data/cities/nyc/reference/parsed/ |
Where each PDF came from: capture URL, DNN file id, bytes, sha256 (the PDFs are gitignored) | data/cities/nyc/sources/sca_bluebook_<year>/<date>/provenance.json |
| Gate-checked reader for those years, shared by the loader and the packet so a year in the warehouse cannot be missing from the page | scripts/loaders/_bluebook_recovered.ts |
| Philadelphia transform (dedupe, site links, school roll-up, checks (iv)-(vi)) and loader | scripts/loaders/philly/_facilities.ts, scripts/loaders/philly/facilities.ts |
| Philadelphia metric keys | scripts/loaders/philly/metric_definitions_facilities.ts (the capacity keys; the other Philadelphia keys are in philly/metric_definitions.ts) |
| Qlik extract → committed CSVs | pipeline_philly/fetch_facilities.py |
Analysis packets → data/analysis/{nyc,philly}-enrollment-capacity.json | scripts/analysis/nyc-capacity-packet.ts, scripts/analysis/philly-capacity-packet.ts |
| Packet types and charts | components/analysis/{nyc,philly}-capacity-packet.ts, components/analysis/{nyc,philly}-capacity-charts.tsx |
| Analysis pages | app/methodology/analysis/enrollment-capacity/page.tsx, app/philly/methodology/analysis/enrollment-capacity/page.tsx |
| Philadelphia school page: the Facilities section, its site query, and the pure function that picks which case a school shows (its own figure, 223 of 331 schools; a shared site's figures, 39; no published capacity, 10; no site in the plan, 59; site figures with no stored school figure, 0) | app/philly/schools/[ulcs]/page.tsx; getPhillySchoolSites and phillyFacilitiesMissing in lib/queries/philly-schools.ts; lib/philly/facilities-display.ts |
| NYC school page (capacity rows and their basis note in the Access section) | app/schools/[dbn]/page.tsx |
| Proof trees for the Philadelphia capacity keys (roll-up formula, assumptions, the checks that guard it) and the Facilities source entry | lib/philly/lineage.ts |
| Keeping the capacity keys out of the percentiles | the subdomain filter in scripts/loaders/compute-percentiles.ts and scripts/loaders/philly/compute_percentiles.ts |
| Probes that reproduce the published figures | docs/design/probe/bluebook_util.py, bluebook_verify.py, docs/cities/philly/probe/facilities.py |
| Tests: transforms on verbatim source rows, and the school-page case choice | tests/nyc-bluebook-transform.test.ts, tests/philly-facilities-transform.test.ts, tests/philly-school-facilities.test.ts |
| The two-parser precedent the NYC gate was modelled on — it runs two pdfplumber strategies, A reading page text with a regex and B reading word boxes by x-position. It does not use camelot; camelot is imported in no file in that pipeline | pipeline_miami/parse/fish_los.py |
| Every command, the cold-database load order, and how to re-parse a year | make capacity-help |
| House writing rules for anything reader-facing | docs/writing-style.md |
How to run and verify
The runbook — installs, dry runs, probes, the cold-database load order and the
row counts a full load produces — is gaps §0, and make capacity-help prints
the same order.
Export DIRECT_URL to the target database before any load. Both seeders
and both loaders connect through their loader library (scripts/loaders/_lib.ts,
scripts/loaders/philly/_lib.ts), which uses DIRECT_URL (or
POSTGRES_URL_NON_POOLING) whenever either is set, exported or in
.env/.env.local, and DATABASE_URL only otherwise. An exported
DIRECT_URL wins over one in the files, because dotenv does not override a
value already set, but an exported DATABASE_URL does not win over a
DIRECT_URL in them; a load run without an exported DIRECT_URL writes to
whatever those files name. The psql steps read
$DIRECT_URL from the shell alone.
What a correct run prints:
nyc-bluebook.ts --dry-run: twelve years, each with check (iv) at 100% of single-building organizations within 1pp, max 0pp.philly/facilities.ts --dry-run: check (iv) 245/245 (max |diff| 0.0000), check (v) 285/285 sites, check (vi) 156/156 over 157 schools with a figure, and 183 more set aside on a shared site.philly/facilities.tsagainst a database: check (vi) 220/220 over 223 directory schools with a figure, and 39 more set aside on a shared site, thenwrote 328 sites, 305 site↔school links, 892 school facts.
Full verification before committing:
npx tsc --noEmit
npx vitest run
npx tsx scripts/analysis/nyc-capacity-packet.ts --check
npx tsx scripts/analysis/philly-capacity-packet.ts --check
npx tsx scripts/loaders/nyc-bluebook.ts --dry-run
npx tsx scripts/loaders/philly/facilities.ts --dry-run
npx next build
On a checkout whose node_modules is a symlink, Turbopack refuses the build;
npx next build --webpack works there.
If a figure changes, the probe, the design doc, the packet and the page all
carry it, and they are expected to agree. --check on either packet diffs the
committed JSON against a fresh run and exits non-zero on drift. The
Philadelphia packet reads the NYC and Miami-Dade packets for its cross_city
block, so regenerating either means regenerating it, and its --check fails
until you do. Metric descriptions live in the database: after editing either
definitions file, rerun its seeder.
Re-parsing a Blue Book year is a separate, deliberate step — it is not part of that sequence, and the committed CSVs are what the loader and the packet read. Gaps §1 says how the PDFs are obtained and how a parsed year enters the panel:
python -m pipeline_nyc.parse.bluebook <year> <pdf> [<pdf>...] \
--snapshot=YYYY-MM-DD --evidence=<section-B measurement>.json
Things that look like bugs and are not
A failed check stops the load. That is the design. Both loaders refuse to write rather than write something the transform cannot vouch for:
- NYC: check (iv), which recomputes utilization from enrollment and capacity
and compares it with the publisher's own column (100% at max 0pp on all
twelve years, the five parsed ones included, which is the main evidence those
parses are right); an unrecognised snapshot vintage (
UnknownSnapshotError); a parsed year whose gate report is missing or failed. - Philadelphia: check (iv), published site utilization against (enrollment + Pre-K allotment) ÷ capacity; check (v), enrollment with allotment against enrollment plus allotment on every site; check (vi), a school whose capacity sits on one of its sites against that site's published utilization; a school whose own sites carry different enrollments. The Philadelphia packet, which reads the building CSV, stops on a repeated building whose rows disagree.
Each guards an assumption the transform relies on. If one fails, the source changed shape or a parse is wrong; do not relax the threshold, skip the year or widen the tolerance to get a load through. The Philadelphia school-grain checks run before anything is written, so a refusal leaves the tables as they were.
Split-site check (iv) is reported, not gated (NYC). A school across several buildings gets one row per building; annex rows often carry a null capacity while repeating the school-wide utilization. So the published per-row utilization is not the utilization of that row's enrollment and capacity, and the loader reports split-site agreement separately (51/209 within 1pp on 2025-26, max 75pp) without gating on it.
The same quantity is reported over two universes. Co-location reads 35.7–41.3% from the loader (every building in the extract, 1,383–1,466 of them) and 38.5–42.1% from the packet (buildings carrying both an enrollment and a capacity, 1,311–1,334). Both are correct across all twelve years. Check which denominator a figure uses before concluding two numbers disagree.
NYC's school count differs between the packet and the database. The packet
joins organizations to DBNs against the directory-plus-Snapshot union, the
loader against the schools table of the database it writes to, so the stored
count depends on that table. Production's is built from the 2019-20 School
Locations extract and lacks the schools opened since: loaded there, the two
counts agree within one school through 2019-20 and diverge after it, to 1,702
in the packet and 1,671 in the database for 2024-25 (NYC doc §10). The packet's
figure is the one the pages cite.
The Philadelphia dry run and the load report different check (vi) counts. The dry run never opens the database, so it cannot read the school directory and runs the roll-up over every link in the extract: 157 schools with a figure and 183 more set aside on a shared site, because a school's own Pre-K programme counts as a second school there. The load runs over directory schools only: 223 with a figure and 39 more set aside on a shared site.
A Philadelphia school figure can match no per-site figure SDP publishes.
Three split campuses (2210, 7440, 8770) have capacity on two sites of their
own. SDP's per-site utilization divides the whole school by one site's capacity
(Hancock, 8770: 194.6% on S100, 256.4% on S130); the school figure divides by
the sum (110.6%), the rule the dashboard's own per-school measure,
Site Utilization (MAP), uses. The site-grain distribution in the packet keeps
SDP's per-site basis (Philadelphia doc §4.1).
Snapshot years are measured, not read off the file. Each of the twelve
vintages was matched against every year of the DOE Demographic Snapshot and
assigned the year with the minimum median absolute enrollment difference — 0
students in all twelve, runner-up margins 18–24 students. The publication lag
runs three to eighteen months, so the posting date gives wrong answers.
UnknownSnapshotError aborts rather than inferring. The five parsed years are
keyed on the audited-register date their report states (2014-10-31,
2019-10-31, 2022-10-31, 2023-10-31, 2024-10-31) because a PDF has no posting
date; the key is only an identifier for the vintage, and the school year beside
it in SNAPSHOT_YEAR was measured. Date-stamp anything new the same way
(bluebook_util.py section B) before adding it.
philly_site_programs has no fixed expected count. The extract carries 385
site↔school pairs; the loader writes only those whose ULCS is already in
philly_schools and prints how many it skipped (80 on the current extract, all
Pre-K programmes). The invariant is 385 − skipped, not 385.
On the PDF years, absent is not zero. The printed report leaves a blank
where the Socrata extract carries a 0, in the PK-capacity and
cluster-rooms-needed columns, on about 2% of rows — 62 of 3,132 on the control
year, all of them split-site organizations. The parser reads those as absent
rather than inventing a zero, because the ink is not on the page. The
blank-vs-zero rule it does apply — if any of the three "+" columns carries a
value, the blanks among them are zeros — is exact on all 1,660 rows where
anything is printed. This is a floor of the source, not a parse defect:
anything doing arithmetic on prek_capacity has to treat absent as unknown,
not as zero, and a per-year total of Pre-K seats is a lower bound on the five
PDF years in a way it is not on the seven Socrata ones.
The capacity keys carry no percentile. By design: they are descriptive.
subdomain = 'Facilities' keeps them out of both percentile batches, and each
batch clears any percentile stored on a Facilities row before it ranks. The
outlier and mover dropdowns still list them. Selected on an outlier page, they
show no results; the NYC movers page sorts them by year-over-year change
like any other metric (Philadelphia has one year, so no movers).
A new capacity key needs that subdomain. The SQL uses IS DISTINCT FROM so that
keys with no subdomain stay in.
What is verified and what is not
Verified, and re-runnable:
- NYC enrollment against the federal CCD, r = 0.9998 / 0.9997, with the DOE's own Snapshot as a control.
- NYC capacity against the DOE room inventory, R² ≈ 0.92 over 28 pre-registered instructional functions. Same publisher, so a consistency check, not an independent one.
- The retired Blue Book extract: 1,382 buildings, 100% exact, r = 1.0000.
- Check (iv) in both cities — NYC at 100%, max 0pp, on all twelve years; Philadelphia 245/245 sites.
- Philadelphia's school roll-up against the district's own per-site figures:
check (vi), 220/220 on every load. Two further comparisons are one-off
measurements that no committed script prints (Philadelphia doc §3.2): the
dashboard's per-school
Site Utilization (MAP)matches the roll-up's numerator and capacity on all 23 multi-site District schools, and the loaded school figures correlate with the NCES 2024 enrollment count at r 0.9993 (median difference 2 students, 222 schools). - The PDF parser against a control. 2025-26 is published on Socrata and
available as a PDF. The parser run on that PDF gives 3,132 rows against 3,132,
no key on one side only, no duplicate keys, and 43,700 of 43,848 cells
identical — 0.338% divergence, in three understood groups (124 blank-vs-zero
cells, 18 where the extract blanks building columns the parse repeats and
rollUpBuildingsreconciles, 6 cosmetic whitespace). Through the repo's own transform the parsed rows reproduce Socrata's answers: the same 1,776 organizations, the same 1,384 buildings, and an identical check (iv) — 1,496/1,496 single-building organizations within 1pp at max 0pp, split-site 51/209 at max 75pp. - The row counts a full load writes (gaps §0), against a real database.
Not verified, and worth holding in mind:
- No second publisher states NYC capacity. Enrollment has one; capacity does
not. The packet's
qa.not_verifiedsays so. - The five parsed years have no second publisher either — Socrata never carried them, so the two-parser gate, the date-stamp measurement and check (iv) are the whole of the evidence for them. The 2025-26 control is what makes those three checks interpretable rather than self-referential.
- NYC has no public closure list, so nothing here supports a predictive claim. This is the gap that would change the picture most and it is a grade C.
- Philadelphia's dashboard carries a second, older capacity figure. On the 207 sites carrying both, the share under 50% reads 25.1% on site capacity and 31.4% on the building figure (each building counted once), a 6.3-point swing; in aggregate the two nearly agree. The app labels the building figure its 2017 ("OLD") capacity and documents the method for neither figure, and the comparison divides 2024-25 enrollment by that 2017 figure. No served figure uses it (gaps §5, Philadelphia doc §6.5).
How to work
The repo's own norms:
- Derive quantities from the process rather than quoting constants; state a decision rule as a lift over a base rate or a test, not a bare threshold.
- Validate against a reference constructed before looking at the output.
- Explain an effect by decomposition and ablation, not narrative.
- When a question is answerable from data in hand, measure it rather than choosing by taste.
- Report negative results next to positive ones. Several of the most useful findings here are negative — the SCA never published 2020-21, the Snapshot cannot supply a closure reference, the SDP report archive holds no capacity figures, and splitting the NYC decomposition by segment does not change the endpoint reading: the enrollment term is negative in every segment but one, and that one (2022-23 → 2023-24) is a +0.4 pp rise (NYC doc §4.3).
- Write reader-facing text as a description of what exists, per
docs/writing-style.md: what each figure is and how it is computed, its hazards stated as hazards, and its limits.