Source document

scripts/loaders/philly/futurereadypa_performance.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 Future Ready PA Index Performance file → philly_school_year_metrics.
// Source: docs/cities/philly/sources.yaml entries `futurereadypa_performance_<year>`.
//
// The file is WIDE-FORMAT: each (measure × subgroup) is its own column.
// Schema sample (per findings.md F.2):
//   PercentProficientorAdvancedonELALiterature_AllStudent
//   PercentProficientorAdvancedonELALiterature_Black
//   MeetingAnnualAcademicGrowthExpectations_PVAASMathematicsAlgebra1_AllStudent
//   ...
// We unpivot these into one row per (school × year × metric_key × subgroup).
//
// Covers school-WIDE grain for all 302 Philly schools (district + charter +
// CTC + cyber + special-ed). For grade-by-grade detail of SDP-district +
// alt schools, see sdp_pssa_keystone.ts.
//
// Run:  npx tsx scripts/loaders/philly/futurereadypa_performance.ts [source_id] [year]

import * as XLSX from "xlsx";
import {
  prisma,
  startDataLoad,
  finishDataLoad,
  readLatestProvenance,
  readLatestSourcePath,
  batched,
  buildPdeToUlcs,
  observationYear,
} from "./_lib";
import type { PhillySubgroup } from "@prisma/client";

// Multi-year: `npx tsx scripts/loaders/philly/futurereadypa_performance.ts [source_id] [year]`
// e.g. futurereadypa_performance_2023-24 2023-24. Defaults keep the original
// 2024-25 behavior. Prior-year files share the wide <Measure>_<Subgroup>
// schema (verified from first-fetch findings before loading).
const SOURCE_ID = process.argv[2] ?? "futurereadypa_performance_2024-25";
const YEAR_COVERED = process.argv[3] ?? "2024-25";
if (!/^futurereadypa_performance_\d{4}-\d{2}$/.test(SOURCE_ID) || !/^\d{4}-\d{2}$/.test(YEAR_COVERED)) {
  throw new Error(`usage: futurereadypa_performance.ts <futurereadypa_performance_YYYY-YY> <YYYY-YY> (got ${SOURCE_ID} ${YEAR_COVERED})`);
}
if (!SOURCE_ID.endsWith(YEAR_COVERED)) {
  throw new Error(`source id ${SOURCE_ID} does not end with year ${YEAR_COVERED} — refusing to load mismatched year`);
}
const SCRIPT_PATH = "scripts/loaders/philly/futurereadypa_performance.ts";
const SHEETS_TO_READ = [
  "State Assessment Measures",   // PSSA all-grades + Keystone + PVAAS
  "School On Track Measures",    // chronic absenteeism + persistent attendance
  "College Career Measures",     // graduation rate (and v2: AP, dual enrollment, etc.)
];
// PDE suppresses groups of fewer than 20 students (reporting guidelines):
// "IS" / "Insufficient Sample" in the older workbooks. Any other non-numeric
// token is also stored as suppressed; the full QA censuses every token.
const SUPPRESS_VALUES = new Set(["IS", "INSUFFICIENT SAMPLE", "--", "INS", "*", ""]);

// Map Future Ready column-name suffixes (after underscore) to our PhillySubgroup enum.
const SUBGROUP_MAP: Record<string, PhillySubgroup> = {
  AllStudent: "ALL",
  AmericanIndianAlaskaNative: "AMER_INDIAN_AK_NATIVE",
  Asian: "ASIAN",
  HawaiianPacificIslander: "HAWAIIAN_PAC_ISL",
  Black: "BLACK",
  Hispanic: "HISPANIC",
  White: "WHITE",
  "2orMoreRaces": "TWO_OR_MORE_RACES",
  EconomicallyDisadvantaged: "ECON_DISADV",
  EnglishLearner: "ELL",
  StudentswithDisabilities: "IEP",
  CombinedEthnicity: "COMBINED_ETHNICITY",
};

// Map measure-name prefix (before _<subgroup>) to our metric_key.
// We deliberately only pull metrics we've registered in metric_definitions —
// other columns (ESSA goal, statewide average, annual progress flags) are
// metadata, not first-class metrics for the analysis engine.
const MEASURE_MAP: Record<string, string> = {
  // ── State Assessment Measures sheet ──
  PercentProficientorAdvancedonMathematicsAlgebra1: "keystone_algebra_proficiency",
  MeetingAnnualAcademicGrowthExpectations_PVAASMathematicsAlgebra1: "pvaas_growth_math_algebra1",
  PercentProficientorAdvancedonELALiterature: "keystone_literature_proficiency",
  MeetingAnnualAcademicGrowthExpectations_PVAASELALiterature: "pvaas_growth_ela_literature",
  PercentProficientorAdvancedonScienceBiology: "keystone_biology_proficiency",
  MeetingAnnualAcademicGrowthExpectations_PVAASScienceBiology: "pvaas_growth_science_biology",

  // ── School On Track Measures sheet ──
  // NOTE: PercentChronicAbsenteeism column is published in the file shape but
  // values are NULL statewide for the 2024-25 file (confirmed empirically).
  // PDE actually reports the metric as PercentPersistentAttendance (the
  // complement). For analytical comparability, we store the persistent rate
  // as-is under a separate metric_key; conversion to chronic_absent semantics
  // happens at display time if needed.
  // YEAR: this is a LAGGING indicator (PDE glossary) — the 2024-25 workbook's
  // value describes 2023-24 attendance. Stored under the observation year via
  // STATE_METRIC_YEAR_OFFSET (_lib.ts); every other measure keeps the report year.
  PercentPersistentAttendance: "attendance_persistence_rate",

  // ── College Career Measures sheet ──
  // Future Ready reports the most recent 4-year cohort rate at the school-year
  // grain (e.g. 2024-25 report = the cohort that graduated through 2023-24).
  // SDP's graduation loader writes the same metric_key but indexed by cohort
  // start year. The two don't conflict on (school, year, metric, subgroup) for
  // any actual data point. For v1 this is acceptable — analysis can filter to
  // whichever indexing convention matters. v2 will normalize.
  PercentGraduation4YearCohort: "graduation_rate_4yr",
};

type Row = Record<string, unknown>;

/** Parse a column name like "PercentProficientorAdvancedonELALiterature_Black"
 *  into a (measure_key, subgroup_suffix) pair, or null if it's not a
 *  recognized metric × subgroup column.
 *
 *  Note: some measure prefixes themselves contain an underscore (PVAAS ones
 *  like "MeetingAnnualAcademicGrowthExpectations_PVAASMathematicsAlgebra1"),
 *  so we can't just split on `_`. We try every registered measure prefix.
 */
function parseColumn(col: string): { measureKey: string; subgroup: PhillySubgroup } | null {
  for (const prefix of Object.keys(MEASURE_MAP)) {
    if (col.startsWith(prefix + "_")) {
      const suffix = col.slice(prefix.length + 1);
      const sg = SUBGROUP_MAP[suffix];
      if (sg) {
        return { measureKey: MEASURE_MAP[prefix], subgroup: sg };
      }
    }
  }
  return null;
}

function toNumber(v: unknown): number | null {
  if (v === null || v === undefined) return null;
  const s = String(v).trim();
  if (SUPPRESS_VALUES.has(s.toUpperCase())) return null;
  const n = Number.parseFloat(s);
  return Number.isFinite(n) ? n : null;
}

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: make philly-discover SOURCE=${SOURCE_ID}`,
    );
  }
  console.log(`[load] file: ${filePath}`);
  console.log(`[load] sha256: ${prov.sha256.slice(0, 12)}... (${prov.bytes.toLocaleString()} bytes)`);

  // Pull the universe of Philly ULCS codes + AUN+PA mapping (loaded by P0)
  const phillySchools = await prisma.phillySchool.findMany({
    select: { ulcsCode: true, aunCode: true, paCode: true, name: true },
  });
  console.log(`[load] philly_schools universe: ${phillySchools.length}`);
  // PDE-side composite key → ULCS code
  const pdeToUlcs = buildPdeToUlcs(phillySchools);

  const wb = XLSX.readFile(filePath, { cellDates: false });

  const dataLoad = await startDataLoad({
    sourceId: SOURCE_ID,
    sourceUrl: prov.url,
    sha256: prov.sha256,
    bytes: prov.bytes,
    yearCovered: YEAR_COVERED,
    scriptPath: SCRIPT_PATH,
  });

  // Accumulate facts (school × metric × subgroup → value) before bulk upsert.
  type Fact = {
    schoolUlcs: string;
    metricKey: string;
    year: string; // observation year (report year for most measures; see observationYear)
    subgroup: PhillySubgroup;
    value: number | null;
    suppressed: boolean;
  };
  const facts: Fact[] = [];
  let phillyHits = 0;
  let nonPhillySkipped = 0;
  let unmatchedAunSchl = 0;

  for (const sheetName of SHEETS_TO_READ) {
    const sheet = wb.Sheets[sheetName];
    if (!sheet) {
      console.warn(`[load] missing sheet: ${sheetName}`);
      continue;
    }
    const rows = XLSX.utils.sheet_to_json<Row>(sheet, { defval: null });
    console.log(`[load] sheet "${sheetName}": ${rows.length} rows`);

    // Discover the metric columns once
    const cols = Object.keys(rows[0] ?? {});
    const metricCols: { col: string; measureKey: string; subgroup: PhillySubgroup }[] = [];
    for (const c of cols) {
      const p = parseColumn(c);
      if (p) metricCols.push({ col: c, measureKey: p.measureKey, subgroup: p.subgroup });
    }
    console.log(`[load]   ${metricCols.length} recognized metric × subgroup columns`);

    for (const r of rows) {
      const aun = String(r["AUN"] ?? "").trim();
      const schl = String(r["Schl"] ?? "").trim();
      if (!aun || !schl) continue;
      const ulcs = pdeToUlcs.get(`${aun}-${schl}`);
      if (!ulcs) {
        // Not in our Philly universe; statewide file contains every PA school.
        if (aun.startsWith("1265")) unmatchedAunSchl++;  // SDP AUN but not in our directory
        else nonPhillySkipped++;
        continue;
      }
      phillyHits++;

      for (const mc of metricCols) {
        const raw = r[mc.col];
        const v = toNumber(raw);
        const suppressed = v === null && raw !== null && raw !== undefined && String(raw).trim() !== "";
        facts.push({
          schoolUlcs: ulcs,
          metricKey: mc.measureKey,
          year: observationYear(mc.measureKey, YEAR_COVERED),
          subgroup: mc.subgroup,
          value: v,
          suppressed,
        });
      }
    }
  }

  console.log(`[load] phillyHits=${phillyHits} nonPhillySkipped=${nonPhillySkipped} unmatchedSdpRow=${unmatchedAunSchl}`);
  console.log(`[load] total facts to upsert: ${facts.length.toLocaleString()}`);

  let suppressed = 0;
  await batched(facts, 200, async (batch) => {
    for (const f of batch) {
      try {
        const result = 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",  // sentinel — Future Ready doesn't ship Acct/Actual cuts
            },
          },
          update: {
            value: f.value,
            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,
            suppressed: f.suppressed,
            sourceLoadId: dataLoad.id,
          },
        });
        // Count each suppressed fact ONCE (an earlier version double-counted,
        // which produced negative rows_inserted in philly_data_loads).
        if (f.suppressed || (result.value === null && result.suppressed)) suppressed++;
      } catch (e) {
        // FK errors usually mean a metric_key isn't registered — log & continue
        if (e instanceof Error && e.message.includes("Foreign key constraint")) {
          console.warn(`[load] FK fail: ${f.metricKey} → metric_definitions missing? Skipping.`);
        } else {
          throw e;
        }
      }
    }
  }, 8);

  // The counts above are loose (`inserted` = touched). Be honest about that.
  await finishDataLoad(dataLoad.id, {
    inserted: facts.length - suppressed,
    updated: 0,
    suppressed,
  });
  console.log(`[load] done — ${facts.length.toLocaleString()} facts upserted (${suppressed.toLocaleString()} suppressed)`);
}

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