diff --git a/backend/app.py b/backend/app.py index de856b2..4ac9986 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1273,17 +1273,63 @@ async def get_filter_options(request: Request): } +def _la_averages_payload(df: pd.DataFrame) -> dict: + """Per-LA Attainment 8 for the "vs LA avg" comparison: DfE's own LA + averages (fact_ks4_la_averages, all state-funded schools), never a mean of + the dataframe. That mean counted independent and special schools and put + most LAs about 7 points low (audit H2). + + The year is the latest with any school Attainment 8, so the average and + the scores set against it are the same year. Figures are keyed by our LA + name through the LA code. An LA without a DfE figure for that year (City of + London) is absent; no figures for the year, or no mart, give an empty map, + so rows show no comparison, never another year's figure. + """ + empty = {"year": 0, "secondary": {"attainment_8_by_la": {}}} + if df.empty or "attainment_8_score" not in df.columns: + return empty + scored = df[df["attainment_8_score"].notna()] + if scored.empty: + return empty + year = int(scored["year"].max()) + + la = (df[["local_authority_code", "local_authority"]] + .dropna() + .drop_duplicates("local_authority_code")) + name_by_code = {int(code): name for code, name in + zip(la["local_authority_code"], la["local_authority"])} + + from . import database + from .models import Ks4LaAverage + + rows: list = [] + db = None + try: + db = database.SessionLocal() + rows = db.query(Ks4LaAverage).filter(Ks4LaAverage.year == year).all() + except Exception: + import logging + logging.getLogger(__name__).warning( + "DfE LA averages unavailable for %s", year, exc_info=True) + if db is not None: + db.rollback() + finally: + if db is not None: + db.close() + + by_la = { + name_by_code[row.la_code]: row.attainment_8_score + for row in rows + if row.attainment_8_score is not None and row.la_code in name_by_code + } + return {"year": year, "secondary": {"attainment_8_by_la": by_la}} + + @app.get("/api/la-averages") @limiter.limit(f"{settings.rate_limit_per_minute}/minute") async def get_la_averages(request: Request): - """Get per-LA average Attainment 8 score for secondary schools in the latest year.""" - df = load_school_data() - if df.empty: - return {"year": 0, "secondary": {"attainment_8_by_la": {}}} - latest_year = int(df["year"].max()) - sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()] - la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict() - return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}} + """DfE's per-LA Attainment 8 averages for the latest year with results.""" + return _la_averages_payload(load_school_data()) _KS2_NATIONAL_METRICS = [ diff --git a/backend/models.py b/backend/models.py index 2fb6f74..b30c5e6 100644 --- a/backend/models.py +++ b/backend/models.py @@ -344,6 +344,18 @@ class Ks4NationalAverage(Base): gcse_grade_91_pct = Column(Float) +class Ks4LaAverage(Base): + """Official DfE KS4 local-authority averages (all state-funded schools) — + one row per academic year and LA. la_code is the GIAS LA code.""" + __tablename__ = "fact_ks4_la_averages" + __table_args__ = MARTS + + year = Column(Integer, primary_key=True) + la_code = Column(Integer, primary_key=True) + la_name = Column(String) + attainment_8_score = Column(Float) + + class Ks2NationalAverage(Base): """Official DfE KS2 national headline averages — one row per academic year.""" __tablename__ = "fact_ks2_national_averages" diff --git a/backend/tests/test_la_averages_marts.py b/backend/tests/test_la_averages_marts.py new file mode 100644 index 0000000..115a83f --- /dev/null +++ b/backend/tests/test_la_averages_marts.py @@ -0,0 +1,119 @@ +"""/api/la-averages serves DfE's own LA averages (fact_ks4_la_averages). + +It used to average the dataframe: every school with an Attainment 8, +independent and special schools included. Kensington and Chelsea came out at +35.2 against DfE's 54.5, and most LAs about 7 points low (audit H2). +""" + +import numpy as np +import pandas as pd +import pytest +from fastapi.testclient import TestClient + +LATEST = 202425 + + +def _df(): + return pd.DataFrame([ + # A state school and an independent: their mean, 37.65, is not DfE's figure. + dict(year=LATEST, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=54.9), + dict(year=LATEST, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=20.4), + dict(year=LATEST, local_authority="Bristol, City of", local_authority_code=801, attainment_8_score=45.0), + dict(year=LATEST, local_authority="West Sussex", local_authority_code=938, attainment_8_score=48.0), + # DfE publishes no LA figure for City of London. + dict(year=LATEST, local_authority="City of London", local_authority_code=201, attainment_8_score=30.0), + # A newer year with primary results only. + dict(year=202526, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=np.nan), + ]) + + +class _Row: + def __init__(self, year, la_code, la_name, attainment_8_score): + self.year = year + self.la_code = la_code + self.la_name = la_name + self.attainment_8_score = attainment_8_score + + +class _StubSession: + rows = [ + _Row(LATEST, 207, "Kensington and Chelsea", 54.5), + _Row(LATEST, 801, "Bristol City", 46.3), # DfE's spelling, not ours + _Row(LATEST, 938, "West Sussex", None), # suppressed + _Row(LATEST, 330, "Birmingham", 44.0), # no school of ours there + _Row(202324, 207, "Kensington and Chelsea", 54.5), + ] + + def query(self, model): + assert model.__name__ == "Ks4LaAverage" + return self + + def filter(self, condition): + # The payload filters on year == ; apply it as Postgres would. + self._year = condition.right.value + return self + + def all(self): + return [r for r in self.rows if r.year == self._year] + + def rollback(self): + pass + + def close(self): + pass + + +class _OldYearOnly(_StubSession): + rows = [_Row(202324, 207, "Kensington and Chelsea", 54.5)] + + +class _NoMart(_StubSession): + def all(self): + raise RuntimeError('relation "marts.fact_ks4_la_averages" does not exist') + + +@pytest.fixture() +def payload(monkeypatch): + from backend import app as app_module + from backend import database as database_module + + def _run(session_cls): + monkeypatch.setattr(database_module, "SessionLocal", session_cls) + return app_module._la_averages_payload(_df()) + + return _run + + +def test_serves_dfe_figures_keyed_by_our_la_names(payload): + out = payload(_StubSession) + assert out["year"] == LATEST + assert out["secondary"]["attainment_8_by_la"] == { + "Kensington and Chelsea": 54.5, + "Bristol, City of": 46.3, + } + + +def test_an_la_without_a_dfe_figure_is_absent(payload): + by_la = payload(_StubSession)["secondary"]["attainment_8_by_la"] + assert "City of London" not in by_la + assert "West Sussex" not in by_la + assert None not in by_la.values() + + +def test_no_dfe_figures_for_the_year_give_an_empty_map(payload): + assert payload(_OldYearOnly) == {"year": LATEST, "secondary": {"attainment_8_by_la": {}}} + + +def test_a_missing_mart_gives_an_empty_map(payload): + assert payload(_NoMart)["secondary"]["attainment_8_by_la"] == {} + + +def test_the_endpoint_serves_dfe_figures(monkeypatch): + from backend import app as app_module + from backend import database as database_module + + monkeypatch.setattr(app_module, "load_school_data", _df) + monkeypatch.setattr(database_module, "SessionLocal", _StubSession) + resp = TestClient(app_module.app).get("/api/la-averages") + assert resp.status_code == 200, resp.text + assert resp.json()["secondary"]["attainment_8_by_la"]["Kensington and Chelsea"] == 54.5 diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 8ffe56b..614a21c 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -417,12 +417,15 @@ test('a secondary search row compares its Attainment 8 with the LA average', asy // guards the comparison itself; the unit test pins the cache mode. const la = await (await page.request.get('/api/la-averages')).json(); const averages: Record = la.secondary?.attainment_8_by_la ?? {}; + // DfE publishes about 152 LA averages. An empty map (a missing mart, or a + // year the LA data set has not reached) hides every comparison: fail, not skip. + expect(Object.keys(averages).length).toBeGreaterThan(100); const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=50'); expect(res.ok()).toBeTruthy(); const school = ((await res.json()).schools ?? []).find( (s: { attainment_8_score?: number | null; local_authority?: string; school_type?: string }) => s.attainment_8_score != null && s.local_authority != null && averages[s.local_authority] != null - && !/special|pupil referral|alternative provision/i.test(s.school_type ?? '')); + && !/special|pupil referral|alternative provision|independent/i.test(s.school_type ?? '')); test.skip(!school, 'no mainstream secondary with an LA average here'); await searchByName(page, school.school_name); @@ -433,6 +436,52 @@ test('a secondary search row compares its Attainment 8 with the LA average', asy await expect(stats.getByText(/vs LA avg/)).toBeVisible(); }); +test('an independent secondary shows its Attainment 8 without an LA comparison', async ({ page }) => { + // DfE's LA averages cover state-funded schools, and an independent school's + // Attainment 8 leaves out IGCSEs, so a gap would mislead (audit H2). A state + // school on the same page must show its gap first, so the absence is real. + const la = await (await page.request.get('/api/la-averages')).json(); + const averages: Record = la.secondary?.attainment_8_by_la ?? {}; + expect(Object.keys(averages).length).toBeGreaterThan(100); + + type Row = { urn: number; school_type?: string; local_authority?: string; attainment_8_score?: number | null }; + const compared = (s: Row) => s.attainment_8_score != null && s.local_authority != null + && averages[s.local_authority] != null + && !/special|pupil referral|alternative provision/i.test(s.school_type ?? ''); + let found: { la: string; state: Row; independent: Row } | null = null; + for (const name of ['Kensington and Chelsea', 'Westminster', 'Camden', 'Hammersmith and Fulham', 'Barnet']) { + // The search page asks for the same first 50 schools. + const res = await page.request.get(`/api/schools?search=${encodeURIComponent(name)}&phase=secondary&page_size=50`); + const schools: Row[] = ((await res.json()).schools ?? []).filter(compared); + const independent = schools.find(s => /independent/i.test(s.school_type ?? '')); + const state = schools.find(s => !/independent/i.test(s.school_type ?? '')); + if (independent && state) { found = { la: name, state, independent }; break; } + } + test.skip(!found, 'no LA here lists a state and an independent secondary on one page'); + + await page.goto(`/?search=${encodeURIComponent(found!.la)}&phase=secondary`); + const stats = (urn: number) => page.locator(`a[href^="/school/${urn}-"]`).first() + .locator('xpath=ancestor::div[contains(@class, "__rowContent")][1]') + .locator('[class*="__line3"]'); + await expect(stats(found!.state.urn).getByText(/vs LA avg/)).toBeVisible({ timeout: 15_000 }); + const independent = stats(found!.independent.urn); + await expect(independent.getByText(found!.independent.attainment_8_score!.toFixed(1))).toBeVisible(); + await expect(independent.getByText(/vs LA avg/)).toHaveCount(0); +}); + +test('a secondary shows its 2023/24 GCSE results, the last year DfE published Progress 8', async ({ page }) => { + // DfE re-issued its 2023/24 file under older column names, and every + // school's 2023/24 row loaded empty (audit C2). These are DfE's final + // figures for Bishop Stopford School, so they do not change. + await page.goto('/school/137086'); + const history = page.locator('#history'); + await history.getByText('View raw year-by-year data').click(); + const row = history.getByRole('row', { name: /2023\/24/ }); + await expect(row).toContainText('64.1'); + await expect(row).toContainText('+1.0'); + await expect(row).toContainText('91.7%'); +}); + test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => { // The search page offers every GIAS phase, but the API only knew the grouped // ones and silently dropped the rest — so "Nursery" returned primaries. diff --git a/nextjs-app/__tests__/components/LeafletMapInner.test.tsx b/nextjs-app/__tests__/components/LeafletMapInner.test.tsx index 5a47ae0..e910061 100644 --- a/nextjs-app/__tests__/components/LeafletMapInner.test.tsx +++ b/nextjs-app/__tests__/components/LeafletMapInner.test.tsx @@ -105,3 +105,20 @@ it('puts the card back when the pins are rebuilt for a reason other than the sch expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School'); expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1); }); + +it('compares a state secondary with its LA average on the card, but not an independent one', () => { + const state: School = { ...base, urn: 3, school_name: 'Holland Park School', school_type: 'Academy converter', + phase: 'Secondary', local_authority: 'Kensington and Chelsea', attainment_8_score: 60, + latitude: 51.5, longitude: -0.2, distance: 0.3 }; + const independent: School = { ...state, urn: 4, school_name: 'Abbey Gate College', + school_type: 'Other independent school', attainment_8_score: 20.4 }; + const laAverages = { 'Kensington and Chelsea': 54.5 }; + + const { container, rerender } = renderMap({ schools: [state, independent], laAverages, selectedUrn: 3 }); + expect(container.querySelector('.sc-popup')).toHaveTextContent('60.0 Att 8 +5.5 vs LA'); + + rerender({ schools: [state, independent], laAverages, selectedUrn: 4 }); + const card = container.querySelector('.sc-popup')!; + expect(card).toHaveTextContent('20.4 Att 8'); + expect(card).not.toHaveTextContent(/vs LA/); +}); diff --git a/nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx b/nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx index 9503a84..2ed339b 100644 --- a/nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx +++ b/nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx @@ -116,3 +116,38 @@ describe('SecondarySchoolRow shares the school page flags', () => { expect(screen.getByText('Fee-paying')).toBeInTheDocument(); }); }); + +describe('SecondarySchoolRow LA comparison', () => { + it('compares a state school with its LA average', () => { + render( + , + ); + expect(screen.getByText(/\+5\.5 vs LA avg/)).toBeInTheDocument(); + }); + + it('keeps the comparison for a school whose type is unknown', () => { + render( + , + ); + expect(screen.getByText(/\+5\.5 vs LA avg/)).toBeInTheDocument(); + }); + + it("shows an independent school's Attainment 8 without an LA comparison", () => { + // DfE's LA average covers state-funded schools; an independent's + // Attainment 8 leaves out IGCSEs (audit H2). + render( + , + ); + expect(screen.getByText('20.4')).toBeInTheDocument(); + expect(screen.queryByText(/vs LA avg/)).not.toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts index ab03561..ea9f983 100644 --- a/nextjs-app/__tests__/lib/utils.test.ts +++ b/nextjs-app/__tests__/lib/utils.test.ts @@ -391,3 +391,20 @@ describe('singleSexLabel', () => { expect(singleSexLabel(undefined)).toBeNull(); }); }); + +describe('isIndependentSchool', () => { + const { isIndependentSchool } = require('@/lib/utils'); + + it('matches both GIAS independent types', () => { + expect(isIndependentSchool({ school_type: 'Other independent school' })).toBe(true); + expect(isIndependentSchool({ school_type: 'Other independent special school' })).toBe(true); + }); + + it('does not match state-funded types or a missing type', () => { + for (const t of ['Academy converter', 'Community school', 'Free schools', 'Non-maintained special school']) { + expect(isIndependentSchool({ school_type: t })).toBe(false); + } + expect(isIndependentSchool({ school_type: null })).toBe(false); + expect(isIndependentSchool({})).toBe(false); + }); +}); diff --git a/nextjs-app/components/LeafletMapInner.tsx b/nextjs-app/components/LeafletMapInner.tsx index e7d1bec..ded0513 100644 --- a/nextjs-app/components/LeafletMapInner.tsx +++ b/nextjs-app/components/LeafletMapInner.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import type { School } from '@/lib/types'; -import { schoolUrl, isSpecialSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils'; +import { schoolUrl, isSpecialSchool, isIndependentSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils'; interface LeafletMapInnerProps { schools: School[]; @@ -70,7 +70,7 @@ function metricHtml(school: School, { nationalAvgRwm, laAverages }: CardContext) const score = school.attainment_8_score; const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null; let delta = ''; - if (!special && laAvg != null) { + if (!special && !isIndependentSchool(school) && laAvg != null) { const diff = Math.round((score - laAvg) * 10) / 10; // Att8 runs 0–90 in 0.1 steps; ±0.5 is meaningful, where RWM needs ±2. const cls = diff >= 0.5 ? 'sc-up' : diff <= -0.5 ? 'sc-down' : ''; diff --git a/nextjs-app/components/SecondarySchoolRow.tsx b/nextjs-app/components/SecondarySchoolRow.tsx index 2c5181e..bc3f48e 100644 --- a/nextjs-app/components/SecondarySchoolRow.tsx +++ b/nextjs-app/components/SecondarySchoolRow.tsx @@ -11,7 +11,7 @@ 'use client'; import type { School } from '@/lib/types'; -import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils'; +import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool, isIndependentSchool } from '@/lib/utils'; import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts'; import styles from './SecondarySchoolRow.module.css'; @@ -45,10 +45,12 @@ export function SecondarySchoolRow({ const att8 = school.attainment_8_score; // The school's own Attainment 8 is a same-school figure — shown whenever it // exists (special schools included; their type tag on line 2 gives context). - // Only the vs-LA-average delta, a benchmark comparison, is dropped for - // special schools / PRUs / AP, whose pupils aren't measured against it fairly. + // Only the vs-LA-average delta, a benchmark comparison, is dropped: for + // special schools / PRUs / AP, whose pupils aren't measured against it + // fairly, and for independent schools, because DfE's LA average covers + // state-funded schools and an independent's Attainment 8 leaves out IGCSEs. const laDelta = - att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null + att8 != null && !isSpecialSchool(school) && !isIndependentSchool(school) && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null; diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 75c873a..9d3440d 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -758,6 +758,16 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t); } +/** + * Independent (fee-paying) schools: GIAS types "Other independent school" and + * "Other independent special school". DfE's Attainment 8 for them leaves out + * IGCSEs, and DfE's LA averages cover state-funded schools only, so callers + * drop the "vs LA avg" comparison for them (audit H2). + */ +export function isIndependentSchool(school: { school_type?: string | null }): boolean { + return /\bindependent\b/i.test(school.school_type ?? ''); +} + /** * Whether GIAS records a religious character. "None", "Does not apply" and * "Not applicable" are the register's ways of saying it has none; the place