diff --git a/backend/data_loader.py b/backend/data_loader.py index e7a8cd1..332ce5e 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -577,6 +577,7 @@ def compute_benchmarks(df: pd.DataFrame) -> dict: "eal_pct": _median(sub, "eal_pct"), "sen_support_pct": _median(sub, "sen_support_pct"), "disadvantaged_pct": _median(sub, "disadvantaged_pct"), + "fsm_pct": _median(sub, "fsm_pct"), "median_pupils": median_pupils, } if with_disadvantaged: diff --git a/backend/tests/test_benchmarks.py b/backend/tests/test_benchmarks.py index 01d8893..50c8233 100644 --- a/backend/tests/test_benchmarks.py +++ b/backend/tests/test_benchmarks.py @@ -17,33 +17,33 @@ def _df(): # weighted = (40*100 + 60*300) / 400 = 55.0 ; unweighted mean = 50.0 dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=100, rwm_expected_disadvantaged_pct=40.0, eal_pct=10.0, - sen_support_pct=10.0, disadvantaged_pct=20.0, total_pupils=200), + sen_support_pct=10.0, disadvantaged_pct=20.0, fsm_pct=15.0, total_pupils=200), dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=300, rwm_expected_disadvantaged_pct=60.0, eal_pct=20.0, - sen_support_pct=14.0, disadvantaged_pct=24.0, total_pupils=280), + sen_support_pct=14.0, disadvantaged_pct=24.0, fsm_pct=17.0, total_pupils=280), dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=np.nan, rwm_expected_disadvantaged_pct=99.0, eal_pct=30.0, - sen_support_pct=18.0, disadvantaged_pct=30.0, total_pupils=300), + sen_support_pct=18.0, disadvantaged_pct=30.0, fsm_pct=19.0, total_pupils=300), dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=50, rwm_expected_disadvantaged_pct=np.nan, eal_pct=np.nan, - sen_support_pct=np.nan, disadvantaged_pct=np.nan, total_pupils=np.nan), + sen_support_pct=np.nan, disadvantaged_pct=np.nan, fsm_pct=np.nan, total_pupils=np.nan), dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=40, rwm_expected_disadvantaged_pct=np.nan, eal_pct=40.0, - sen_support_pct=20.0, disadvantaged_pct=40.0, total_pupils=350), + sen_support_pct=20.0, disadvantaged_pct=40.0, fsm_pct=21.0, total_pupils=350), dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=60, rwm_expected_disadvantaged_pct=np.nan, eal_pct=50.0, - sen_support_pct=22.0, disadvantaged_pct=44.0, total_pupils=400), + sen_support_pct=22.0, disadvantaged_pct=44.0, fsm_pct=23.0, total_pupils=400), # Two secondary schools (attainment_8 non-null) dict(year=LATEST, attainment_8_score=45.0, eligible_pupils=180, rwm_expected_disadvantaged_pct=np.nan, eal_pct=15.0, - sen_support_pct=12.0, disadvantaged_pct=22.0, total_pupils=1000), + sen_support_pct=12.0, disadvantaged_pct=22.0, fsm_pct=12.0, total_pupils=1000), dict(year=LATEST, attainment_8_score=50.0, eligible_pupils=200, rwm_expected_disadvantaged_pct=np.nan, eal_pct=25.0, - sen_support_pct=16.0, disadvantaged_pct=26.0, total_pupils=1200), + sen_support_pct=16.0, disadvantaged_pct=26.0, fsm_pct=14.0, total_pupils=1200), # An older-year primary row that must NOT influence anything dict(year=202324, attainment_8_score=np.nan, eligible_pupils=500, rwm_expected_disadvantaged_pct=1.0, eal_pct=99.0, - sen_support_pct=99.0, disadvantaged_pct=99.0, total_pupils=9999), + sen_support_pct=99.0, disadvantaged_pct=99.0, fsm_pct=99.0, total_pupils=9999), ] return pd.DataFrame(rows) @@ -59,6 +59,8 @@ def test_medians_ignore_nan_and_older_years(): assert b["year"] == LATEST # eal medians over [10,20,30,40,50] = 30 assert b["primary"]["eal_pct"] == 30.0 + # fsm medians over [15,17,19,21,23] = 19 + assert b["primary"]["fsm_pct"] == 19.0 # median pupils over [200,280,300,350,400] = 300 assert b["primary"]["median_pupils"] == 300 @@ -66,6 +68,7 @@ def test_medians_ignore_nan_and_older_years(): def test_secondary_block_has_no_disadvantaged_rwm(): b = compute_benchmarks(_df()) assert "disadvantaged_rwm_expected_pct" not in b["secondary"] + assert b["secondary"]["fsm_pct"] == 13.0 assert b["secondary"]["median_pupils"] == 1100 diff --git a/nextjs-app/__tests__/lib/compareLogic.test.ts b/nextjs-app/__tests__/lib/compareLogic.test.ts index 03c2432..e1308bf 100644 --- a/nextjs-app/__tests__/lib/compareLogic.test.ts +++ b/nextjs-app/__tests__/lib/compareLogic.test.ts @@ -126,6 +126,13 @@ describe('ofstedDisplay', () => { expect(ofstedDisplay(ofsted({})).kind).toBe('none'); }); + it('identifies transitional inspections without overall grades', () => { + const transitional = ofstedDisplay( + ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }), + ); + expect(transitional.kind).toBe('transitional'); + }); + it('uses the four legacy grade words', () => { expect(OFSTED_LEGACY_GRADES).toEqual({ 1: 'Outstanding', diff --git a/nextjs-app/components/ComparisonView.tsx b/nextjs-app/components/ComparisonView.tsx index 003658b..65e6624 100644 --- a/nextjs-app/components/ComparisonView.tsx +++ b/nextjs-app/components/ComparisonView.tsx @@ -142,19 +142,23 @@ export function ComparisonView({ }); }, [urnKey, isInitialized]); - // Classify schools by phase using comparison data - const classifySchool = (school: School): 'primary' | 'secondary' => { + const primarySchools = selectedSchools.filter((school) => { const info = comparisonData?.[school.urn]?.school_info; - if (info?.attainment_8_score != null) return 'secondary'; - if (info?.rwm_expected_pct != null) return 'primary'; - // Fallback: check yearly data - const yearlyData = comparisonData?.[school.urn]?.yearly_data; - if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary'; - return 'primary'; - }; + const hasPrimaryData = + info?.rwm_expected_pct != null || + comparisonData?.[school.urn]?.yearly_data?.some((d) => d.rwm_expected_pct != null); + if (hasPrimaryData) return true; + return school.phase?.toLowerCase().includes('primary') || false; + }); - const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary'); - const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary'); + const secondarySchools = selectedSchools.filter((school) => { + const info = comparisonData?.[school.urn]?.school_info; + const hasSecondaryData = + info?.attainment_8_score != null || + comparisonData?.[school.urn]?.yearly_data?.some((d) => d.attainment_8_score != null); + if (hasSecondaryData) return true; + return school.phase?.toLowerCase().includes('secondary') || false; + }); // Auto-select tab with more schools and sync the metric to match the phase. useEffect(() => { diff --git a/nextjs-app/components/compare/CompareAtAGlance.tsx b/nextjs-app/components/compare/CompareAtAGlance.tsx index 64b61ea..59c9fe7 100644 --- a/nextjs-app/components/compare/CompareAtAGlance.tsx +++ b/nextjs-app/components/compare/CompareAtAGlance.tsx @@ -83,6 +83,14 @@ export function CompareAtAGlance({ {display.carriedForward && Grade carried forward} )} + {display.kind === 'transitional' && ( + <> + + No overall grade + + Sub-judgements only + + )} {display.kind === 'none' && No inspection in our dataset} ); diff --git a/nextjs-app/components/compare/CompareCommunity.tsx b/nextjs-app/components/compare/CompareCommunity.tsx index 186c427..6d39a3c 100644 --- a/nextjs-app/components/compare/CompareCommunity.tsx +++ b/nextjs-app/components/compare/CompareCommunity.tsx @@ -31,13 +31,14 @@ export function CompareCommunity({ const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary; const fsmChip = (value: number | null) => { - if (value == null || bench?.disadvantaged_pct == null) return null; - const v = verdict(value, bench.disadvantaged_pct, 3); + const anchor = bench?.fsm_pct ?? bench?.disadvantaged_pct ?? null; + if (value == null || anchor == null) return null; + const v = verdict(value, anchor, 3); return ( - {v === 'above' && 'Above the state-school average'} - {v === 'close' && 'About the state-school average'} - {v === 'below' && 'Below the state-school average'} + {v === 'above' && `Above the state-school average (${Math.round(anchor)}%)`} + {v === 'close' && `About the state-school average (${Math.round(anchor)}%)`} + {v === 'below' && `Below the state-school average (${Math.round(anchor)}%)`} ); }; diff --git a/nextjs-app/components/compare/CompareOfsted.tsx b/nextjs-app/components/compare/CompareOfsted.tsx index 805b1af..56ff359 100644 --- a/nextjs-app/components/compare/CompareOfsted.tsx +++ b/nextjs-app/components/compare/CompareOfsted.tsx @@ -51,6 +51,18 @@ function ResultCell({ display }: { display: OfstedDisplay }) { ); } + if (display.kind === 'transitional') { + return ( + <> + + No overall grade + + + Inspected under transitional framework (sub-judgements only) + + + ); + } return ( <> diff --git a/nextjs-app/lib/compareLogic.ts b/nextjs-app/lib/compareLogic.ts index 0916161..b4cb44f 100644 --- a/nextjs-app/lib/compareLogic.ts +++ b/nextjs-app/lib/compareLogic.ts @@ -102,6 +102,7 @@ export type OfstedDisplay = | { kind: 'none' } | { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false } | { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true } + | { kind: 'transitional' } | { kind: 'report_card'; summary: ReportCardSummary }; export function ofstedDisplay( @@ -117,7 +118,12 @@ export function ofstedDisplay( const grade = ofsted.overall_effectiveness; const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined; - if (grade == null || gradeLabel === undefined) return { kind: 'none' }; + if (grade == null || gradeLabel === undefined) { + if (ofsted.inspection_date) { + return { kind: 'transitional' }; + } + return { kind: 'none' }; + } if (ofsted.grade_source === 'ungraded_carried_forward') { return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true }; diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 3634f87..f82d907 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -357,6 +357,7 @@ export interface BenchmarkBlock { eal_pct: number | null; sen_support_pct: number | null; disadvantaged_pct: number | null; + fsm_pct?: number | null; median_pupils: number | null; /** Primary only — weighted by cohort size. */ disadvantaged_rwm_expected_pct?: number | null;