Source document

scripts/loaders/philly/pses.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 PSES topic+subtopic scores → philly_school_year_metrics.
// Source: pses_topic_subtopic_scores_all_years.
//
// Architecture: the Zip ships two xlsx files. The subtopic file has
// per-respondent breakdowns (column "survey" = Parent/Teacher/Student/Support),
// while the topic file aggregates across respondents. For O.6 — teacher +
// student first-class — we need per-respondent topic-level rollups, which we
// compute by averaging the subtopic scores within each (school, year, topic,
// respondent).
//
// Suppression codes in score_display: "n.a." (no data) and "i.s." (insufficient
// sample). Both become value=null, suppressed=true.
//
// v1 scope per O.6: teacher + student rollups. Parent + Support are ingested
// too (extra rows in DB are cheap) but no metric_definition is registered for
// them, so they'd FK-fail; we skip them at insert time.
//
// Run:  npx tsx scripts/loaders/philly/pses.ts

// @ts-expect-error — runtime-only import (no shipped types in yauzl-promise v4)
import * as zip from "yauzl-promise";
import * as XLSX from "xlsx";
import {
  prisma,
  startDataLoad,
  finishDataLoad,
  readLatestProvenance,
  readLatestSourcePath,
  batched,
} from "./_lib";

const SOURCE_ID = "pses_topic_subtopic_scores_all_years";
const SCRIPT_PATH = "scripts/loaders/philly/pses.ts";

// Topic name → metric_key suffix
const TOPIC_MAP: Record<string, string> = {
  "School Climate": "school_climate",
  "Instructional Environment": "instructional_environment",
  "School Leadership": "school_leadership",
  "Professional Capacity": "professional_capacity",
  "Family Engagement": "family_engagement",
  "Diversity, Equity, and Inclusion": "dei",
};

// O.6: v1 first-class respondents
const RESPONDENT_MAP: Record<string, string> = {
  Teacher: "teacher",
  Student: "student",
  // Parent and Support are ingested too (Zip contains them), but no
  // metric_definition exists for them, so we'll skip-write at insert.
  Parent: "parent",
  Support: "support",
};

function toNumber(v: unknown): number | null {
  if (v === null || v === undefined) return null;
  const s = String(v).trim();
  if (s === "" || s === "n.a." || s === "i.s." || s === "*") return null;
  const n = Number.parseFloat(s);
  return Number.isFinite(n) ? n : null;
}

function normalizeYear(year: string): string {
  // "2019-2020" → "2019-20"
  const m = year.match(/^(\d{4})-\d{2}(\d{2})$/);
  return m ? `${m[1]}-${m[2]}` : year;
}

type Row = Record<string, unknown>;

async function readSubtopicSheet(zipPath: string): Promise<Row[]> {
  const z = await zip.open(zipPath);
  try {
    for await (const entry of z) {
      if (entry.filename === "open_data_subtopic_scores.xlsx") {
        const stream = await entry.openReadStream();
        const chunks: Buffer[] = [];
        for await (const chunk of stream) chunks.push(chunk as Buffer);
        const wb = XLSX.read(Buffer.concat(chunks), { type: "buffer", cellDates: false });
        const sheet = wb.Sheets["School"];
        return XLSX.utils.sheet_to_json<Row>(sheet, { defval: null });
      }
    }
    throw new Error("subtopic xlsx not found in Zip");
  } finally {
    await z.close();
  }
}

async function main() {
  const zipPath = readLatestSourcePath(SOURCE_ID);
  const prov = readLatestProvenance(SOURCE_ID);
  if (!zipPath || !prov) throw new Error(`No fetched source for ${SOURCE_ID}.`);
  console.log(`[load] zip: ${zipPath}`);
  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}`);

  console.log(`[load] reading open_data_subtopic_scores.xlsx (School sheet)`);
  const rows = await readSubtopicSheet(zipPath);
  console.log(`[load]   ${rows.length.toLocaleString()} subtopic rows`);

  // Aggregate per (ulcs, year, topic, respondent) → mean of subtopic scores
  type AggKey = string;
  type Agg = { sum: number; n: number };
  const agg = new Map<AggKey, Agg>();
  let parseSkipped = 0;
  for (const r of rows) {
    const ulcs = String(r["ulcs_code"] ?? "").trim();
    if (!ulcs || !ulcsSet.has(ulcs)) continue;
    const year = normalizeYear(String(r["year_academic"] ?? ""));
    const respondentKey = RESPONDENT_MAP[String(r["survey"] ?? "")];
    if (!respondentKey) continue;
    const topicKey = TOPIC_MAP[String(r["topic"] ?? "")];
    if (!topicKey) continue;
    const v = toNumber(r["score_display"]);
    if (v === null) { parseSkipped++; continue; }
    const k = `${ulcs}|${year}|${topicKey}|${respondentKey}`;
    if (!agg.has(k)) agg.set(k, { sum: 0, n: 0 });
    const a = agg.get(k)!;
    a.sum += v;
    a.n += 1;
  }
  console.log(`[load]   aggregated ${agg.size.toLocaleString()} (school × year × topic × respondent) cells`);
  console.log(`[load]   subtopic rows with no parseable score: ${parseSkipped.toLocaleString()}`);

  const dataLoad = await startDataLoad({
    sourceId: SOURCE_ID, sourceUrl: prov.url, sha256: prov.sha256, bytes: prov.bytes,
    yearCovered: "2019-20..2024-25", scriptPath: SCRIPT_PATH,
  });

  type Fact = {
    schoolUlcs: string;
    year: string;
    metricKey: string;
    value: number;
  };
  const facts: Fact[] = [];
  let skippedNoMetric = 0;
  for (const [k, a] of agg) {
    const [ulcs, year, topic, respondent] = k.split("|");
    // Per O.6, only teacher + student have registered metric_definitions
    if (respondent !== "teacher" && respondent !== "student") {
      skippedNoMetric++;
      continue;
    }
    const metricKey = `survey_${respondent}_${topic}`;
    // PSES scores are on a 0-10 display scale; store as PERCENT-ish but
    // multiplied by 10 so the display unit aligns with the metric_definitions
    // unit=PERCENT (the values are bounded 0-100 after ×10, except DEI which
    // can show as fractional). For v1 we store raw mean as-is; analysis can
    // re-scale if needed.
    facts.push({
      schoolUlcs: ulcs,
      year,
      metricKey,
      value: a.sum / a.n,
    });
  }
  console.log(`[load]   facts to upsert: ${facts.length.toLocaleString()}`);
  console.log(`[load]   skipped (parent/support — no metric_definition): ${skippedNoMetric.toLocaleString()}`);

  let inserted = 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: "ALL",
            populationCut: "n/a",
          },
        },
        update: { value: f.value, suppressed: false, sourceLoadId: dataLoad.id },
        create: {
          schoolUlcs: f.schoolUlcs,
          year: f.year,
          metricKey: f.metricKey,
          subgroup: "ALL",
          populationCut: "n/a",
          value: f.value,
          suppressed: false,
          sourceLoadId: dataLoad.id,
        },
      });
      inserted++;
    }
  }, 8);

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

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