scripts/loaders/philly/master_school_list.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 SDP Master School List CSV → philly_schools table.
// Source: docs/cities/philly/sources.yaml entry `sdp_master_school_list_2024-25`.
//
// Prereq: pipeline_philly/discover.py has run for that source_id, so the file
// + provenance live under data/cities/philly/sources/.../<YYYY-MM-DD>/.
//
// Run: npx tsx scripts/loaders/philly/master_school_list.ts
// Idempotent: upserts on ulcs_code.
import fs from "node:fs";
import { parse } from "csv-parse/sync";
import {
prisma,
startDataLoad,
finishDataLoad,
readLatestProvenance,
readLatestSourcePath,
batched,
} from "./_lib";
import type {
PhillySchoolType,
PhillyAdmissionCategory,
PhillyESSADesignation,
} from "@prisma/client";
const SOURCE_ID = "sdp_master_school_list_2024-25";
const YEAR_COVERED = "2024-25";
const SCRIPT_PATH = "scripts/loaders/philly/master_school_list.ts";
// Governance value → PhillySchoolType enum.
// Master List ships {District, Charter, Contracted}. We map onto PDE's
// 5-value taxonomy (per F.5) by collapsing Master List's Renaissance into
// CHARTER (carrying is_renaissance as a derived boolean per F.11).
function mapSchoolType(
governance: string,
schoolReportingCategory: string | null,
): PhillySchoolType {
const g = governance.trim().toLowerCase();
const cat = (schoolReportingCategory ?? "").toLowerCase();
if (cat.includes("cyber")) return "CYBER";
if (cat.includes("compctc") || cat.includes("cte")) return "COMPCTC";
if (cat.includes("special education")) return "SPECIALED";
if (g === "district") return "REGULAR";
if (g === "charter") return "CHARTER";
if (g === "contracted") return "CONTRACTED";
return "REGULAR";
}
function mapAdmission(admissionType: string | null): PhillyAdmissionCategory | null {
if (!admissionType) return null;
const t = admissionType.trim().toLowerCase();
if (t === "catchment") return "CATCHMENT";
if (t.startsWith("citywide")) return "CITYWIDE_LOTTERY";
if (t.startsWith("special admission") || t === "special-admission") return "SPECIAL_ADMISSION";
if (t.startsWith("criteria")) return "CRITERIA_BASED";
if (t.startsWith("charter")) return "CHARTER_LOTTERY";
if (t === "cte" || t.includes("career")) return "CTE";
if (t.includes("alternative")) return "ALTERNATIVE";
return "OTHER";
}
function mapEssa(designation: string | null): PhillyESSADesignation | null {
if (!designation) return null;
const d = designation.trim().toUpperCase();
if (["DFLT", "TSI", "CSI", "ATSI", "ACSI"].includes(d)) {
return d as PhillyESSADesignation;
}
return null;
}
function parseGps(gps: string | null): { lat: number | null; lng: number | null } {
if (!gps) return { lat: null, lng: null };
const parts = gps.split(",").map((s) => s.trim());
if (parts.length !== 2) return { lat: null, lng: null };
const lat = Number.parseFloat(parts[0]);
const lng = Number.parseFloat(parts[1]);
return {
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
};
}
function emptyToNull(v: string | undefined | null): string | null {
if (v === undefined || v === null) return null;
const t = String(v).trim();
return t === "" || t.toLowerCase() === "null" ? null : t;
}
function parseIntOrNull(v: string | undefined | null): number | null {
if (v === undefined || v === null || String(v).trim() === "") return null;
const n = Number.parseInt(String(v), 10);
return Number.isFinite(n) ? n : null;
}
type Row = Record<string, string>;
async function main() {
const filePath = readLatestSourcePath(SOURCE_ID);
const prov = readLatestProvenance(SOURCE_ID);
if (!filePath || !prov) {
throw new Error(
`No fetched source for ${SOURCE_ID}. Run: python pipeline_philly/discover.py --source ${SOURCE_ID}`,
);
}
console.log(`[load] file: ${filePath}`);
console.log(`[load] sha256: ${prov.sha256.slice(0, 12)}... (${prov.bytes.toLocaleString()} bytes)`);
const text = fs.readFileSync(filePath, "utf-8");
const rows = parse(text, {
columns: true,
skip_empty_lines: true,
trim: true,
}) as Row[];
console.log(`[load] parsed ${rows.length} rows`);
// City filter: Master List should be Philly-only already, but defensive
const phillyRows = rows.filter((r) => {
const c = (r["City"] || "").trim().toUpperCase();
// Allow null/empty (some directory entries omit city); only exclude clearly-non-Philly
return c === "" || c === "PHILADELPHIA";
});
if (phillyRows.length !== rows.length) {
console.log(`[load] filtered to ${phillyRows.length} rows where City=Philadelphia (excluded ${rows.length - phillyRows.length})`);
}
const dataLoad = await startDataLoad({
sourceId: SOURCE_ID,
sourceUrl: prov.url,
sha256: prov.sha256,
bytes: prov.bytes,
yearCovered: YEAR_COVERED,
scriptPath: SCRIPT_PATH,
notes: `Loaded ${phillyRows.length} Philly schools from Master List`,
});
let inserted = 0;
let updated = 0;
await batched(phillyRows, 25, async (batch) => {
for (const r of batch) {
const ulcsCode = emptyToNull(r["ULCS Code"]);
const aunCode = emptyToNull(r["AUN Code"]);
const name = emptyToNull(r["School Name (ULCS)"]) ?? emptyToNull(r["Publication Name"]);
if (!ulcsCode || !aunCode || !name) continue;
const governance = emptyToNull(r["Governance"]) ?? "Unknown";
const schoolReportingCategory = emptyToNull(r["School Reporting Category"]);
// Renaissance Charter status is recorded in `Major Intervention`, not
// `School Reporting Category` (empirical, verified 2026-06-03; 17 schools
// in the 2024-25 file carry it as "Renaissance Charter"). Override the
// F.11 finding text in docs/cities/philly/findings.md with this detail.
const majorIntervention = emptyToNull(r["Major Intervention"]);
const isRenaissance =
(majorIntervention ?? "").toLowerCase() === "renaissance charter";
const { lat, lng } = parseGps(emptyToNull(r["GPS Location"]));
const fields = {
aunCode,
paCode: emptyToNull(r["PA Code"]),
srcSchoolId: emptyToNull(r["SRC School ID"]),
ncesCode: emptyToNull(r["NCES Code"]),
name,
publicationName: emptyToNull(r["Publication Name"]),
abbreviatedName: emptyToNull(r["Abbreviated Name"]),
schoolType: mapSchoolType(governance, schoolReportingCategory),
governance,
isRenaissance,
schoolReportingCategory,
managementOrganization: emptyToNull(r["Management Organization"]),
admissionType: mapAdmission(emptyToNull(r["Admission Type"])),
schoolLevel: emptyToNull(r["School Level"]),
currentGradeSpan: emptyToNull(r["Current Grade Span Served"]),
yearOpened: parseIntOrNull(r["Year Opened"]),
learningNetwork: emptyToNull(r["Learning Network"]),
cityCouncilDistrict: emptyToNull(r["City Council District"]),
latitude: lat,
longitude: lng,
streetAddress: emptyToNull(r["Street Address"]),
city: emptyToNull(r["City"]),
state: emptyToNull(r["State"]),
zipCode: emptyToNull(r["Zip Code"]),
// Master List only marks the CSI cohort; other ESSA tiers come from
// Future Ready when that loader runs. Don't clobber an existing value
// (set by an earlier Future Ready load) with null.
...(mapEssa(emptyToNull(r["Federal Accountability Designation"])) !== null
? { essaDesignation: mapEssa(emptyToNull(r["Federal Accountability Designation"])) }
: {}),
titleI: emptyToNull(r["Title I Designation"]) === "1",
majorIntervention,
majorInterventionYear: emptyToNull(r["Major Intervention Year"]),
communitySchoolCohort: emptyToNull(r["Community School Cohort"]),
cteStatus: emptyToNull(r["CTE Status"]),
alternateEducationType: emptyToNull(r["Alternate Education Type"]),
schoolLeaderName: emptyToNull(r["School Leader Name"]),
} as const;
const result = await prisma.phillySchool.upsert({
where: { ulcsCode },
update: fields,
create: { ulcsCode, ...fields },
});
// Prisma upsert returns the row; we can't tell insert from update from
// the return value alone. Count both as upserted.
if (result.createdAt.getTime() === result.updatedAt.getTime()) inserted++;
else updated++;
}
});
await finishDataLoad(dataLoad.id, { inserted, updated, suppressed: 0 });
console.log(`[load] done: ${inserted} inserted, ${updated} updated`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());