scripts/loaders/miami/derived.ts
Served verbatim from the project repository. Internal working document conventions apply: documents may reference file paths, branch names, and findings-ledger anchors from the repo.
// Derived closure-track metrics, recomputed from validated inputs:
// utilization_permanent_pct = enrollment_oct / fish_capacity_permanent (same year)
// enrollment_change_5yr_pct = (enrollment_oct[y] / enrollment_oct[y-5] - 1) * 100
// k_to_g5_ratio = enrollment_oct_gKG / enrollment_oct_g5 (schools with both > 0)
// students_per_teacher = enrollment_oct / teacher_fte (same year)
// For 2025-26, where the state FISH report is not yet out, permanent capacity
// falls back to the latest FISH year, then the board book, then the GIS layer;
// the fallback used is recorded in the load notes.
// Run: npx tsx scripts/loaders/miami/derived.ts
import { prisma, startDataLoad, finishDataLoad, stageLoadOperation, upsertFacts, shiftSchoolYear, runMain, type Fact } from "./_lib";
const SCRIPT_PATH = "scripts/loaders/miami/derived.ts";
type Cell = { schoolNumber: string; year: string; metricKey: string; value: number | null; suppressed: boolean };
async function main() {
const keys = ["enrollment_oct", "enrollment_oct_gKG", "enrollment_oct_g5", "fish_capacity_permanent", "fish_capacity_permanent_abc", "fish_capacity_permanent_gis", "teacher_fte"];
const cells = (await prisma.miamiSchoolYearMetric.findMany({
where: { metricKey: { in: keys }, subgroup: "ALL" },
select: { schoolNumber: true, year: true, metricKey: true, value: true, suppressed: true },
})) as Cell[];
const idx = new Map<string, Cell>();
for (const c of cells) idx.set(`${c.schoolNumber}|${c.year}|${c.metricKey}`, c);
const get = (n: string, y: string, k: string) => {
const c = idx.get(`${n}|${y}|${k}`);
return c && !c.suppressed && c.value !== null ? c.value : null;
};
const years = [...new Set(cells.filter((c) => c.metricKey === "enrollment_oct").map((c) => c.year))].sort();
const fishYears = [...new Set(cells.filter((c) => c.metricKey === "fish_capacity_permanent").map((c) => c.year))].sort();
const latestFish = fishYears[fishYears.length - 1];
const schools = [...new Set(cells.map((c) => c.schoolNumber))];
const facts: Fact[] = [];
let fallbackFish = 0, fallbackAbc = 0, fallbackGis = 0;
for (const n of schools) {
for (const y of years) {
const enr = get(n, y, "enrollment_oct");
if (enr === null) continue;
// utilization
let cap = get(n, y, "fish_capacity_permanent");
if (cap === null && fishYears.includes(y) === false) {
if (get(n, latestFish, "fish_capacity_permanent") !== null && y > latestFish) { cap = get(n, latestFish, "fish_capacity_permanent"); fallbackFish++; }
else if (get(n, y, "fish_capacity_permanent_abc") !== null) { cap = get(n, y, "fish_capacity_permanent_abc"); fallbackAbc++; }
else if (get(n, "2024-25", "fish_capacity_permanent_gis") !== null && y > "2023-24") { cap = get(n, "2024-25", "fish_capacity_permanent_gis"); fallbackGis++; }
}
if (cap !== null && cap > 0) facts.push({ schoolNumber: n, year: y, metricKey: "utilization_permanent_pct", value: (enr / cap) * 100, denominator: cap });
// five-year change
const prev = get(n, shiftSchoolYear(y, -5), "enrollment_oct");
if (prev !== null && prev > 0) facts.push({ schoolNumber: n, year: y, metricKey: "enrollment_change_5yr_pct", value: (enr / prev - 1) * 100, denominator: prev });
// pipeline ratio
const k = get(n, y, "enrollment_oct_gKG"), g5 = get(n, y, "enrollment_oct_g5");
if (k !== null && g5 !== null && g5 > 0 && k > 0) facts.push({ schoolNumber: n, year: y, metricKey: "k_to_g5_ratio", value: k / g5, denominator: g5 });
// staffing
const t = get(n, y, "teacher_fte");
if (t !== null && t > 0) facts.push({ schoolNumber: n, year: y, metricKey: "students_per_teacher", value: enr / t, denominator: t });
}
}
const load = await startDataLoad({ sourceId: "derived", scriptPath: SCRIPT_PATH,
notes: `inputs: ${keys.join(", ")}; years ${years[0]}..${years[years.length - 1]}; FISH years ${fishYears.join(",")}; capacity fallbacks for years after ${latestFish}: latest FISH ${fallbackFish}, board book ${fallbackAbc}, GIS ${fallbackGis}` });
// miami_schools.latest_enrollment = the school's latest unsuppressed October count.
let latestUpdated = 0;
for (const n of schools) {
const ys = years.filter((y) => get(n, y, "enrollment_oct") !== null);
if (!ys.length) continue;
stageLoadOperation(load.id, (tx) => tx.miamiSchool.update({ where: { schoolNumber: n }, data: { latestEnrollment: Math.round(get(n, ys[ys.length - 1], "enrollment_oct")!) } }));
latestUpdated++;
}
const res = await upsertFacts(facts, load.id);
await finishDataLoad(load.id, { inserted: res.written, updated: 0, suppressed: 0 });
console.log(`[derived] ${res.written} facts; capacity fallbacks — latest FISH: ${fallbackFish}, board book: ${fallbackAbc}, GIS: ${fallbackGis}; latest_enrollment set on ${latestUpdated} schools`);
}
runMain(main);