Source document

scripts/loaders/philly/sdp_graduation.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 graduation rates CSV → philly_school_year_metrics.
// Source: sdp_graduation_school_2014-2025 (single CSV covering 2010-11 cohort
// onwards, with 4/5/6-year graduation rates × subgroup).
//
// Cohort convention: `cohort` field is the 9th-grade-start school year.
// 4-year graduation rate for cohort 2017-18 → graduated by 2020-21 school
// year. We index metrics by the cohort year (matches SDP/SPREE convention).
//
// Run:  npx tsx scripts/loaders/philly/sdp_graduation.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 SOURCE_ID = "sdp_graduation_school_2014-2025";
const SCRIPT_PATH = "scripts/loaders/philly/sdp_graduation.ts";

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 rateTypeToMetric(rateType: string): string | null {
  const t = rateType.trim();
  if (t === "4-Year Graduation Rate") return "graduation_rate_4yr";
  if (t === "5-Year Graduation Rate") return "graduation_rate_5yr";
  if (t === "6-Year Graduation Rate") return "graduation_rate_6yr";
  return null;
}

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 main() {
  const filePath = readLatestSourcePath(SOURCE_ID);
  const prov = readLatestProvenance(SOURCE_ID);
  if (!filePath || !prov) throw new Error(`No fetched source for ${SOURCE_ID}.`);
  console.log(`[load] file: ${filePath}`);
  console.log(`[load] sha256: ${prov.sha256.slice(0, 12)}... (${prov.bytes.toLocaleString()} bytes)`);

  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}`);

  const rows = parse(fs.readFileSync(filePath, "utf-8"), {
    columns: true,
    skip_empty_lines: true,
    trim: true,
  }) as Row[];
  console.log(`[load] CSV rows: ${rows.length.toLocaleString()}`);

  const dataLoad = await startDataLoad({
    sourceId: SOURCE_ID,
    sourceUrl: prov.url,
    sha256: prov.sha256,
    bytes: prov.bytes,
    yearCovered: "2010-11..2021-22",
    scriptPath: SCRIPT_PATH,
  });

  type Fact = {
    schoolUlcs: string;
    year: string;
    metricKey: string;
    subgroup: PhillySubgroup;
    value: number | null;
    denom: number | null;
    suppressed: boolean;
  };
  const facts: Fact[] = [];
  let hits = 0;
  let skipped = 0;

  for (const r of rows) {
    const ulcs = r["schoolid_ulcs"]?.trim();
    if (!ulcs || !ulcsSet.has(ulcs)) { skipped++; continue; }
    const mk = rateTypeToMetric(r["rate_type"] ?? "");
    if (!mk) { skipped++; continue; }
    const subgroup = GROUP_MAP[r["subgroup"]?.trim() ?? ""];
    if (!subgroup) { skipped++; continue; }
    // cohort is "2017-2018" — normalize to "2017-18" canonical form
    const cohort = (r["cohort"] ?? "").trim();
    const m = cohort.match(/^(\d{4})-\d{2}(\d{2})$/);
    if (!m) { skipped++; continue; }
    const year = `${m[1]}-${m[2]}`;
    hits++;
    facts.push({
      schoolUlcs: ulcs,
      year,
      metricKey: mk,
      subgroup,
      value: toNumber(r["score"]),
      denom: toNumber(r["denom"]),
      suppressed: toNumber(r["score"]) === null,
    });
  }
  console.log(`[load] hits=${hits} skipped=${skipped} facts=${facts.length.toLocaleString()}`);

  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: dataLoad.id },
        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: dataLoad.id,
        },
      });
      if (f.suppressed) suppressed++;
    }
  }, 8);

  await finishDataLoad(dataLoad.id, { inserted: facts.length - suppressed, updated: 0, suppressed });
  console.log(`[load] done — ${facts.length.toLocaleString()} facts (${suppressed.toLocaleString()} suppressed)`);
}

main()
  .catch((e) => { console.error(e); process.exit(1); })
  .finally(() => prisma.$disconnect());