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;