Source document

scripts/loaders/miami/sesir.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 FLDOE SESIR (School Environmental Safety Incident Report) school-level
// counts → incident rates per 100 students: total, violence, bullying,
// weapons, drugs. Category membership is by incident-type name (listed in
// the load notes); every type counts toward the total. The file also carries
// a "SCHOOL TOTAL" row per school; it is excluded from the sum (the
// 2026-09-16 review found it doubled the total) and used as a cross-check —
// the load fails if the summed types disagree with it for any school.
// Denominator: the same year's October enrollment.
// Run:  npx tsx scripts/loaders/miami/sesir.ts

import fs from "node:fs";
import { prisma, requireSource, startDataLoad, finishDataLoad, padSchoolNumber, parseCell, schoolUniverse, upsertFacts, sheetRows, runMain, SOURCES_ROOT, type Fact } from "./_lib";

const SCRIPT_PATH = "scripts/loaders/miami/sesir.ts";
const CATS: Record<string, RegExp> = {
  // SEXUAL HARASSMENT is a harassment incident (bullying category), not a violent one; only sexual assault, battery and
  // other sexual offenses count as violence (review 2026-09-16b: the bare SEXUAL pattern put 68 harassment incidents in both).
  sesir_violence_per_100: /BATTERY|FIGHTING|HOMICIDE|KIDNAPPING|PHYSICAL ATTACK|ROBBERY|SEXUAL (ASSAULT|BATTERY|OFFENSE)|THREAT/,
  sesir_bullying_per_100: /BULLYING|HARASSMENT|HAZING/,
  sesir_weapons_per_100: /WEAPON/,
  sesir_drugs_per_100: /ALCOHOL|DRUG|TOBACCO/,
};

async function main() {
  const dirs = fs.readdirSync(SOURCES_ROOT).filter((d) => /^fldoe_sesir_school_\d{4}-\d{2}$/.test(d)).sort();
  const universe = await schoolUniverse();
  for (const sourceId of dirs) {
    const year = sourceId.slice(-7);
    const { filePath, prov } = requireSource(sourceId);
    const { rows } = sheetRows(filePath, "Schools");
    const h = rows.findIndex((r) => String(r?.[0] ?? "").trim().toLowerCase() === "district #");
    if (h < 0) throw new Error(`${sourceId}: header not found`);
    const enr = new Map((await prisma.miamiSchoolYearMetric.findMany({ where: { metricKey: "enrollment_oct", year, subgroup: "ALL", suppressed: false }, select: { schoolNumber: true, value: true } })).map((r) => [r.schoolNumber, r.value ?? 0]));
    const counts = new Map<string, Record<string, number>>();
    const published = new Map<string, number>();   // the file's own SCHOOL TOTAL row
    const types = new Set<string>();
    let dade = 0;
    for (const r of rows.slice(h + 2)) { // row h+1 is the second header line
      if (String(r[0] ?? "").trim().replace(/\.0$/, "") !== "13") continue;
      dade++;
      const num = padSchoolNumber(r[2]);
      if (!num || !universe.has(num)) continue;
      const type = String(r[4] ?? "").trim().toUpperCase();
      const total = parseCell(r[5]).value ?? 0;
      if (/^SCHOOL TOTAL$/.test(type)) { published.set(num, total); continue; }
      types.add(type);
      const c = counts.get(num) ?? { sesir_total_per_100: 0, sesir_violence_per_100: 0, sesir_bullying_per_100: 0, sesir_weapons_per_100: 0, sesir_drugs_per_100: 0 };
      c.sesir_total_per_100 += total;
      for (const [k, re] of Object.entries(CATS)) if (re.test(type)) c[k] += total;
      counts.set(num, c);
    }
    // Cross-check: the summed incident types must equal the file's SCHOOL TOTAL row.
    const disagree = [...counts].filter(([num, c]) => published.has(num) && Math.abs(c.sesir_total_per_100 - published.get(num)!) > 0.5).map(([num, c]) => `${num}: types ${c.sesir_total_per_100} vs total ${published.get(num)}`);
    if (disagree.length) throw new Error(`${sourceId}: summed incident types disagree with the SCHOOL TOTAL row for ${disagree.length} schools (e.g. ${disagree.slice(0, 5).join("; ")})`);
    const facts: Fact[] = [];
    for (const [num, c] of counts) {
      const e = enr.get(num);
      if (!e) continue;
      for (const [k, v] of Object.entries(c)) facts.push({ schoolNumber: num, year, metricKey: k, value: (v / e) * 100, denominator: e });
    }
    const load = await startDataLoad({ sourceId, sourceUrl: prov.url, sha256: prov.sha256, bytes: prov.bytes, yearCovered: year, scriptPath: SCRIPT_PATH,
      notes: `Dade rows ${dade}; SCHOOL TOTAL rows ${published.size} (excluded from the sum; summed types agree for every school); incident types: ${[...types].sort().join(" | ")}; categories: ${Object.entries(CATS).map(([k, re]) => `${k}=${re.source}`).join("; ")}` });
    const res = await upsertFacts(facts, load.id);
    await finishDataLoad(load.id, { inserted: res.written, updated: 0, suppressed: 0 });
    console.log(`[sesir ${year}] ${res.written} facts for ${counts.size} schools; ${types.size} incident types`);
  }
}

runMain(main);