Source document

scripts/loaders/philly/futurereadypa_longformat_attendance.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 attendance from the OLDER Future Ready PA Index datafiles (2018-19,
// 2019-20, 2020-21), which use a LONG format — sheets "Schools A to L"/"Schools L to Z"
// (2018-19) or "Schools A - M"/"Schools M - Z" (2020-21), one row per
// (school, DataElement, DisplayValue) — instead of the wide <Measure>_<Subgroup>
// columns the 2021-22+ files use (scripts/loaders/philly/futurereadypa_performance.ts).
//
// Only the attendance element is loaded: "Percent Regular Attendance (<Subgroup>)"
// — PDE's earlier name for what the 2021-22+ files call
// PercentPersistentAttendance (students attending 90%+ of enrolled days).
// ASSUMPTION (documented on the methodology page): same definition under a
// renamed indicator; PDE's Future Ready documentation describes both as the
// share of students with 90%+ attendance. Stored under the same metric_key,
// attendance_persistence_rate. "IS" (insufficient sample) → suppressed.
//
// Run:  npx tsx scripts/loaders/philly/futurereadypa_longformat_attendance.ts futurereadypa_performance_2018-19 2018-19

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

const SOURCE_ID = process.argv[2];
const YEAR_COVERED = process.argv[3];
const SCRIPT_PATH = "scripts/loaders/philly/futurereadypa_longformat_attendance.ts";
if (!SOURCE_ID || !YEAR_COVERED || !SOURCE_ID.endsWith(YEAR_COVERED)) {
  throw new Error("usage: futurereadypa_longformat_attendance.ts <source_id ending in year> <YYYY-YY>");
}
const METRIC_KEY = "attendance_persistence_rate";
const ELEMENT_PREFIX = "Percent Regular Attendance";
// LAGGING indicator (PDE glossary): the workbook's report year minus one is
// the year the students were actually in school. See _lib.ts.
const OBS_YEAR = observationYear(METRIC_KEY, YEAR_COVERED);

const SUBGROUP_MAP: Record<string, PhillySubgroup> = {
  "All Student": "ALL",
  "American Indian/Alaska Native": "AMER_INDIAN_AK_NATIVE",
  Asian: "ASIAN",
  "Hawaiian/Pacific Islander": "HAWAIIAN_PAC_ISL",
  Black: "BLACK",
  Hispanic: "HISPANIC",
  White: "WHITE",
  "2 or More Races": "TWO_OR_MORE_RACES",
  "Economically Disadvantaged": "ECON_DISADV",
  "English Learner": "ELL",
  "Students with Disabilities": "IEP",
};
// "Insufficient Sample" is the 2020-21 workbook's spelling of "IS" (groups of
// fewer than 20 students, per PDE's reporting guidelines).
const SUPPRESS = new Set(["IS", "INSUFFICIENT SAMPLE", "--", "INS", "*", "", "N/A", "NA"]);

/** "Percent Regular Attendance  (English Learner)" → "English Learner" | null.
 *  Excludes "ESSA Goal ..." elements (targets, not measured values). */
function parseElement(el: string): string | null {
  const norm = el.replace(/\s+/g, " ").trim();
  if (!norm.startsWith(ELEMENT_PREFIX)) return null;
  const m = /\(([^)]+)\)\s*$/.exec(norm);
  return m ? m[1].trim() : null;
}

function toNumber(v: unknown): { value: number | null; suppressed: boolean } {
  if (v === null || v === undefined) return { value: null, suppressed: false };
  const s = String(v).trim().replace(/%$/, "");
  if (SUPPRESS.has(s.toUpperCase())) return { value: null, suppressed: s !== "" };
  const n = Number.parseFloat(s);
  return Number.isFinite(n) ? { value: n, suppressed: false } : { value: null, suppressed: true };
}

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] ${SOURCE_ID} (report year ${YEAR_COVERED} → stored as ${OBS_YEAR}) file: ${filePath}`);

  const phillySchools = await prisma.phillySchool.findMany({
    select: { ulcsCode: true, aunCode: true, paCode: true, name: true },
  });
  const pdeToUlcs = buildPdeToUlcs(phillySchools);

  console.log("[load] reading workbook (large long-format file; this takes a minute)…");
  const wb = XLSX.readFile(filePath, { cellDates: false, dense: true });
  const sheetNames = wb.SheetNames.filter((n) => /^Schools\b/i.test(n));
  console.log(`[load] school sheets: ${sheetNames.join(" | ")}`);

  const dataLoad = await startDataLoad({
    sourceId: SOURCE_ID,
    sourceUrl: prov.url,
    sha256: prov.sha256,
    bytes: prov.bytes,
    yearCovered: YEAR_COVERED,
    scriptPath: SCRIPT_PATH,
    notes: `long-format attendance only ('${ELEMENT_PREFIX} (<subgroup>)' → ${METRIC_KEY}); lagging indicator stored under ${OBS_YEAR}`,
  });

  type Fact = { schoolUlcs: string; subgroup: PhillySubgroup; value: number | null; suppressed: boolean };
  const facts = new Map<string, Fact>();
  let phillyRows = 0;
  const unknownSubgroup = new Set<string>();
  for (const name of sheetNames) {
    const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(wb.Sheets[name], { defval: null });
    console.log(`[load] sheet "${name}": ${rows.length} rows`);
    for (const r of rows) {
      const el = r["DataElement"];
      if (typeof el !== "string") continue;
      const sgLabel = parseElement(el);
      if (!sgLabel) continue;
      const aun = String(r["AUN"] ?? "").trim();
      const schl = String(r["Schl"] ?? "").trim();
      const ulcs = pdeToUlcs.get(`${aun}-${schl}`);
      if (!ulcs) continue;
      const sg = SUBGROUP_MAP[sgLabel];
      if (!sg) { unknownSubgroup.add(sgLabel); continue; }
      phillyRows++;
      const { value, suppressed } = toNumber(r["DisplayValue"]);
      facts.set(`${ulcs}|${sg}`, { schoolUlcs: ulcs, subgroup: sg, value, suppressed });
    }
  }
  if (unknownSubgroup.size) console.warn(`[load] unmapped subgroup labels: ${[...unknownSubgroup].join("; ")}`);
  console.log(`[load] Philly attendance rows: ${phillyRows}; distinct (school × subgroup) facts: ${facts.size}`);

  let suppressed = 0;
  await batched([...facts.values()], 200, async (batch) => {
    for (const f of batch) {
      await prisma.phillySchoolYearMetric.upsert({
        where: {
          schoolUlcs_year_metricKey_subgroup_populationCut: {
            schoolUlcs: f.schoolUlcs, year: OBS_YEAR, metricKey: METRIC_KEY,
            subgroup: f.subgroup, populationCut: "n/a",
          },
        },
        update: { value: f.value, suppressed: f.suppressed, sourceLoadId: dataLoad.id },
        create: {
          schoolUlcs: f.schoolUlcs, year: OBS_YEAR, metricKey: METRIC_KEY,
          subgroup: f.subgroup, populationCut: "n/a",
          value: f.value, suppressed: f.suppressed, sourceLoadId: dataLoad.id,
        },
      });
      if (f.suppressed) suppressed++;
    }
  }, 8);
  await finishDataLoad(dataLoad.id, { inserted: facts.size - suppressed, updated: 0, suppressed });
  console.log(`[load] done — ${facts.size} facts (${suppressed} suppressed)`);
}

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