scripts/loaders/philly/sdp_attendance_and_suspensions.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 attendance (≥90%, ADA) + out-of-school suspensions distribution
// → philly_school_year_metrics.
//
// Three sources, similar shape:
// sdp_attendance_90_school → attendance_rate_above_90
// sdp_attendance_ada_school → average_daily_attendance
// sdp_suspensions_school → derives oss_pct_any / multiple / chronic (F.10)
//
// Run: npx tsx scripts/loaders/philly/sdp_attendance_and_suspensions.ts
import * as fs from "node:fs";
import { parse } from "csv-parse/sync";
import {
prisma,
startDataLoad,
finishDataLoad,
readLatestProvenance,
readLatestSourcePath,
batched,
} from "./_lib";
import type { PhillySubgroup } from "@prisma/client";
const SCRIPT_PATH = "scripts/loaders/philly/sdp_attendance_and_suspensions.ts";
// SDP's "Group" values for these CSVs differ slightly from the grad CSV — they
// use straight strings rather than the racial taxonomy. Build a flexible map.
const GROUP_MAP: Record<string, PhillySubgroup> = {
"All Students": "ALL",
"American Indian/Alaskan Native": "AMER_INDIAN_AK_NATIVE",
"Asian": "ASIAN",
"Native Hawaiian/Pacific Islander": "HAWAIIAN_PAC_ISL",
"Black/African American": "BLACK",
"Hispanic/Latino": "HISPANIC",
"White": "WHITE",
"Multi Racial/Other": "TWO_OR_MORE_RACES",
"Economically Disadvantaged": "ECON_DISADV",
"Not Economically Disadvantaged": "NOT_ECON_DISADV",
"EL": "ELL",
"Non-EL": "NOT_ELL",
"Has IEP": "IEP",
"Does Not Have IEP": "NOT_IEP",
"Female": "FEMALE",
"Male": "MALE",
"Non-Binary": "NON_BINARY",
};
function toNumber(v: unknown): number | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
if (s === "" || s === "*" || s === "S") return null;
const n = Number.parseFloat(s);
return Number.isFinite(n) ? n : null;
}
type Row = Record<string, string>;
async function readCsv(sourceId: string): Promise<{ rows: Row[]; prov: ReturnType<typeof readLatestProvenance> }> {
const filePath = readLatestSourcePath(sourceId);
const prov = readLatestProvenance(sourceId);
if (!filePath || !prov) throw new Error(`No fetched source for ${sourceId}.`);
const rows = parse(fs.readFileSync(filePath, "utf-8"), {
columns: true,
skip_empty_lines: true,
trim: true,
}) as Row[];
return { rows, prov };
}
type Fact = {
schoolUlcs: string;
year: string;
metricKey: string;
subgroup: PhillySubgroup;
value: number | null;
denom: number | null;
suppressed: boolean;
};
async function upsertFacts(facts: Fact[], dataLoadId: string): Promise<{ inserted: number; suppressed: number }> {
let inserted = 0;
let suppressed = 0;
await batched(facts, 200, async (batch) => {
for (const f of batch) {
await prisma.phillySchoolYearMetric.upsert({
where: {
schoolUlcs_year_metricKey_subgroup_populationCut: {
schoolUlcs: f.schoolUlcs,
year: f.year,
metricKey: f.metricKey,
subgroup: f.subgroup,
populationCut: "n/a",
},
},
update: { value: f.value, denominator: f.denom, suppressed: f.suppressed, sourceLoadId: dataLoadId },
create: {
schoolUlcs: f.schoolUlcs,
year: f.year,
metricKey: f.metricKey,
subgroup: f.subgroup,
populationCut: "n/a",
value: f.value,
denominator: f.denom,
suppressed: f.suppressed,
sourceLoadId: dataLoadId,
},
});
inserted++;
if (f.suppressed) suppressed++;
}
}, 8);
return { inserted, suppressed };
}
async function loadAttendance(
sourceId: string,
metricKey: string,
valueCol: string,
denomCol: string,
ulcsSet: Set<string>,
): Promise<void> {
console.log(`\n[load] ${sourceId} → ${metricKey}`);
const { rows, prov } = await readCsv(sourceId);
console.log(`[load] ${rows.length.toLocaleString()} CSV rows`);
const dataLoad = await startDataLoad({
sourceId, sourceUrl: prov!.url, sha256: prov!.sha256, bytes: prov!.bytes,
scriptPath: SCRIPT_PATH,
});
const facts: Fact[] = [];
for (const r of rows) {
const ulcs = r["ULCS Code"]?.trim();
if (!ulcs || !ulcsSet.has(ulcs)) continue;
const year = (r["School Year"] ?? "").trim();
if (!/^\d{4}-\d{4}$/.test(year)) continue;
const yearCanon = `${year.slice(0, 4)}-${year.slice(7, 9)}`;
const subgroup = GROUP_MAP[r["Group"]?.trim() ?? ""];
if (!subgroup) continue;
facts.push({
schoolUlcs: ulcs,
year: yearCanon,
metricKey,
subgroup,
value: toNumber(r[valueCol]),
denom: toNumber(r[denomCol]),
suppressed: toNumber(r[valueCol]) === null,
});
}
console.log(`[load] facts: ${facts.length.toLocaleString()}`);
const { inserted, suppressed } = await upsertFacts(facts, dataLoad.id);
await finishDataLoad(dataLoad.id, { inserted: inserted - suppressed, updated: 0, suppressed });
console.log(`[load] done — ${inserted.toLocaleString()} (${suppressed.toLocaleString()} suppressed)`);
}
async function loadAdaSimple(ulcsSet: Set<string>): Promise<void> {
const SOURCE_ID = "sdp_attendance_ada_school";
console.log(`\n[load] ${SOURCE_ID} → average_daily_attendance (school-wide, ALL subgroup only)`);
const { rows, prov } = await readCsv(SOURCE_ID);
console.log(`[load] ${rows.length.toLocaleString()} CSV rows`);
const dataLoad = await startDataLoad({
sourceId: SOURCE_ID, sourceUrl: prov!.url, sha256: prov!.sha256, bytes: prov!.bytes,
scriptPath: SCRIPT_PATH,
});
const facts: Fact[] = [];
for (const r of rows) {
const ulcs = r["ULCS Code"]?.trim();
if (!ulcs || !ulcsSet.has(ulcs)) continue;
const year = (r["School Year"] ?? "").trim();
if (!/^\d{4}-\d{4}$/.test(year)) continue;
const yearCanon = `${year.slice(0, 4)}-${year.slice(7, 9)}`;
const value = toNumber(r["Average Daily Attendance (YTD)"]);
facts.push({
schoolUlcs: ulcs,
year: yearCanon,
metricKey: "average_daily_attendance",
subgroup: "ALL",
value,
denom: null,
suppressed: value === null,
});
}
console.log(`[load] facts: ${facts.length.toLocaleString()}`);
const { inserted, suppressed } = await upsertFacts(facts, dataLoad.id);
await finishDataLoad(dataLoad.id, { inserted: inserted - suppressed, updated: 0, suppressed });
console.log(`[load] done — ${inserted.toLocaleString()} (${suppressed.toLocaleString()} suppressed)`);
}
async function loadSuspensions(ulcsSet: Set<string>): Promise<void> {
const SOURCE_ID = "sdp_suspensions_school";
console.log(`\n[load] ${SOURCE_ID} → oss_pct_{any,multiple,chronic}`);
const { rows, prov } = await readCsv(SOURCE_ID);
console.log(`[load] ${rows.length.toLocaleString()} CSV rows`);
const dataLoad = await startDataLoad({
sourceId: SOURCE_ID, sourceUrl: prov!.url, sha256: prov!.sha256, bytes: prov!.bytes,
scriptPath: SCRIPT_PATH,
});
const facts: Fact[] = [];
for (const r of rows) {
const ulcs = r["ULCS Code"]?.trim();
if (!ulcs || !ulcsSet.has(ulcs)) continue;
const year = (r["School Year"] ?? "").trim();
if (!/^\d{4}-\d{4}$/.test(year)) continue;
const yearCanon = `${year.slice(0, 4)}-${year.slice(7, 9)}`;
const subgroup = GROUP_MAP[r["Group"]?.trim() ?? ""];
if (!subgroup) continue;
const denom = toNumber(r["Total Students (Yearly)"]);
const pctZero = toNumber(r["% with Zero OS Suspensions (Yearly)"]);
const pctTwo = toNumber(r["% with 2 OS Suspensions (Yearly)"]);
const pctThree = toNumber(r["% with 3 OS Suspensions (Yearly)"]);
// We don't have a published "% with 4+" column directly — derive as
// residual: 100 − (sum of 0+1+2+3 percents)
const pctOne = toNumber(r["% with 1 OS Suspension (Yearly)"]);
const four_plus =
pctZero !== null && pctOne !== null && pctTwo !== null && pctThree !== null
? Math.max(0, 100 - (pctZero + pctOne + pctTwo + pctThree))
: null;
// Derived metrics
const pctAny = pctZero === null ? null : 100 - pctZero;
const pctMultiple =
pctTwo !== null && pctThree !== null && four_plus !== null
? pctTwo + pctThree + four_plus
: null;
const pctChronic = four_plus;
for (const [key, value] of [
["oss_pct_any", pctAny],
["oss_pct_multiple", pctMultiple],
["oss_pct_chronic", pctChronic],
] as const) {
facts.push({
schoolUlcs: ulcs,
year: yearCanon,
metricKey: key,
subgroup,
value,
denom,
suppressed: value === null,
});
}
}
console.log(`[load] facts: ${facts.length.toLocaleString()}`);
const { inserted, suppressed } = await upsertFacts(facts, dataLoad.id);
await finishDataLoad(dataLoad.id, { inserted: inserted - suppressed, updated: 0, suppressed });
console.log(`[load] done — ${inserted.toLocaleString()} (${suppressed.toLocaleString()} suppressed)`);
}
async function main() {
const phillySchools = await prisma.phillySchool.findMany({ select: { ulcsCode: true } });
const ulcsSet = new Set(phillySchools.map((s) => s.ulcsCode));
console.log(`[load] philly_schools universe: ${ulcsSet.size}`);
await loadAttendance(
"sdp_attendance_90_school",
"attendance_rate_above_90",
"% with 90%+ Attendance (Yearly)",
"Total Students (Yearly)",
ulcsSet,
);
// ADA file is school-wide only (no subgroups), 5 columns total.
// Custom loader since loadAttendance assumes Group + denominator columns.
await loadAdaSimple(ulcsSet);
await loadSuspensions(ulcsSet);
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());