From 1e5c66d6ab7664a417e57fc798e5efbd6e3630ad Mon Sep 17 00:00:00 2001 From: Tudor Sitaru Date: Wed, 8 Apr 2026 11:29:40 +0100 Subject: [PATCH] fix(admissions): correct first_preference_offer_pct in dbt staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging model was mapping EES column ``proportion_1stprefs_v_totaloffers`` straight onto ``first_preference_offer_pct``. That raw column is not a percentage — it is a ratio of first-preference applications to total offers (an oversubscription indicator, >1 means oversubscribed), so OLQH rendered as "1%" when the true first-choice success rate is 27/42 = 64%. The frontend display code is not at fault and is not patched here — data-quality issues must be fixed at the source. - stg_ees_admissions: compute ``first_preference_offer_pct`` as ``100 * number_1st_preference_offers / times_put_as_1st_preference`` — of families who listed this school first, the % that received an offer (0–100). Guard against divide-by-zero. - stg_ees_admissions: expose the legitimate EES ratio as the new column ``oversubscription_ratio`` (1st-preference applications per place) for future use, clearly named. - fact_admissions, FactAdmissions model, data_loader: propagate the new ``oversubscription_ratio`` column. - SchoolAdmissions type: document both columns inline. - buildSchoolSummary: reword the oversubscription clause so it reads sensibly across the whole 0–100 range (no more "just 64%"). - Hero chip subtitle: clearer phrasing "X% of first-choice applicants offered a place". Requires a dbt run of stg_ees_admissions and fact_admissions on deploy so the new column materialises. Co-Authored-By: Claude Opus 4.6 --- backend/data_loader.py | 1 + backend/models.py | 1 + nextjs-app/components/SchoolDetailView.tsx | 2 +- nextjs-app/lib/types.ts | 3 +++ nextjs-app/lib/utils.ts | 5 +++-- .../models/marts/fact_admissions.sql | 1 + .../models/staging/stg_ees_admissions.sql | 20 ++++++++++++++++++- 7 files changed, 29 insertions(+), 4 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index 1cfbbc1..ddfac01 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -414,6 +414,7 @@ def get_supplementary_data(db: Session, urn: int) -> dict: "first_preference_applications": a.first_preference_applications, "first_preference_offers": a.first_preference_offers, "first_preference_offer_pct": a.first_preference_offer_pct, + "oversubscription_ratio": a.oversubscription_ratio, "oversubscribed": a.oversubscribed, } if a diff --git a/backend/models.py b/backend/models.py index 57f1586..c92999d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -184,6 +184,7 @@ class FactAdmissions(Base): first_preference_applications = Column(Integer) first_preference_offers = Column(Integer) first_preference_offer_pct = Column(Float) + oversubscription_ratio = Column(Float) oversubscribed = Column(Boolean) admissions_policy = Column(String(100)) diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 18d19d3..6dc8932 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -226,7 +226,7 @@ export function SchoolDetailView({
Oversubscribed
{admissions.first_preference_offer_pct != null - ? `${Math.round(admissions.first_preference_offer_pct)}% got first choice` + ? `${Math.round(admissions.first_preference_offer_pct)}% of first-choice applicants offered a place` : 'More applicants than places'}
diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index a3ba806..7ead4bf 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -134,7 +134,10 @@ export interface SchoolAdmissions { total_applications: number | null; first_preference_applications?: number | null; first_preference_offers?: number | null; + /** Of families who listed this school as 1st preference, the % that received an offer (0–100). */ first_preference_offer_pct: number | null; + /** 1st-preference applications per place offered (>1 means oversubscribed). */ + oversubscription_ratio?: number | null; oversubscribed: boolean | null; } diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 2291399..8309100 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -557,11 +557,12 @@ export function buildSchoolSummary( // Admissions clause if (admissions?.oversubscribed) { if (admissions.first_preference_offer_pct != null) { + const pct = Math.round(admissions.first_preference_offer_pct); parts.push( - `heavily oversubscribed — just ${Math.round(admissions.first_preference_offer_pct)}% of applicants get a first-choice offer`, + `oversubscribed — ${pct}% of first-choice applicants are offered a place`, ); } else { - parts.push('heavily oversubscribed'); + parts.push('oversubscribed'); } } else if (admissions?.first_preference_offer_pct != null && admissions.first_preference_offer_pct >= 90) { parts.push('most families get their first-choice offer'); diff --git a/pipeline/transform/models/marts/fact_admissions.sql b/pipeline/transform/models/marts/fact_admissions.sql index 9ca5e84..36f18b2 100644 --- a/pipeline/transform/models/marts/fact_admissions.sql +++ b/pipeline/transform/models/marts/fact_admissions.sql @@ -9,6 +9,7 @@ select first_preference_applications, first_preference_offers, first_preference_offer_pct, + oversubscription_ratio, oversubscribed, admissions_policy from {{ ref('stg_ees_admissions') }} diff --git a/pipeline/transform/models/staging/stg_ees_admissions.sql b/pipeline/transform/models/staging/stg_ees_admissions.sql index 3ca26aa..060604a 100644 --- a/pipeline/transform/models/staging/stg_ees_admissions.sql +++ b/pipeline/transform/models/staging/stg_ees_admissions.sql @@ -29,7 +29,25 @@ renamed as ( {{ safe_numeric('times_put_as_1st_preference') }}::integer as first_preference_applications, -- Proportions - {{ safe_numeric('proportion_1stprefs_v_totaloffers') }} as first_preference_offer_pct, + -- first_preference_offer_pct: of families who listed this school FIRST, + -- the percentage that received an offer. 0–100 scale. + -- The raw EES column `proportion_1stprefs_v_totaloffers` stores a + -- different, misleading figure (first-preference applications divided + -- by total offers, i.e. an oversubscription ratio) — do not use it. + case + when {{ safe_numeric('times_put_as_1st_preference') }} > 0 + then 100.0 + * {{ safe_numeric('number_1st_preference_offers') }} + / {{ safe_numeric('times_put_as_1st_preference') }} + end as first_preference_offer_pct, + + -- Oversubscription ratio: 1st-preference applications per place offered. + -- >1 means the school is oversubscribed on first preferences. + case + when {{ safe_numeric('total_number_places_offered') }} > 0 + then {{ safe_numeric('times_put_as_1st_preference') }}::numeric + / {{ safe_numeric('total_number_places_offered') }} + end as oversubscription_ratio, -- Derived: oversubscribed if 1st-preference applications > places offered -- Use already-cast columns to avoid repeating the regex expression