Compare commits

...
Author SHA1 Message Date
Tudor 609bb923d9 fix: preserve literal 'NULL' strings for primary key columns
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 47s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 8s
2026-07-16 08:54:14 +01:00
Tudor 95f10bf352 fix: convert NaN/NULL to None and restore record properties structure in tap.py
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 47s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m46s
2026-07-16 08:29:59 +01:00
Tudor 8abff7a0a1 feat: ingest independent schools in Ofsted tap and dbt staging
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 47s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 10s
2026-07-15 23:21:40 +01:00
Tudor e74d3882ce Pass phase state to compare sub-components to prevent phase metrics override by multi-phase schools
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
2026-07-15 17:38:02 +01:00
Tudor b4b0249a06 Fix Ofsted transitional inspections, phase tab exclusions, and FSM benchmark comparison
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m9s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 10s
2026-07-15 17:23:40 +01:00
TudorandClaude Fable 5 fef83b3bf2 fix(compare): sticky school bar hidden behind the site header
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m7s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m3s
The Navigation header is position:sticky top:0 (z-index 1000). The school
bar was also sticky top:0 (z-index 10), so when scrolled it pinned at the
same top:0 *behind* the header — on mobile 57 of its 72px were covered,
leaving only a sliver, so you couldn't see which schools were selected.

Offset the bar's sticky top to the header height (65px desktop, 57px
mobile — the Navigation breakpoint is also 640px) so it pins just below.

e2e guard asserts the bar's sticky offset is at least the header height
(verified it fails against the pre-fix build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-15 12:32:36 +01:00
tudor cf458fe05c Merge pull request 'School name cutoff fix' (#43) from fix/compare-mockup-drift into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #43
2026-07-15 08:01:47 +00:00
tudor 19b41b6999 Merge pull request 'fix(compare): match mockups — remove trends table + mobile measure-first cards' (#42) from fix/compare-mockup-drift into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 48s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 37s
Reviewed-on: #42
2026-07-15 07:17:08 +00:00
15 changed files with 200 additions and 57 deletions
+1
View File
@@ -577,6 +577,7 @@ def compute_benchmarks(df: pd.DataFrame) -> dict:
"eal_pct": _median(sub, "eal_pct"), "eal_pct": _median(sub, "eal_pct"),
"sen_support_pct": _median(sub, "sen_support_pct"), "sen_support_pct": _median(sub, "sen_support_pct"),
"disadvantaged_pct": _median(sub, "disadvantaged_pct"), "disadvantaged_pct": _median(sub, "disadvantaged_pct"),
"fsm_pct": _median(sub, "fsm_pct"),
"median_pupils": median_pupils, "median_pupils": median_pupils,
} }
if with_disadvantaged: if with_disadvantaged:
+12 -9
View File
@@ -17,33 +17,33 @@ def _df():
# weighted = (40*100 + 60*300) / 400 = 55.0 ; unweighted mean = 50.0 # weighted = (40*100 + 60*300) / 400 = 55.0 ; unweighted mean = 50.0
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=100, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=100,
rwm_expected_disadvantaged_pct=40.0, eal_pct=10.0, 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, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=300,
rwm_expected_disadvantaged_pct=60.0, eal_pct=20.0, 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, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=np.nan,
rwm_expected_disadvantaged_pct=99.0, eal_pct=30.0, 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, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=50,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=np.nan, 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, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=40,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=40.0, 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, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=60,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=50.0, 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) # Two secondary schools (attainment_8 non-null)
dict(year=LATEST, attainment_8_score=45.0, eligible_pupils=180, dict(year=LATEST, attainment_8_score=45.0, eligible_pupils=180,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=15.0, 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, dict(year=LATEST, attainment_8_score=50.0, eligible_pupils=200,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=25.0, 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 # An older-year primary row that must NOT influence anything
dict(year=202324, attainment_8_score=np.nan, eligible_pupils=500, dict(year=202324, attainment_8_score=np.nan, eligible_pupils=500,
rwm_expected_disadvantaged_pct=1.0, eal_pct=99.0, 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) return pd.DataFrame(rows)
@@ -59,6 +59,8 @@ def test_medians_ignore_nan_and_older_years():
assert b["year"] == LATEST assert b["year"] == LATEST
# eal medians over [10,20,30,40,50] = 30 # eal medians over [10,20,30,40,50] = 30
assert b["primary"]["eal_pct"] == 30.0 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 # median pupils over [200,280,300,350,400] = 300
assert b["primary"]["median_pupils"] == 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(): def test_secondary_block_has_no_disadvantaged_rwm():
b = compute_benchmarks(_df()) b = compute_benchmarks(_df())
assert "disadvantaged_rwm_expected_pct" not in b["secondary"] assert "disadvantaged_rwm_expected_pct" not in b["secondary"]
assert b["secondary"]["fsm_pct"] == 13.0
assert b["secondary"]["median_pupils"] == 1100 assert b["secondary"]["median_pupils"] == 1100
+13
View File
@@ -227,6 +227,19 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
); );
expect(bodyOverflowsX).toBe(false); expect(bodyOverflowsX).toBe(false);
// The sticky school bar must pin *below* the sticky site header, not at
// top:0 where the header covers it and the selected schools are hidden.
// Assert the sticky offset directly (robust — no scroll timing needed).
const barTop = await page
.locator('[class*="schoolBar"]')
.first()
.evaluate((el) => parseFloat(getComputedStyle(el).top));
const headerHeight = await page
.locator('[class*="header"]')
.first()
.evaluate((el) => el.getBoundingClientRect().height);
expect(barTop).toBeGreaterThanOrEqual(headerHeight - 1);
// The trends chart still renders (inside the Explore trends section)… // The trends chart still renders (inside the Explore trends section)…
const chartCanvas = page.locator('canvas:visible').first(); const chartCanvas = page.locator('canvas:visible').first();
await expect(chartCanvas).toBeVisible({ timeout: 15_000 }); await expect(chartCanvas).toBeVisible({ timeout: 15_000 });
@@ -126,6 +126,13 @@ describe('ofstedDisplay', () => {
expect(ofstedDisplay(ofsted({})).kind).toBe('none'); 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', () => { it('uses the four legacy grade words', () => {
expect(OFSTED_LEGACY_GRADES).toEqual({ expect(OFSTED_LEGACY_GRADES).toEqual({
1: 'Outstanding', 1: 'Outstanding',
@@ -80,10 +80,12 @@
} }
/* Sticky school bar — column identity while scrolling; horizontal scroll on /* Sticky school bar — column identity while scrolling; horizontal scroll on
narrow screens */ narrow screens. Offset by the sticky site header's height (Navigation is
position: sticky, top: 0) so this bar pins just below it instead of
sliding underneath and being hidden. Header ≈ 65px desktop / 57px mobile. */
.schoolBar { .schoolBar {
position: sticky; position: sticky;
top: 0; top: 65px;
z-index: 10; z-index: 10;
background: var(--bg-primary, #faf7f2); background: var(--bg-primary, #faf7f2);
display: flex; display: flex;
@@ -173,6 +175,11 @@
pills with short names (matching the mobile mockup) instead of full-width pills with short names (matching the mobile mockup) instead of full-width
cards whose names wrap to several lines. */ cards whose names wrap to several lines. */
@media (max-width: 640px) { @media (max-width: 640px) {
/* The mobile Navigation header is shorter (≈57px). */
.schoolBar {
top: 57px;
}
.schoolChip { .schoolChip {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 0; min-width: 0;
+18 -11
View File
@@ -142,19 +142,23 @@ export function ComparisonView({
}); });
}, [urnKey, isInitialized]); }, [urnKey, isInitialized]);
// Classify schools by phase using comparison data const primarySchools = selectedSchools.filter((school) => {
const classifySchool = (school: School): 'primary' | 'secondary' => {
const info = comparisonData?.[school.urn]?.school_info; const info = comparisonData?.[school.urn]?.school_info;
if (info?.attainment_8_score != null) return 'secondary'; const hasPrimaryData =
if (info?.rwm_expected_pct != null) return 'primary'; info?.rwm_expected_pct != null ||
// Fallback: check yearly data comparisonData?.[school.urn]?.yearly_data?.some((d) => d.rwm_expected_pct != null);
const yearlyData = comparisonData?.[school.urn]?.yearly_data; if (hasPrimaryData) return true;
if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary'; return school.phase?.toLowerCase().includes('primary') || false;
return 'primary'; });
};
const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary'); const secondarySchools = selectedSchools.filter((school) => {
const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary'); 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. // Auto-select tab with more schools and sync the metric to match the phase.
useEffect(() => { useEffect(() => {
@@ -375,6 +379,7 @@ export function ComparisonView({
data={activeComparisonData} data={activeComparisonData}
nationalAverages={nationalAverages} nationalAverages={nationalAverages}
benchmarks={benchmarks} benchmarks={benchmarks}
isSecondary={!isPrimary}
/> />
<CompareOfsted schools={activeSchools} data={activeComparisonData} /> <CompareOfsted schools={activeSchools} data={activeComparisonData} />
<CompareAcademics <CompareAcademics
@@ -382,12 +387,14 @@ export function ComparisonView({
data={activeComparisonData} data={activeComparisonData}
nationalAverages={nationalAverages} nationalAverages={nationalAverages}
benchmarks={benchmarks} benchmarks={benchmarks}
isSecondary={!isPrimary}
/> />
<CompareAdmissions schools={activeSchools} data={activeComparisonData} /> <CompareAdmissions schools={activeSchools} data={activeComparisonData} />
<CompareCommunity <CompareCommunity
schools={activeSchools} schools={activeSchools}
data={activeComparisonData} data={activeComparisonData}
benchmarks={benchmarks} benchmarks={benchmarks}
isSecondary={!isPrimary}
/> />
<TrendsExplorer <TrendsExplorer
schools={activeSchools} schools={activeSchools}
@@ -125,15 +125,17 @@ export function CompareAcademics({
data, data,
nationalAverages, nationalAverages,
benchmarks, benchmarks,
isSecondary: propIsSecondary,
}: { }: {
schools: School[]; schools: School[];
data: Record<string, ComparisonData>; data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages; nationalAverages?: NationalAverages;
benchmarks?: Benchmarks; benchmarks?: Benchmarks;
isSecondary?: boolean;
}) { }) {
const urns = schools.map((school) => school.urn); const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name); const schoolNames = schools.map((school) => school.school_name);
const isSecondary = schools.some( const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
); );
@@ -47,14 +47,16 @@ export function CompareAtAGlance({
data, data,
nationalAverages, nationalAverages,
benchmarks, benchmarks,
isSecondary: propIsSecondary,
}: { }: {
schools: School[]; schools: School[];
data: Record<string, ComparisonData>; data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages; nationalAverages?: NationalAverages;
benchmarks?: Benchmarks; benchmarks?: Benchmarks;
isSecondary?: boolean;
}) { }) {
const urns = schools.map((school) => school.urn); const urns = schools.map((school) => school.urn);
const isSecondary = schools.some( const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
); );
const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct'; const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct';
@@ -83,6 +85,14 @@ export function CompareAtAGlance({
{display.carriedForward && <span className={s.small}>Grade carried forward</span>} {display.carriedForward && <span className={s.small}>Grade carried forward</span>}
</> </>
)} )}
{display.kind === 'transitional' && (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
No overall grade
</span>
<span className={s.small}>Sub-judgements only</span>
</>
)}
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>} {display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
</Cell> </Cell>
); );
@@ -20,24 +20,27 @@ export function CompareCommunity({
schools, schools,
data, data,
benchmarks, benchmarks,
isSecondary: propIsSecondary,
}: { }: {
schools: School[]; schools: School[];
data: Record<string, ComparisonData>; data: Record<string, ComparisonData>;
benchmarks?: Benchmarks; benchmarks?: Benchmarks;
isSecondary?: boolean;
}) { }) {
const isSecondary = schools.some( const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
); );
const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary; const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary;
const fsmChip = (value: number | null) => { const fsmChip = (value: number | null) => {
if (value == null || bench?.disadvantaged_pct == null) return null; const anchor = bench?.fsm_pct ?? bench?.disadvantaged_pct ?? null;
const v = verdict(value, bench.disadvantaged_pct, 3); if (value == null || anchor == null) return null;
const v = verdict(value, anchor, 3);
return ( return (
<Chip tone="neutral"> <Chip tone="neutral">
{v === 'above' && 'Above the state-school average'} {v === 'above' && `Above the state-school average (${Math.round(anchor)}%)`}
{v === 'close' && 'About the state-school average'} {v === 'close' && `About the state-school average (${Math.round(anchor)}%)`}
{v === 'below' && 'Below the state-school average'} {v === 'below' && `Below the state-school average (${Math.round(anchor)}%)`}
</Chip> </Chip>
); );
}; };
@@ -51,6 +51,18 @@ function ResultCell({ display }: { display: OfstedDisplay }) {
</> </>
); );
} }
if (display.kind === 'transitional') {
return (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
No overall grade
</span>
<span className={s.small}>
Inspected under transitional framework (sub-judgements only)
</span>
</>
);
}
return ( return (
<> <>
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}> <span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
+7 -1
View File
@@ -102,6 +102,7 @@ export type OfstedDisplay =
| { kind: 'none' } | { kind: 'none' }
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false } | { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true } | { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
| { kind: 'transitional' }
| { kind: 'report_card'; summary: ReportCardSummary }; | { kind: 'report_card'; summary: ReportCardSummary };
export function ofstedDisplay( export function ofstedDisplay(
@@ -117,7 +118,12 @@ export function ofstedDisplay(
const grade = ofsted.overall_effectiveness; const grade = ofsted.overall_effectiveness;
const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined; 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') { if (ofsted.grade_source === 'ungraded_carried_forward') {
return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true }; return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true };
+1
View File
@@ -357,6 +357,7 @@ export interface BenchmarkBlock {
eal_pct: number | null; eal_pct: number | null;
sen_support_pct: number | null; sen_support_pct: number | null;
disadvantaged_pct: number | null; disadvantaged_pct: number | null;
fsm_pct?: number | null;
median_pupils: number | null; median_pupils: number | null;
/** Primary only — weighted by cohort size. */ /** Primary only — weighted by cohort size. */
disadvantaged_rwm_expected_pct?: number | null; disadvantaged_rwm_expected_pct?: number | null;
+3
View File
@@ -49,6 +49,9 @@ plugins:
- name: mi_url - name: mi_url
kind: string kind: string
description: Ofsted Management Information download URL description: Ofsted Management Information download URL
- name: independent_mi_url
kind: string
description: Ofsted Independent Schools Management Information download URL
- name: tap-uk-fbit - name: tap-uk-fbit
namespace: uk_fbit namespace: uk_fbit
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
import io import io
import re import re
@@ -14,20 +15,28 @@ GOV_UK_PAGE = (
"monthly-management-information-ofsteds-school-inspections-outcomes" "monthly-management-information-ofsteds-school-inspections-outcomes"
) )
INDEPENDENT_GOV_UK_PAGE = (
"https://www.gov.uk/government/statistical-data-sets/"
"non-association-independent-schools-inspections-and-outcomes-management-information"
)
# Column name → internal field, in priority order (first match wins). # Column name → internal field, in priority order (first match wins).
# Handles both current and older file formats. # Handles both current and older file formats.
COLUMN_PRIORITY = { COLUMN_PRIORITY = {
"urn": ["URN", "Urn", "urn"], "urn": ["URN", "Urn", "urn"],
"inspection_date": [ "inspection_date": [
"Inspection start date of latest OEIF graded inspection", "Inspection start date of latest OEIF graded inspection",
"Inspection start date of latest OEIF standard inspection",
"Inspection start date", "Inspection start date",
"Inspection date", "Inspection date",
], ],
"inspection_type": [ "inspection_type": [
"Inspection type of latest OEIF graded inspection", "Inspection type of latest OEIF graded inspection",
"Inspection type of latest OEIF standard inspection",
"Inspection type", "Inspection type",
], ],
"event_type_grouping": [ "event_type_grouping": [
"Event type grouping of latest OEIF standard inspection",
"Event type grouping", "Event type grouping",
"Inspection type grouping", "Inspection type grouping",
], ],
@@ -52,10 +61,12 @@ COLUMN_PRIORITY = {
"Effectiveness of leadership and management", "Effectiveness of leadership and management",
], ],
"early_years_provision": [ "early_years_provision": [
"Latest OEIF early years provision (where applicable)",
"Latest OEIF early years provision", "Latest OEIF early years provision",
"Early years provision (where applicable)", "Early years provision (where applicable)",
], ],
"sixth_form_provision": [ "sixth_form_provision": [
"Latest OEIF sixth form provision (where applicable)",
"Latest OEIF sixth form provision", "Latest OEIF sixth form provision",
"Sixth form provision (where applicable)", "Sixth form provision (where applicable)",
], ],
@@ -68,12 +79,7 @@ COLUMN_PRIORITY = {
"ungraded_inspection_date": [ "ungraded_inspection_date": [
"Date of latest ungraded inspection", "Date of latest ungraded inspection",
], ],
# Report Card fields (post-Nov 2025 framework). Confirmed verbatim MI # Report Card fields (post-Nov 2025 framework).
# headers per diagnose_compare_gaps.py's Task 1(c) findings. No MI column
# currently exists for early-years or sixth-form report-card grades, so
# those two fields are deliberately omitted here (see schema below) --
# they stay absent from every record, same as the existing `report_url`
# pattern for fields with no COLUMN_PRIORITY entry.
"rc_safeguarding_met": ["Safeguarding standards"], "rc_safeguarding_met": ["Safeguarding standards"],
"rc_inclusion": ["Inclusion"], "rc_inclusion": ["Inclusion"],
"rc_curriculum_teaching": ["Curriculum and teaching"], "rc_curriculum_teaching": ["Curriculum and teaching"],
@@ -81,6 +87,13 @@ COLUMN_PRIORITY = {
"rc_attendance_behaviour": ["Attendance and behaviour"], "rc_attendance_behaviour": ["Attendance and behaviour"],
"rc_personal_development": ["Personal development and wellbeing"], "rc_personal_development": ["Personal development and wellbeing"],
"rc_leadership_governance": ["Leadership and governance"], "rc_leadership_governance": ["Leadership and governance"],
"rc_early_years": ["Early years (where applicable)"],
"rc_sixth_form": ["Post-16 provision (where applicable)"],
"report_url": [
"Web Link (opens in new window)",
"Web link to Ofsted provider page",
"Web link",
],
} }
@@ -103,6 +116,51 @@ def discover_csv_url() -> str | None:
return matches[0] if matches else None return matches[0] if matches else None
def discover_independent_csv_url() -> str | None:
"""Scrape GOV.UK page to find the latest independent schools MI CSV download link."""
resp = requests.get(INDEPENDENT_GOV_UK_PAGE, timeout=30)
resp.raise_for_status()
# Look for CSV attachment links
csv_links = re.findall(
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
resp.text,
)
if not csv_links:
# Fall back to ODS
csv_links = re.findall(
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
resp.text,
)
months = {
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12
}
parsed_links = []
for link in csv_links:
normalized_link = link.lower().replace('-', '_')
if 'most_recent' not in normalized_link:
continue
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized_link)
if match:
day, month_str, year = match.groups()
month = months.get(month_str)
if month:
try:
dt = datetime(int(year), month, int(day))
parsed_links.append((dt, link))
except ValueError:
continue
parsed_links.sort(reverse=True)
if parsed_links:
return parsed_links[0][1]
return csv_links[0] if csv_links else None
class OfstedInspectionsStream(Stream): class OfstedInspectionsStream(Stream):
"""Stream: Ofsted inspection records.""" """Stream: Ofsted inspection records."""
@@ -131,8 +189,6 @@ class OfstedInspectionsStream(Stream):
th.Property("rc_attendance_behaviour", th.StringType), th.Property("rc_attendance_behaviour", th.StringType),
th.Property("rc_personal_development", th.StringType), th.Property("rc_personal_development", th.StringType),
th.Property("rc_leadership_governance", th.StringType), th.Property("rc_leadership_governance", th.StringType),
# No MI column exists for these yet; declared for forward
# compatibility with the mart schema, always emitted as absent/NULL.
th.Property("rc_early_years", th.StringType), th.Property("rc_early_years", th.StringType),
th.Property("rc_sixth_form", th.StringType), th.Property("rc_sixth_form", th.StringType),
th.Property("report_url", th.StringType), th.Property("report_url", th.StringType),
@@ -148,15 +204,8 @@ class OfstedInspectionsStream(Stream):
break break
return mapping return mapping
def get_records(self, context): def _fetch_and_parse_url(self, url: str, pd) -> list[dict]:
import pandas as pd """Download file and parse records."""
url = self.config.get("mi_url") or discover_csv_url()
if not url:
self.logger.error("Could not discover Ofsted MI download URL")
return
self.logger.info("Downloading Ofsted MI: %s", url)
resp = requests.get(url, timeout=120) resp = requests.get(url, timeout=120)
resp.raise_for_status() resp.raise_for_status()
@@ -172,8 +221,6 @@ class OfstedInspectionsStream(Stream):
lines = text.split("\n") lines = text.split("\n")
header_idx = 0 header_idx = 0
for i, line in enumerate(lines[:20]): for i, line in enumerate(lines[:20]):
# Match lines where URN appears as a CSV field (start or after comma),
# not as a substring of words like "turn" or "return".
if re.search(r'(?:^|,)\s*URN\s*(?:,|$)', line): if re.search(r'(?:^|,)\s*URN\s*(?:,|$)', line):
header_idx = i header_idx = i
break break
@@ -191,16 +238,38 @@ class OfstedInspectionsStream(Stream):
for _, row in df.iterrows(): for _, row in df.iterrows():
record = {} record = {}
for field, col in col_map.items(): for field, col in col_map.items():
record[field] = row.get(col, None) val = row.get(col, None)
if pd.isna(val):
val = None
record[field] = val
# Cast URN # Cast URN
try: try:
record["urn"] = int(record["urn"]) record["urn"] = int(record.get("urn"))
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
continue continue
yield record yield record
def get_records(self, context):
import pandas as pd
# 1. State-funded schools
state_url = self.config.get("mi_url") or discover_csv_url()
if state_url:
self.logger.info("Downloading Ofsted state-funded MI: %s", state_url)
yield from self._fetch_and_parse_url(state_url, pd)
else:
self.logger.error("Could not discover Ofsted state-funded MI download URL")
# 2. Independent schools
ind_url = self.config.get("independent_mi_url") or discover_independent_csv_url()
if ind_url:
self.logger.info("Downloading Ofsted independent MI: %s", ind_url)
yield from self._fetch_and_parse_url(ind_url, pd)
else:
self.logger.error("Could not discover Ofsted independent MI download URL")
class TapUKOfsted(Tap): class TapUKOfsted(Tap):
"""Singer tap for UK Ofsted Management Information.""" """Singer tap for UK Ofsted Management Information."""
@@ -209,6 +278,7 @@ class TapUKOfsted(Tap):
config_jsonschema = th.PropertiesList( config_jsonschema = th.PropertiesList(
th.Property("mi_url", th.StringType, description="Direct URL to Ofsted MI file"), th.Property("mi_url", th.StringType, description="Direct URL to Ofsted MI file"),
th.Property("independent_mi_url", th.StringType, description="Direct URL to Ofsted Independent Schools MI file"),
).to_dict() ).to_dict()
def discover_streams(self): def discover_streams(self):
@@ -46,12 +46,10 @@ renamed as (
{{ parse_report_card_grade('rc_attendance_behaviour') }}::integer as rc_attendance_behaviour, {{ parse_report_card_grade('rc_attendance_behaviour') }}::integer as rc_attendance_behaviour,
{{ parse_report_card_grade('rc_personal_development') }}::integer as rc_personal_development, {{ parse_report_card_grade('rc_personal_development') }}::integer as rc_personal_development,
{{ parse_report_card_grade('rc_leadership_governance') }}::integer as rc_leadership_governance, {{ parse_report_card_grade('rc_leadership_governance') }}::integer as rc_leadership_governance,
-- No MI column exists for these yet (see tap.py); the tap never {{ parse_report_card_grade('rc_early_years') }}::integer as rc_early_years,
-- emits rc_early_years/rc_sixth_form, so these stay NULL. {{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form,
null::integer as rc_early_years,
null::integer as rc_sixth_form,
report_url nullif(trim(report_url), 'NULL') as report_url
from source from source
where urn is not null where urn is not null
and ( and (