diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts
index 4df77d7..ab4ce8f 100644
--- a/e2e/tests/journeys.spec.ts
+++ b/e2e/tests/journeys.spec.ts
@@ -175,8 +175,30 @@ test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not ju
// The combined results section carries both phases.
const results = page.locator('#results');
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
- await expect(results.getByText('Reading, Writing & Maths combined')).toBeVisible(); // KS2
- await expect(results.getByText('Attainment 8').first()).toBeVisible(); // KS4
+ await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block
+ await expect(results.getByRole('heading', { name: /Secondary.*GCSEs/ })).toBeVisible(); // KS4 block
+});
+
+test('a special school is not shown as failing against the mainstream England average', async ({ page }) => {
+ // Special schools sit the same tests but very few pupils reach the mainstream
+ // "expected standard", so a "0.0% · −62 pts below England average" rendering
+ // portrays them as failing against a benchmark that doesn't fit. The results
+ // section drops the England comparison and explains the context instead.
+ const SP_URN = 101099; // Greenmead School — a community special school
+ const res = await page.request.get(`/api/schools/${SP_URN}`);
+ expect(res.ok()).toBeTruthy();
+ const detail = await res.json();
+ test.skip(
+ !/special|pupil referral|alternative provision/i.test(detail.school_info?.school_type ?? ''),
+ 'precondition: chosen URN must currently be a special school',
+ );
+
+ await page.goto(`/school/${SP_URN}`);
+ const results = page.locator('#results');
+ // The special-school context note is shown…
+ await expect(results.getByText(/This is a special school/i)).toBeVisible({ timeout: 15_000 });
+ // …and the mainstream England-average comparison is dropped entirely.
+ await expect(results.getByText(/England avg/i)).toHaveCount(0);
});
test('school with no performance data still gets a working detail page', async ({ page }) => {
diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts
index f657d1b..38e3b78 100644
--- a/nextjs-app/__tests__/lib/utils.test.ts
+++ b/nextjs-app/__tests__/lib/utils.test.ts
@@ -269,6 +269,44 @@ describe('isProposedToClose', () => {
});
});
+describe('isSpecialSchool', () => {
+ const { isSpecialSchool } = require('@/lib/utils');
+
+ it('matches every DfE special-school establishment type', () => {
+ for (const t of [
+ 'Community special school',
+ 'Foundation special school',
+ 'Academy special converter',
+ 'Academy special sponsor led',
+ 'Non-maintained special school',
+ 'Free schools special',
+ 'Other independent special school',
+ ]) {
+ expect(isSpecialSchool({ school_type: t })).toBe(true);
+ }
+ });
+
+ it('matches pupil referral units and alternative provision', () => {
+ expect(isSpecialSchool({ school_type: 'Pupil referral unit' })).toBe(true);
+ expect(isSpecialSchool({ school_type: 'Academy alternative provision converter' })).toBe(true);
+ });
+
+ it('does not match mainstream types (no false positives)', () => {
+ for (const t of [
+ 'Academy converter',
+ 'Community school',
+ 'Voluntary aided school',
+ 'Free schools',
+ 'University technical college',
+ 'Other independent school',
+ null,
+ undefined,
+ ]) {
+ expect(isSpecialSchool({ school_type: t })).toBe(false);
+ }
+ });
+});
+
describe('shortName', () => {
it('drops the trailing establishment-type words', () => {
expect(shortName('Barclay Primary School')).toBe('Barclay');
diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css
index b2b419a..cfe453b 100644
--- a/nextjs-app/components/SchoolDetailView.module.css
+++ b/nextjs-app/components/SchoolDetailView.module.css
@@ -1558,3 +1558,21 @@
.closingStrip strong {
color: #8a6200;
}
+
+/* Special-school context note: soft, informational (teal), not a warning. It
+ explains why the mainstream attainment measures and England comparison are
+ dropped for special schools / PRUs / AP. */
+.specialNote {
+ background: var(--bg-secondary, #f3ede4);
+ border-left: 4px solid var(--accent-teal, #2d7d7d);
+ border-radius: 0 6px 6px 0;
+ padding: 0.7rem 1rem;
+ margin: 0 0 1rem;
+ font-size: 0.9rem;
+ line-height: 1.5;
+ color: var(--text-secondary, #5c564d);
+ max-width: 72ch;
+}
+.specialNote strong {
+ color: var(--text-primary, #1a1612);
+}
diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx
index e353795..be5f501 100644
--- a/nextjs-app/components/SchoolDetailView.tsx
+++ b/nextjs-app/components/SchoolDetailView.tsx
@@ -18,7 +18,7 @@ import type {
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import {
- formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas,
+ formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
} from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { summariseAdmissions } from '@/lib/compareLogic';
@@ -233,6 +233,24 @@ export function SchoolDetailView({
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
const hasAnyResults = hasKS2Results || hasKS4Results;
+ // Special schools / PRUs / AP: their pupils sit the same tests but very few
+ // reach the mainstream "expected standard", so a 0% headline and an England
+ // comparison portray them as failing against a benchmark that doesn't fit.
+ const isSpecial = isSpecialSchool(schoolInfo);
+ // Belt-and-braces: a whole-row zero attainment (special or suppressed cohort)
+ // is a placeholder, not a real mainstream result — suppress its comparison
+ // too. A single 0 (e.g. 0% exceeding at a normal school) stays comparable.
+ const ks2Placeholder = latestResults != null
+ && latestResults.rwm_expected_pct === 0
+ && (latestResults.reading_expected_pct ?? 0) === 0
+ && (latestResults.writing_expected_pct ?? 0) === 0
+ && (latestResults.maths_expected_pct ?? 0) === 0;
+ const ks4Placeholder = latestResults != null && latestResults.attainment_8_score === 0;
+ // Whether to drop the England-average deltas / national markers / "below"
+ // framing on the attainment measures.
+ const suppressKs2Comparison = isSpecial || ks2Placeholder;
+ const suppressKs4Comparison = isSpecial || ks4Placeholder;
+
// Build section nav items dynamically — only sections with data.
// Order is engagement-led (from section_nav_used analytics): the most-sought
// sections — results, admissions, inclusion, history — sit near the top,
@@ -624,13 +642,32 @@ export function SchoolDetailView({
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
- {isAllThrough
- ? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
- : isSecondary
- ? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
- : 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
+ {isSpecial
+ ? (isSecondary
+ ? 'GCSE results for Year 11 pupils.'
+ : 'End-of-primary-school tests taken by Year 6 pupils.')
+ : isAllThrough
+ ? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
+ : isSecondary
+ ? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
+ : 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
+ {/* Special schools sit the same national assessments but teach pupils
+ with SEND, so very few reach the mainstream "expected standard".
+ Explain that up front and drop the England comparison below, so a
+ 0% headline never reads as a failing grade against a benchmark
+ that doesn't fit the school. */}
+ {isSpecial && (
+
+ This is a special school. Its pupils have special educational
+ needs and work towards individual targets. They sit the same national tests, but
+ very few reach the mainstream “expected standard” these measures report,
+ so a comparison with the England average isn’t a meaningful guide to the
+ school. Where available, the progress pupils make (below) is a fairer measure.
+
+ )}
+
{/* ── Primary / KS2 content ── */}
{hasKS2Results && (
<>
@@ -646,7 +683,7 @@ export function SchoolDetailView({
{formatPercentage(latestResults.rwm_expected_pct)}
- {primaryAvg.rwm_expected_pct != null && (
+ {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
)}
- {primaryAvg.rwm_expected_pct != null && (
+ {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%
)}
@@ -668,7 +705,7 @@ export function SchoolDetailView({
{formatPercentage(latestResults.rwm_high_pct)}
- {primaryAvg.rwm_high_pct != null && (
+ {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
)}
- {primaryAvg.rwm_high_pct != null && (
+ {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%
)}
)}
- {latestResults.rwm_expected_pct != null &&
+ {!suppressKs2Comparison &&
+ latestResults.rwm_expected_pct != null &&
latestResults.reading_expected_pct != null &&
latestResults.writing_expected_pct != null &&
latestResults.maths_expected_pct != null && (
@@ -708,28 +746,34 @@ export function SchoolDetailView({
)}
-
+ {/* All-zero placeholder rows (special / suppressed) would render as
+ three empty bars against the national markers — misleading, so
+ skip the chart. For a special school with some non-zero
+ subjects, keep the bars but drop the national markers. */}
+ {!ks2Placeholder && (
+
+ )}
{/* Progress scores row */}
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
@@ -786,7 +830,7 @@ export function SchoolDetailView({
{latestResults.attainment_8_score.toFixed(1)}
- {secondaryAvg.attainment_8_score != null && (
+ {!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
)}
@@ -810,7 +854,7 @@ export function SchoolDetailView({
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
- {secondaryAvg.english_maths_standard_pass_pct != null && (
+ {!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
)}
@@ -822,7 +866,7 @@ export function SchoolDetailView({
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
- {secondaryAvg.english_maths_strong_pass_pct != null && (
+ {!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
)}
@@ -1076,8 +1120,8 @@ export function SchoolDetailView({
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={false}
- nationalRwmAvg={primaryAvg.rwm_expected_pct ?? null}
- nationalByYear={nationalAvg?.by_year}
+ nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
+ nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
/>
>
@@ -1090,8 +1134,8 @@ export function SchoolDetailView({
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
- nationalAtt8Avg={secondaryAvg.attainment_8_score ?? null}
- nationalByYear={nationalAvg?.by_year}
+ nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
+ nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
/>
>
@@ -1103,9 +1147,9 @@ export function SchoolDetailView({
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={isSecondary}
- nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
- nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
- nationalByYear={nationalAvg?.by_year}
+ nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
+ nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
+ nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
/>
)}
diff --git a/nextjs-app/components/SchoolRow.tsx b/nextjs-app/components/SchoolRow.tsx
index 43dc205..b472051 100644
--- a/nextjs-app/components/SchoolRow.tsx
+++ b/nextjs-app/components/SchoolRow.tsx
@@ -9,7 +9,7 @@
*/
import type { School } from '@/lib/types';
-import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
+import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import styles from './SchoolRow.module.css';
interface SchoolRowProps {
@@ -38,10 +38,19 @@ export function SchoolRow({
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
+ // Special schools / PRUs / AP: the mainstream RWM measure and its England
+ // comparison aren't a fair judgement (their pupils have SEND), so a "0% ·
+ // −62 vs national" row misrepresents them. Treat the mainstream stat as not
+ // comparable — same for a placeholder all-zero value.
+ const rwmComparable =
+ school.rwm_expected_pct != null &&
+ school.rwm_expected_pct !== 0 &&
+ !isSpecialSchool(school);
+
// vs-national delta
const rwmDelta =
- school.rwm_expected_pct != null && nationalAvgRwm != null
- ? Math.round(school.rwm_expected_pct - nationalAvgRwm)
+ rwmComparable && nationalAvgRwm != null
+ ? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
: null;
const handleCompareClick = () => {
@@ -87,9 +96,9 @@ export function SchoolRow({
- {school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
+ {rwmComparable ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
- {school.prev_rwm_expected_pct != null && (
+ {rwmComparable && school.prev_rwm_expected_pct != null && (
= 202425;
const hasResults = latestResults?.attainment_8_score != null;
+ // Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
+ // so their headline attainment is far below the mainstream average by design.
+ // Drop the England comparison + "below" framing so the page doesn't portray
+ // them as failing against a benchmark that doesn't fit. (Also guards a
+ // placeholder all-zero Attainment 8.)
+ const isSpecial = isSpecialSchool(schoolInfo);
+ const suppressComparison = isSpecial || (latestResults != null && latestResults.attainment_8_score === 0);
+
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
@@ -452,9 +460,18 @@ export function SecondarySchoolDetailView({
GCSE Results ({formatAcademicYear(latestResults.year)})
- GCSE results for Year 11 pupils. England averages shown for comparison.
+ GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
+ {isSpecial && (
+
+ This is a special school. Its pupils have special educational
+ needs and work towards individual targets. They sit the same GCSEs, but their
+ headline attainment is far below the mainstream average by design, so a comparison
+ with the England average isn’t a meaningful guide to the school.
+
+ )}
+
{p8Suspended && (
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
@@ -472,7 +489,7 @@ export function SecondarySchoolDetailView({
{latestResults.attainment_8_score.toFixed(1)}
- {secondaryAvg.attainment_8_score != null && (
+ {!suppressComparison && secondaryAvg.attainment_8_score != null && (
)}
- {secondaryAvg.attainment_8_score != null && (
+ {!suppressComparison && secondaryAvg.attainment_8_score != null && (
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
)}
@@ -512,7 +529,7 @@ export function SecondarySchoolDetailView({
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
- {secondaryAvg.english_maths_strong_pass_pct != null && (
+ {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
)}
- {secondaryAvg.english_maths_strong_pass_pct != null && (
+ {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
)}
@@ -534,7 +551,7 @@ export function SecondarySchoolDetailView({
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
- {secondaryAvg.english_maths_standard_pass_pct != null && (
+ {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
)}
- {secondaryAvg.english_maths_standard_pass_pct != null && (
+ {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
)}
)}
- {/* Attainment 8 visual bar (0–80 scale) */}
- {latestResults.attainment_8_score != null && (
+ {/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
+ "school vs national", so it's dropped for special schools where
+ that comparison isn't meaningful. */}
+ {!suppressComparison && latestResults.attainment_8_score != null && (
Attainment 8 — school vs national
@@ -743,8 +762,8 @@ export function SecondarySchoolDetailView({
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
- nationalAtt8Avg={heroAtt8Nat}
- nationalByYear={nationalAvg?.by_year}
+ nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
+ nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
/>
>
diff --git a/nextjs-app/components/SecondarySchoolRow.tsx b/nextjs-app/components/SecondarySchoolRow.tsx
index 39c1256..f08d20c 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 } from '@/lib/utils';
+import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null {
@@ -55,8 +55,12 @@ export function SecondarySchoolRow({
const ofstedBadge = buildOfstedListBadge(school);
const phase = getPhaseStyle(school.phase);
const att8 = school.attainment_8_score;
+ // Special schools / PRUs / AP: Attainment 8 vs the LA average isn't a fair
+ // comparison (their pupils have SEND), so drop the delta and the number
+ // rather than show them trailing "the average" by design.
+ const att8Comparable = att8 != null && att8 !== 0 && !isSpecialSchool(school);
const laDelta =
- att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
+ att8Comparable && laAvgAttainment8 != null ? (att8 as number) - laAvgAttainment8 : null;
const admissionsTag = detectAdmissionsTag(school);
const sixthForm = hasSixthForm(school);
@@ -106,7 +110,7 @@ export function SecondarySchoolRow({
- {att8 != null ? att8.toFixed(1) : '—'}
+ {att8Comparable ? (att8 as number).toFixed(1) : '—'}
Attainment 8
diff --git a/nextjs-app/components/compare/CompareAcademics.tsx b/nextjs-app/components/compare/CompareAcademics.tsx
index 6660a81..1db15b4 100644
--- a/nextjs-app/components/compare/CompareAcademics.tsx
+++ b/nextjs-app/components/compare/CompareAcademics.tsx
@@ -8,6 +8,7 @@
'use client';
import { latestValues, verdict } from '@/lib/compareLogic';
+import { isSpecialSchool } from '@/lib/utils';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { DotStrip } from '@/components/DotStrip';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
@@ -90,15 +91,19 @@ function Strip({
urns,
schoolNames,
national,
+ special,
}: {
spec: StripSpec;
data: Record
;
urns: number[];
schoolNames: string[];
national: Record | undefined;
+ /** Per-school special-school flag; special schools' mainstream attainment is
+ * not a fair comparison, so it's dropped from the strip (no dot). */
+ special: boolean[];
}) {
- const values = latestValues(data, urns, spec.metric).map((v) =>
- v != null ? Math.round(v) : null,
+ const values = latestValues(data, urns, spec.metric).map((v, i) =>
+ v != null && !special[i] ? Math.round(v) : null,
);
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
const anchor =
@@ -135,12 +140,19 @@ export function CompareAcademics({
}) {
const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name);
+ // Special schools / PRUs / AP: their pupils sit the same assessments but very
+ // few reach the mainstream standard, so their attainment isn't a fair
+ // like-for-like comparison — drop it (progress banding, which IS meaningful,
+ // is kept).
+ const specialFlags = schools.map((school) => isSpecialSchool(school));
+ const dropSpecial = (vals: Array) =>
+ vals.map((v, i) => (specialFlags[i] ? null : v));
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
if (isSecondary) {
- const att8 = latestValues(data, urns, 'attainment_8_score');
+ const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score'));
const banding = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
@@ -159,8 +171,8 @@ export function CompareAcademics({
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425;
});
- const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
- const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
+ const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct'));
+ const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct'));
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
@@ -272,7 +284,7 @@ export function CompareAcademics({
}
const national = nationalAverages?.primary;
- const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
+ const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct'));
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
// groups where single pupils move the percentage — show roughly how many
@@ -307,6 +319,7 @@ export function CompareAcademics({
urns={urns}
schoolNames={schoolNames}
national={national}
+ special={specialFlags}
/>
))}
@@ -320,6 +333,7 @@ export function CompareAcademics({
urns={urns}
schoolNames={schoolNames}
national={national}
+ special={specialFlags}
/>
))}
diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts
index 41b7090..11e4f3f 100644
--- a/nextjs-app/lib/utils.ts
+++ b/nextjs-app/lib/utils.ts
@@ -795,3 +795,24 @@ export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
export function isProposedToClose(school: { status?: string | null }): boolean {
return school.status === PROPOSED_TO_CLOSE_STATUS;
}
+
+/**
+ * Special schools, pupil referral units and alternative provision teach pupils
+ * with SEND or outside mainstream settings. Their pupils sit the same KS2/KS4
+ * assessments but very few reach the mainstream "expected standard", so the
+ * headline attainment measures — and any comparison to the England average —
+ * are not a fair judgement of the school. Callers use this to drop the
+ * mainstream-benchmark framing (deltas, "below England", national markers)
+ * rather than portray these schools as failing.
+ *
+ * Detection is by establishment type: every DfE special-school type contains
+ * "special" (e.g. "Community special school", "Academy special converter/sponsor
+ * led", "Foundation special school", "Non-maintained special school", "Free
+ * schools special", "Other independent special school"); PRUs and alternative
+ * provision are matched by name. Special schools carry a mainstream `phase`
+ * (Primary/Secondary/All-through), so `phase` alone can't identify them.
+ */
+export function isSpecialSchool(school: { school_type?: string | null }): boolean {
+ const t = (school.school_type ?? '').toLowerCase();
+ return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
+}