scripts/loaders/miami/membership.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.
// Load FLDOE "Membership by District and School by Grade" (Survey 2, October)
// → miami_school_year_metrics: enrollment_oct (total) and enrollment_oct_g<grade>.
//
// One source per year: fldoe_membership_by_school_grade_<YYYY-YY>. Sheet
// order, header row, and blank/zero conventions differ by year
// (findings.md F.3); the header row is located by its first cell.
// Suppression tokens ('*', '**') on a grade cell → value null, suppressed.
// The school total comes from the "…Total Enrollment/Membership" sheet
// (present in every year; unsuppressed there), else from the sum of grade cells.
//
// Run: npx tsx scripts/loaders/miami/membership.ts [year ...] (default: every fetched year)
import fs from "node:fs";
import {
requireSource, startDataLoad, finishDataLoad, padSchoolNumber, parseCell, TokenCensus,
schoolUniverse, upsertFacts, sheetRows, findHeaderRow, runMain, SOURCES_ROOT, type Fact,
} from "./_lib";
const SCRIPT_PATH = "scripts/loaders/miami/membership.ts";
const GRADES = ["PK", "KG", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
function fetchedYears(): string[] {
return fs.readdirSync(SOURCES_ROOT)
.filter((d) => /^fldoe_membership_by_school_grade_\d{4}-\d{2}$/.test(d))
.map((d) => d.replace("fldoe_membership_by_school_grade_", ""))
.sort();
}
async function loadYear(year: string, universe: Set<string>) {
const sourceId = `fldoe_membership_by_school_grade_${year}`;
const { filePath, prov } = requireSource(sourceId);
const { rows: schoolRows, sheetNames } = sheetRows(filePath, "School");
const h = findHeaderRow(schoolRows, "District #");
// Header spelling varies by year ("District #" vs "DISTRICT #"); compare case-insensitively.
const header = schoolRows[h].map((c) => (c === null ? "" : String(c).replace(/\.0$/, "").trim().toUpperCase()));
const iDist = header.indexOf("DISTRICT #");
const iSch = header.indexOf("SCHOOL #");
const gradeIdx: Record<string, number> = {};
for (const g of GRADES) {
const i = header.findIndex((c) => c.toUpperCase() === g);
if (i >= 0) gradeIdx[g] = i;
}
if (iDist < 0 || iSch < 0 || Object.keys(gradeIdx).length < GRADES.length) {
throw new Error(`${sourceId}: unexpected header ${JSON.stringify(header)}`);
}
// Total sheet (name varies: "Schl Total Membership" 2016-17, "School Total Membership" 2017-18,
// "Schl/School Total Enrollment" from 2018-19); its value column is "Total Membership" or "# of Students".
const totalSheet = sheetNames.find((n) => /total (enrollment|membership)/i.test(n));
if (!totalSheet) throw new Error(`${sourceId}: no total sheet among ${JSON.stringify(sheetNames)}`);
const totals = new Map<string, { value: number | null; suppressed: boolean }>();
if (totalSheet) {
const { rows: tRows } = sheetRows(filePath, totalSheet);
const th = findHeaderRow(tRows, "District #");
const tHeader = tRows[th].map((c) => (c === null ? "" : String(c).trim().toUpperCase()));
const tDist = tHeader.indexOf("DISTRICT #");
const tSch = tHeader.indexOf("SCHOOL #");
const tVal = tHeader.findIndex((c) => /students|membership/i.test(c));
if (tDist < 0 || tSch < 0 || tVal < 0) throw new Error(`${sourceId}: unexpected total-sheet header ${JSON.stringify(tHeader)}`);
for (const r of tRows.slice(th + 1)) {
if (String(r[tDist] ?? "").replace(/\.0$/, "").trim() !== "13") continue;
const num = padSchoolNumber(r[tSch]);
if (!num) continue;
const p = parseCell(r[tVal]);
totals.set(num, { value: p.value, suppressed: p.suppressed });
}
}
const census = new TokenCensus();
const facts: Fact[] = [];
let dadeRows = 0;
let unknown = 0;
const unknownExamples: string[] = [];
for (const r of schoolRows.slice(h + 1)) {
if (String(r[iDist] ?? "").replace(/\.0$/, "").trim() !== "13") continue;
dadeRows++;
const num = padSchoolNumber(r[iSch]);
if (!num) continue;
if (!universe.has(num)) {
unknown++;
if (unknownExamples.length < 8) unknownExamples.push(`${num} ${String(r[iSch + 1] ?? "")}`);
continue;
}
// Grade cells: a number, blank (grade not served → 0), or a token ('*', '**').
// The token means "fewer than 10 students" in most years, but the 2022-23
// file uses '*' for grades a school does not serve at all. Decide per row
// from the data: if the numeric grade cells already sum to the school's
// published total, the token cells hold nobody (0, not suppressed);
// otherwise they hide students and stay suppressed.
const parsed = GRADES.map((g) => ({ g, p: parseCell(r[gradeIdx[g]]) }));
for (const { g, p } of parsed) census.add(p.token, g);
const numericSum = parsed.reduce((s, { p }) => s + (p.value ?? 0), 0);
const t = totals.get(num);
const tokensAreZero = parsed.some(({ p }) => p.suppressed) && t?.value !== null && t?.value !== undefined && Math.abs(numericSum - t.value) < 0.5;
let sum = 0;
let anySuppressed = false;
for (const { g, p } of parsed) {
const suppressed = p.suppressed && !tokensAreZero;
if (suppressed) anySuppressed = true;
const v = p.value ?? (suppressed ? null : 0);
facts.push({ schoolNumber: num, year, metricKey: `enrollment_oct_g${g}`, value: v, suppressed });
if (v !== null) sum += v;
}
if (t && t.value !== null) {
facts.push({ schoolNumber: num, year, metricKey: "enrollment_oct", value: t.value, suppressed: false });
} else if (!anySuppressed) {
facts.push({ schoolNumber: num, year, metricKey: "enrollment_oct", value: sum, suppressed: false });
} else {
// Total unavailable and at least one grade suppressed: the sum would be a floor, so mark suppressed.
facts.push({ schoolNumber: num, year, metricKey: "enrollment_oct", value: null, suppressed: true });
}
}
const load = await startDataLoad({ sourceId, sourceUrl: prov.url, sha256: prov.sha256, bytes: prov.bytes, yearCovered: year, scriptPath: SCRIPT_PATH });
const res = await upsertFacts(facts, load.id);
const note = `Dade rows ${dadeRows}; not in directory ${unknown}${unknownExamples.length ? ` (e.g. ${unknownExamples.join("; ")})` : ""}; total sheet: ${totalSheet ?? "none"}; tokens: ${census}`;
await finishDataLoad(load.id, { inserted: res.written, updated: 0, suppressed: res.suppressed }, note);
console.log(`[membership ${year}] ${res.written} facts (${res.suppressed} suppressed); ${note}`);
}
async function main() {
const years = process.argv.slice(2).length ? process.argv.slice(2) : fetchedYears();
const universe = await schoolUniverse();
console.log(`[membership] universe ${universe.size} schools; years: ${years.join(", ")}`);
for (const y of years) await loadYear(y, universe);
}
runMain(main);