// Compute comparison_group_percentile + citywide_percentile for every // (school × year × metric × subgroup × population_cut) row in // philly_school_year_metrics. // // citywide_percentile: rank within all default-included Philly schools for the // same (metric, year, subgroup, population_cut), expressed as a fraction in // [0, 1]. // comparison_group_percentile: rank within the school's stored peer group // (from philly_comparison_groups.methodology_notes JSON, k=DEFAULT_K), same // (year, metric, subgroup, population_cut). Schools without a peer group // get null. // // For metrics where lower is better (e.g., OSS rates), we invert so a high // percentile always means "better outcomes." // // Mirrors scripts/loaders/compute-percentiles.ts (NYC). Same midrank // algorithm + same minimum-5-peers floor for peer percentiles. // // Run: npx tsx scripts/loaders/philly/compute_percentiles.ts import path from "node:path"; import { config as loadEnv } from "dotenv"; loadEnv({ path: path.join(process.cwd(), ".env.local") }); import pg from "pg"; const directUrl = (process.env.DIRECT_URL ?? process.env.POSTGRES_URL_NON_POOLING ?? process.env.DATABASE_URL ?? process.env.POSTGRES_URL)!; let pool: pg.Pool; function makePool() { const u = new URL(directUrl); // Honor `?sslmode=disable` (local/dev Postgres has no TLS); otherwise keep // the permissive TLS Supabase needs. const ssl = u.searchParams.get("sslmode") === "disable" ? false : ({ rejectUnauthorized: false } as const); return new pg.Pool({ user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), host: u.hostname, port: parseInt(u.port || "5432", 10), database: u.pathname.slice(1), ssl, max: 2, connectionTimeoutMillis: 15_000, idleTimeoutMillis: 10_000, statement_timeout: 60_000, }); } pool = makePool(); async function query(sql: string, params?: unknown[]): Promise { let attempts = 0; while (true) { try { const r = await pool.query(sql, params); return r.rows as T[]; } catch (e) { attempts++; if (attempts >= 4) throw e; const msg = (e as Error).message; console.warn(` query err (att ${attempts}): ${msg.slice(0, 80)} — reconnecting`); try { await pool.end(); } catch { /* ignore */ } pool = makePool(); await new Promise((r) => setTimeout(r, 2000 * attempts)); } } } function rank(items: T[], valueOf: (t: T) => number): Map { const sorted = items .map((t, i) => ({ t, v: valueOf(t), i })) .sort((a, b) => a.v - b.v); const n = sorted.length; const out = new Map(); let i = 0; while (i < n) { let j = i; while (j + 1 < n && sorted[j + 1].v === sorted[i].v) j++; const rankMid = (i + j) / 2 + 1; // 1-based midrank const pct = n === 1 ? 0.5 : (rankMid - 1) / (n - 1); for (let k = i; k <= j; k++) out.set(sorted[k].t, pct); i = j + 1; } return out; } async function main() { // Get each school's peer group (default k linked into philly_schools). const schoolRows = await query<{ ulcs_code: string; methodology_notes: { peers: string[] } | null; include_in_default_comparisons: boolean; }>( `SELECT s.ulcs_code, cg.methodology_notes::jsonb AS methodology_notes, s.include_in_default_comparisons FROM philly_schools s LEFT JOIN philly_comparison_groups cg ON cg.id = s.comparison_group_id`, ); const peerMap = new Map>(); const includedSchools = new Set(); for (const s of schoolRows) { if (s.include_in_default_comparisons) includedSchools.add(s.ulcs_code); if (s.methodology_notes && Array.isArray(s.methodology_notes.peers)) { peerMap.set(s.ulcs_code, new Set([s.ulcs_code, ...s.methodology_notes.peers])); } } console.log(`Schools with peer groups: ${peerMap.size}`); console.log(`Schools in default comparisons: ${includedSchools.size}`); const combos = await query<{ metric_key: string; year: string; subgroup: string; population_cut: string }>( `SELECT DISTINCT metric_key, year, subgroup::text AS subgroup, population_cut FROM philly_school_year_metrics WHERE value IS NOT NULL AND suppressed = false`, ); console.log(`(metric, year, subgroup, population_cut) combos: ${combos.length}`); const metricsList = await query<{ key: string; direction: string }>( `SELECT key, direction FROM philly_metric_definitions`, ); const directionByKey = new Map(metricsList.map((m) => [m.key, m.direction])); let updated = 0; let comboIdx = 0; for (const { metric_key, year, subgroup, population_cut } of combos) { comboIdx++; const rows = await query<{ id: string; school_ulcs: string; value: number }>( `SELECT id, school_ulcs, value::float8 AS value FROM philly_school_year_metrics WHERE metric_key = $1 AND year = $2 AND subgroup::text = $3 AND population_cut = $4 AND value IS NOT NULL AND suppressed = false`, [metric_key, year, subgroup, population_cut], ); if (rows.length === 0) continue; const direction = directionByKey.get(metric_key) ?? "HIGHER_BETTER"; const sign = direction === "LOWER_BETTER" ? -1 : 1; // Citywide rank (only default-included schools) const citywideRows = rows.filter((r) => includedSchools.has(r.school_ulcs)); const cityRanks = rank(citywideRows, (r) => sign * r.value); const byUlcs = new Map(rows.map((r) => [r.school_ulcs, r])); const updatesById: Array<{ id: string; cg: number | null; city: number | null }> = []; for (const r of rows) { const peers = peerMap.get(r.school_ulcs); let cg: number | null = null; if (peers) { const inPeers = [...peers] .map((u) => byUlcs.get(u)) .filter((x): x is { id: string; school_ulcs: string; value: number } => !!x); if (inPeers.length >= 5) { const ranks = rank(inPeers, (x) => sign * x.value); const me = inPeers.find((p) => p.school_ulcs === r.school_ulcs); if (me) cg = ranks.get(me) ?? null; } } const city = cityRanks.get(r) ?? null; updatesById.push({ id: r.id, cg, city }); } const batchSize = 300; for (let i = 0; i < updatesById.length; i += batchSize) { const batch = updatesById.slice(i, i + batchSize); const ids = batch.map((u) => u.id); const cgCases = batch .map((u) => `WHEN '${u.id}' THEN ${u.cg === null ? "NULL" : `${u.cg}::float8`}`) .join(" "); const cityCases = batch .map((u) => `WHEN '${u.id}' THEN ${u.city === null ? "NULL" : `${u.city}::float8`}`) .join(" "); const sql = ` UPDATE philly_school_year_metrics SET comparison_group_percentile = (CASE id ${cgCases} END)::float8, citywide_percentile = (CASE id ${cityCases} END)::float8 WHERE id = ANY($1::text[]) `; await query(sql, [ids]); updated += batch.length; } if (comboIdx % 100 === 0) { console.log(` ${comboIdx}/${combos.length} combos done, ${updated.toLocaleString()} rows updated`); } } console.log(`Updated percentiles on ${updated.toLocaleString()} rows`); await pool.end(); } main().catch((e) => { console.error(e); process.exit(1); });