Compare commits

..
Author SHA1 Message Date
tudor 2b5e681482 Merge pull request 'fix(site): DfE LA averages for "vs LA avg"; no gap for independents (H2, part 2 of 2)' (#186) from fix/la-averages-site into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m38s
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 26s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m30s
Reviewed-on: #186
2026-10-06 15:07:36 +00:00
tudor 3bd1dbd276 Merge pull request 'fix(pipeline): load 2023/24 KS4/KS2 data and DfE LA averages (C2, H2, part 1 of 2)' (#185) from fix/ks4-2023-24-pipeline into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 0s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 45s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m33s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m6s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 6s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m27s
Reviewed-on: #185
2026-10-06 12:42:19 +00:00
TudorandClaude Opus 5.5 870af949ee test(e2e): prove the independent row against a state row's gap (review)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m26s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
The journey passed whenever the LA map was empty or not yet rendered. It now
waits for a state school in the same LA to show its gap, and both LA journeys
fail rather than skip on an empty map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:51:18 +01:00
TudorandClaude Opus 5.5 5f93a7ecd2 docs: name the benchmarks computed from our dataset (review)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m19s
PR Checks / Backend Smoke (pull_request) Successful in 11s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m36s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m17s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:50:40 +01:00
TudorandClaude Opus 5.5 951c666f8a test(dbt): warn when DfE's LA averages fall behind the school results (review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:50:27 +01:00
TudorandClaude Opus 5.5 e09d7a202b fix(ees): read the year from suffixed release slugs (review)
A '2025-26-revised' release read as an unknown year went last, behind the
'2025-26' release, which then owned the year and dropped every revised row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:50:10 +01:00
TudorandClaude Opus 5.5 c6ff77f05b test(e2e): 2023/24 results and no LA gap for independents (C2, H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:37:02 +01:00
TudorandClaude Opus 5.5 c7ddf0505d fix(search): no LA comparison for independent schools (H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:36:00 +01:00
TudorandClaude Opus 5.5 e659867590 fix(api): serve DfE's LA averages for "vs LA avg" (H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:35:11 +01:00
TudorandClaude Opus 5.5 4ae3853277 fix(dbt): read percentages DfE writes as "34%" (C2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:33:36 +01:00
TudorandClaude Opus 5.5 242c603aec test(dbt): fail the build when a published year loads empty (C2, H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:33:03 +01:00
TudorandClaude Opus 5.5 59dd20ab00 feat(dbt): fact_ks4_la_averages from DfE's LA rows (H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:32:22 +01:00
TudorandClaude Opus 5.5 dbb60acff3 feat(ees): keep DfE's KS4 LA averages from the summary data set (H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:46:53 +01:00
TudorandClaude Opus 5.5 967b1f0eed fix(ees): read 2023/24 KS4 school information under DfE's older names (C2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:45:57 +01:00
TudorandClaude Opus 5.5 4db1131d0f fix(ees): newest KS4 release owns every year it contains (C2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:45:14 +01:00
TudorandClaude Opus 5.5 0e987ef06e docs: plan for 2023/24 KS4/KS2 data and DfE LA averages (C2, H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:34:00 +01:00
TudorandClaude Opus 5.5 ac5b7ccd7f docs: design for 2023/24 KS4/KS2 data and DfE LA averages (C2, H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:08:25 +01:00
tudor c26b65246f Merge pull request 'fix: show the Ofsted grade still in force and the latest visit (C1/M1/M2, part 2 of 2)' (#184) from fix/ofsted-current-status-site into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 0s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m36s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m28s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 5s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m22s
Reviewed-on: #184
2026-10-05 21:47:34 +00:00
tudor 3728a63275 Merge pull request 'feat(pipeline): one current Ofsted status per school (C1/M1, part 1 of 2)' (#183) from fix/ofsted-current-status-pipeline into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m31s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m19s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 6s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m24s
Reviewed-on: #183
2026-10-05 15:44:59 +00:00
tudor b6e48c4930 Merge pull request 'fix(pipeline): decode GIAS extracts as Windows-1252' (#182) from fix/gias-encoding into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m32s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m37s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 5s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m21s
Reviewed-on: #182
2026-10-05 06:31:30 +00:00
tudor 9f4f2507cc Merge pull request 'fix(pipeline): keep the destinations marts out of the scheduled builds' (#181) from fix/scheduled-dbt-selectors into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 47s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m35s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m20s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 3s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m26s
Reviewed-on: #181
2026-10-05 06:19:44 +00:00
TudorandClaude Opus 5.5 94bfac9caf fix(pipeline): decode GIAS extracts as Windows-1252
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m16s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m27s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m17s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 19s
GIAS publishes its CSVs in Windows-1252 and sends no charset. The tap read
resp.text, so requests guessed the codec, and the encoding="latin-1" passed
to read_csv did nothing on already-decoded text. On 3 Oct 2026 the guess was
windows-1250, and "St Thomas à Becket" (138950, 149557) was stored as
"St Thomas ŕ Becket". A different guess on another day would garble other
accented names.

Both streams now decode the downloaded bytes themselves (gias_csv.py). A byte
Windows-1252 leaves undefined becomes U+FFFD with a logged warning instead of
failing the load, so one odd name cannot stop the daily refresh. None of the
nine extracts checked (1 Jul to 3 Oct 2026) contains such a byte.

Checked by running the tap on the real 3 Oct extract with .text forced to
windows-1250: all 52,586 rows decode, with no "ŕ" and no replacement
characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:09:39 +01:00
TudorandClaude Opus 5.5 65a2619e1d fix(pipeline): keep the destinations marts out of the scheduled builds
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m18s
PR Checks / Backend Smoke (pull_request) Successful in 11s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m35s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m21s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 19s
fact_ks4_destinations and fact_ks5_destinations join dim_school, so the
daily build's stg_gias_establishments+ and the monthly Ofsted build's
dim_school+ both selected them. They also read stg_ees_ks4/ks5_destinations,
which only the manually triggered EES DAG builds. Where that DAG hasn't run
since the destinations models landed, dbt_build fails with "relation
staging.stg_ees_ks4_destinations does not exist", and sync_typesense and
invalidate_cache never run. Production's register data has been stuck at
about 25 Aug 2026.

Both builds now exclude the descendants of the two EES staging models, as
the daily build already does for the KS2/KS4 lineage models. The EES DAG
still rebuilds the marts when their data changes.

test_dag_selectors reads the model graph from the SQL (CI has no dbt) and
checks that every scheduled build only reads models it or the daily build
builds. It failed for the daily and monthly Ofsted builds before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:21:27 +01:00
36 changed files with 3237 additions and 115 deletions

No files matched your search

+54 -8
View File
@@ -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") @app.get("/api/la-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute") @limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_la_averages(request: Request): async def get_la_averages(request: Request):
"""Get per-LA average Attainment 8 score for secondary schools in the latest year.""" """DfE's per-LA Attainment 8 averages for the latest year with results."""
df = load_school_data() return _la_averages_payload(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}}
_KS2_NATIONAL_METRICS = [ _KS2_NATIONAL_METRICS = [
+12
View File
@@ -344,6 +344,18 @@ class Ks4NationalAverage(Base):
gcse_grade_91_pct = Column(Float) 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): class Ks2NationalAverage(Base):
"""Official DfE KS2 national headline averages — one row per academic year.""" """Official DfE KS2 national headline averages — one row per academic year."""
__tablename__ = "fact_ks2_national_averages" __tablename__ = "fact_ks2_national_averages"
+119
View File
@@ -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 == <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
+6
View File
@@ -61,6 +61,12 @@ history from the full DataFrame and supplementary data from marts. Comparisons
batch supplementary queries across selected URNs. Async routes still contain batch supplementary queries across selected URNs. Async routes still contain
synchronous dependency calls; a fully asynchronous database layer is not present. synchronous dependency calls; a fully asynchronous database layer is not present.
DfE's official benchmarks live in marts: `fact_ks2_national_averages` and
`fact_ks4_national_averages` for England, and `fact_ks4_la_averages` (all
state-funded schools, per LA) for the search rows' "vs LA avg".
`data_loader.compute_benchmarks` computes further state-school benchmarks from
our own dataset; they are not DfE figures and are labelled as computed.
## Frontend boundaries ## Frontend boundaries
`app/(frontend)` owns the public root layout and pages. `app/(payload)` owns the `app/(frontend)` owns the public root layout and pages. `app/(payload)` owns the
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,300 @@
# 2023/24 Results and DfE LA Averages — Design
**Date:** 2026-10-06
**Status:** approved design, not yet implemented
**Scope:** `tap_uk_ees` (KS4 results, KS4 information, new LA stream),
`safe_numeric`, new `fact_ks4_la_averages` mart, annual EES DAG selector,
`/api/la-averages`, secondary search rows and map cards
**Fixes:** audit findings C2 and H2, from the 3 Oct 2026 accuracy audit
## Goal
Show every 2023/24 result and school-information figure DfE published, and
compare each state-funded secondary school with DfE's own local-authority
average.
## The problem
### C2: 2023/24 is empty
Bishop Stopford School (137086) shows Attainment 8 60.7 for 2022/23, nothing
for 2023/24 and 58.7 for 2024/25. DfE's 2023/24 figures are Attainment 8 64.1,
Progress 8 +1.02 and English and maths grade 4+ 91.7%. 2023/24 is the last year
DfE published Progress 8 (2024/25 has no KS2 baseline), so the site shows no
recent Progress 8 for any school. Site-wide, 4,170 listed schools have a DfE
2023/24 Attainment 8 and 3,384 a Progress 8.
There are three separate causes.
1. **KS4 results.** The 2024/25 release's
`202425_performance_tables_schools_final.csv` is a time series: it holds
2022/23, 2023/24 and 2024/25 under the current column names, with the right
values (Bishop Stopford 2023/24: 64.1, 1.02, 91.7). The 2023/24 release's own
`202324_performance_tables_schools_final.csv`, re-issued on 10 March 2026,
uses the older names (`t_pupils`, `avg_att8`, `avg_p8score`,
`pt_l2basics_94` …). `EESDatasetStream` reads releases in the API's order,
newest first, so the old file is read last. Its rows carry none of the
declared fields, and target-postgres upserts on the stream's primary key
(`append_only = not key_properties` in meltanolabs-target-postgres 0.8.0),
so they overwrite the good 2023/24 rows with nulls.
The two files share the keys of every "Total" row, but 34,254 of the 57,090
2023/24 sub-group rows use different labels ("Low prior" against "Low prior
attainment"). Those old-label rows sit in `raw.ees_ks4_performance` with
null measures. Nothing reads them.
2. **KS4 school information.** 2023/24 information exists only in the 2023/24
release, in `202324_information_about_schools_final.csv`, with the older
names. `EESKS4InfoStream` declares the newer ones, so prior attainment,
SEN percentages, disadvantage gaps and Progress 8 banding are null for
2023/24.
3. **KS2 school information.** The 2023/24 file
`ks2_school_information_data.csv` uses the declared names, and pupil counts
load (school 147411: 818 pupils, 112 eligible). Its percentages are written
with a sign (`ptfsm6cla1a = "34%"`). `safe_numeric` accepts only
`^-?[0-9]+(\.[0-9]+)?$`, so disadvantaged, EAL, SEN and mobility percentages
are null for every school in 2023/24.
DfE published no school-level KS2 information file for 2022/23 (the 2023/24
release's attainment file carries 2022/23 attainment rows only). Those nulls are
a gap in the source, not a defect.
### H2: "vs LA avg" uses the wrong average
`/api/la-averages` (`backend/app.py`) takes an unweighted mean of every school
in the LA with an Attainment 8 score, independent and special schools included.
Independent schools score low because DfE measures exclude IGCSEs, and special
schools score low for other reasons, so the average is too low almost
everywhere. Kensington and Chelsea's "LA avg" is 35.2: the mean of 6 state
schools (54.9) and 8 independent schools (20.4). DfE's figure is 54.5. Of the
151 LAs the audit compared, ours was lower in 147, by 7.1 points on average and
by up to 19.3, so most secondary schools look better than their area.
DfE's LA averages are already in the file the pipeline downloads for the
England averages: the "summary, all state-funded" data set
(`data-catalogue/data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv`). Its
`Local authority` / `All state-funded` / Total rows match DfE's published
performance-table LA averages (RECTYPE 4) for all 152 LAs in 2024/25, with no
difference. `EESKs4NationalStream` keeps the England row and discards them.
"All state-funded" is the same population as the England benchmark the site
already shows.
Computing the average ourselves from state-funded schools, weighted by pupils,
was tested and rejected: it differs from DfE's figure by 1.1 points on average
and is never exact.
## Non-goals
- 2022/23 KS2 school information (DfE published none).
- An "excludes IGCSEs" note wherever an independent school's Attainment 8
appears. This change only stops comparing independent schools with the LA.
- Showing 2023/24 Progress 8 in the GCSE section's headline. The history section
shows it once the data loads; the 2024/25 banner stays true.
- Updating the fixed EES data-set id when DfE publishes 2025/26. It already
feeds the England averages; the LA stream shares it.
- LA comparisons for other measures or on other pages.
- Deleting the leftover old-label raw rows automatically.
## The rules
### The newest release owns every year it contains (KS4 results only)
`EESDatasetStream` gets an opt-in class attribute,
`_newest_release_owns_period: bool = False`. `EESKS4PerformanceStream` sets it
to `True`. When it is on:
- releases are processed newest first by `time_period` (from the release slug),
not in the API's order;
- the stream records each `time_period` it has emitted;
- in each older release, rows whose `time_period` a newer release already
emitted are dropped, and the stream logs how many it skipped and for which
years;
- years only an older release contains are emitted as before.
The filter is a pure function, testable without a download. Other streams keep
the current behaviour. A general rule would be wrong: the 2024/25 KS2 file holds
98,448 of the 955,956 rows the 2023/24 release has for 2023/24.
### KS4 information: old names
`EESKS4InfoStream._column_renames` maps the 2023/24 names onto the declared
fields:
| 2023/24 column | Declared field |
|---|---|
| `t_allks_pupils` | `allks_pupil_count` |
| `t_allks_boys` | `allks_boys_count` |
| `t_allks_girls` | `allks_girls_count` |
| `t_pupils` | `endks4_pupil_count` |
| `avg_ks2_scaledscore` | `ks2_scaledscore_average` |
| `pt_sen_with_ehcp` | `sen_with_ehcp_pupil_percent` |
| `pt_sen` | `sen_pupil_percent` |
| `pt_sen_no_ehcp` | `sen_no_ehcp_pupil_percent` |
| `diffn_att8` | `attainment8_diffn` |
| `diffn_p8mea` | `progress8_diffn` |
| `p8_banding` | `progress8_banding` |
Newer files contain none of the old names, so they are unaffected.
### `safe_numeric` accepts a trailing `%`
The pattern becomes `^-?[0-9]+(\.[0-9]+)?%?$` and the cast reads
`rtrim(col, '%')`. A value such as `34%` can only mean 34. Suppression codes
(`c`, `z`, `x` …) still become null.
### LA averages
A new stream, `ees_ks4_la`, reads the same CSV as `ees_ks4_national` and keeps
rows where `geographic_level = 'Local authority'`,
`establishment_type_group = 'All state-funded'`, `breakdown_topic = 'Total'` and
`breakdown = 'Total'` (case-insensitive, as the national stream compares). It
emits `time_period`, `old_la_code`, `new_la_code`, `la_name` and the 8 headline
measures the national stream emits (`_KS4_NATIONAL_COL_MAP`). Primary key:
(`time_period`, `old_la_code`). The two streams share one download-and-filter
helper.
`old_la_code` is the GIAS LA code (`local_authority_code`), so schools join on
the code, not the name. Names match today for every LA DfE publishes; DfE
publishes no figure for City of London.
### Which schools get a gap
The search row and the map card show "vs LA avg" only when the school has an
Attainment 8 score, is neither special (`isSpecialSchool`) nor independent
(`isIndependentSchool`: "independent" in the GIAS type, which covers "Other
independent school" and "Other independent special school"), and its LA has a
DfE figure for the year the endpoint serves.
## Delivery
Two PRs, as for C1.
### PR 1: pipeline
- `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py`: release
precedence, KS4 information renames, `ees_ks4_la` stream registered in
`discover_streams`, shared national/LA helper.
- `pipeline/transform/macros/safe_numeric.sql`: trailing `%`.
- `pipeline/transform/models/staging/`: source `raw.ees_ks4_la` and
`stg_ees_ks4_la` (view): `cast(old_la_code as integer) as la_code`,
`cast(time_period as integer) as year`, `la_name`, measures via
`safe_numeric`, named as in `stg_ees_ks4_national`.
- `pipeline/transform/models/marts/fact_ks4_la_averages.sql` (table): one row
per (`year`, `la_code`) with `la_name` and the columns of
`fact_ks4_national_averages`. Schema tests: unique (`year`, `la_code`);
`year`, `la_code` and `la_name` not null.
- Data tests in `pipeline/transform/tests/`:
- `assert_ks4_years_have_results`: every year in `stg_ees_ks4` has a non-null
Attainment 8 for at least 50% of its rows. DfE's files reach 82% each year;
2023/24 loads at 0% today. Pre-2019 years come from the legacy model and are
not tested.
- `assert_ks2_info_percentages_loaded`: for every year in `stg_ees_ks2` where
at least 1,000 rows have `total_pupils`, at least 90% of those rows have
`disadvantaged_pct`. DfE's files reach 96–97%. 2022/23 has no pupil counts
and is skipped.
- `assert_ks4_la_averages_cover_las`: the latest year in
`fact_ks4_la_averages` has at least 145 LAs (DfE: 152).
- `pipeline/dags/school_data_pipeline.py`: the annual EES build selects
`stg_ees_ks4_la+`.
- `docs/ARCHITECTURE.md`: LA averages come from DfE's data set.
### PR 2: backend and UI (after the EES DAG has run on PR 1)
- `backend/models.py`: `Ks4LaAverage` for `marts.fact_ks4_la_averages`.
- `backend/app.py` `/api/la-averages`: the year is the latest with any school
Attainment 8 (as now). It reads that year's rows from the mart and keys each
`attainment_8_score` by our LA name, through the `local_authority_code` →
`local_authority` pairs in the school data. The response shape is unchanged.
No rows for that year, a missing table or a query error give an empty map,
logged, never another year's figures and never a computed mean.
- `nextjs-app/lib/utils.ts`: `isIndependentSchool(school)`.
- `nextjs-app/components/SecondarySchoolRow.tsx` and
`nextjs-app/components/LeafletMapInner.tsx`: the rule in "Which schools get a
gap".
- `e2e/tests/journeys.spec.ts`: the two journeys under Testing.
## Testing
**Extractor (pytest, `pipeline/tests/`, new):**
- precedence: with the flag on, a year in a newer release is emitted once, from
the newer release; a year only an older release has is kept; with the flag
off, every row passes;
- releases arriving oldest first are still processed newest first;
- KS4 information: an old-format row yields `endks4_pupil_count`,
`ks2_scaledscore_average`, `progress8_banding` and the rest of the table;
- LA filter: a small CSV with national, regional, LA and sub-group rows yields
one row per LA and year, with the declared fields.
**dbt (local `pgserver`, as for C1):**
- unit test on `stg_ees_ks2`: `34%` → 34, `34` → 34, `c` → null;
- unit test on `stg_ees_ks4_la` or the mart: codes and years cast, measures
carried;
- the three data tests and the schema tests above;
- `pipeline/tests/test_dag_selectors.py` passes with the new selector.
**Backend (pytest):**
- the response gives the mart's figure where the fixture's plain mean differs;
- matching works by code when the mart's `la_name` differs from ours;
- a mart without the served year, and a missing table, give an empty map.
**Front end (Jest):**
- `isIndependentSchool` for "Other independent school", "Other independent
special school", "Academy converter" and null;
- `SecondarySchoolRow` and the map card show no gap for an independent school
and show one for a state school with an LA figure.
**E2E (`journeys.spec.ts`, PR 2):**
- C2: Bishop Stopford (137086) history shows 2023/24 Attainment 8 64.1 and
Progress 8 +1.02. These are final figures, so the journey stays stable.
- H2: a search returning an independent and a state secondary: the independent
row has no "vs LA avg", the state row has one. The exact gap is not asserted:
DfE's 2025/26 provisional KS4 data is due and would change it.
## Rollout and verification
1. PR 1 merges to staging. Tudor runs `school_data_annual_ees` on staging.
2. Through the staging API: Bishop Stopford 2023/24 Attainment 8 64.1 and
Progress 8 1.02; school 147411 2023/24 disadvantaged 34; the LA mart has 152
LAs for 2024/25. If a data test fails, the build stops before search sync;
investigate before going further.
3. PR 2 merges, so the post-merge E2E gate runs against loaded data.
4. Production, Tudor's decision, in order: promote PR 1, run the EES DAG on
production, promote PR 2. If PR 2 arrives first, the endpoint returns an
empty map and rows show no gap, never a wrong one.
5. Optional cleanup, in the PR 1 description for Tudor:
`delete from raw.ees_ks4_performance where time_period = '202324' and pupil_count is null`.
New-format rows always have `pupil_count` (suppressed values are `c` or `z`,
not null), so this removes exactly the old-label leftovers.
6. Validation on production against DfE's files: every listed school's 2023/24
Attainment 8, Progress 8 and English and maths figures match; the 2023/24 KS4
and KS2 information fields match; `/api/la-averages` equals DfE for all 152
LAs; independent rows show no gap. Then mark C2 and H2 resolved in the audit
report.
## Expected visible change
- Secondary history charts and tables run unbroken from 2022/23 to 2024/25, and
2023/24 Progress 8 appears for about 3,400 schools.
- 2023/24 school information (KS2 and KS4) fills in.
- "vs LA avg" drops for most state secondaries. In Kensington and Chelsea, a
school with Attainment 8 60.0 moves from +24.8 to +5.5.
- Independent schools and City of London schools show no LA gap.
## Risks
- **Data-test thresholds stop a good build.** They were set from DfE's own
files (82% and 96–97% against 50% and 90%). A failure means the data changed
shape, which is what they are for.
- **`safe_numeric` is shared by 14 models.** Values written as `n%` were null
and become numbers. No DfE column uses `%` for anything but a percentage.
The staging EES run is the check.
- **Precedence drops data an older release holds more completely.** It is
opt-in for KS4 results, where both files hold the same 57,090 2023/24 keys.
- **The fixed EES data-set id goes stale** when 2025/26 is published. The
endpoint's year check then gives no gap rather than a mismatched one.
+50 -1
View File
@@ -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. // guards the comparison itself; the unit test pins the cache mode.
const la = await (await page.request.get('/api/la-averages')).json(); const la = await (await page.request.get('/api/la-averages')).json();
const averages: Record<string, number> = la.secondary?.attainment_8_by_la ?? {}; const averages: Record<string, number> = 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'); const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=50');
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
const school = ((await res.json()).schools ?? []).find( const school = ((await res.json()).schools ?? []).find(
(s: { attainment_8_score?: number | null; local_authority?: string; school_type?: string }) => (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 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'); test.skip(!school, 'no mainstream secondary with an LA average here');
await searchByName(page, school.school_name); 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(); 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<string, number> = 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 }) => { 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 // The search page offers every GIAS phase, but the API only knew the grouped
// ones and silently dropped the rest — so "Nursery" returned primaries. // ones and silently dropped the rest — so "Nursery" returned primaries.
@@ -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.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1); 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/);
});
@@ -116,3 +116,38 @@ describe('SecondarySchoolRow shares the school page flags', () => {
expect(screen.getByText('Fee-paying')).toBeInTheDocument(); expect(screen.getByText('Fee-paying')).toBeInTheDocument();
}); });
}); });
describe('SecondarySchoolRow LA comparison', () => {
it('compares a state school with its LA average', () => {
render(
<SecondarySchoolRow
school={{ ...base, school_type: 'Academy converter', attainment_8_score: 60 }}
laAvgAttainment8={54.5}
/>,
);
expect(screen.getByText(/\+5\.5 vs LA avg/)).toBeInTheDocument();
});
it('keeps the comparison for a school whose type is unknown', () => {
render(
<SecondarySchoolRow
school={{ ...base, school_type: null, attainment_8_score: 60 } as unknown as School}
laAvgAttainment8={54.5}
/>,
);
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(
<SecondarySchoolRow
school={{ ...base, school_type: 'Other independent school', attainment_8_score: 20.4 }}
laAvgAttainment8={54.5}
/>,
);
expect(screen.getByText('20.4')).toBeInTheDocument();
expect(screen.queryByText(/vs LA avg/)).not.toBeInTheDocument();
});
});
+17
View File
@@ -391,3 +391,20 @@ describe('singleSexLabel', () => {
expect(singleSexLabel(undefined)).toBeNull(); 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);
});
});
+2 -2
View File
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import type { School } from '@/lib/types'; 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 { interface LeafletMapInnerProps {
schools: School[]; schools: School[];
@@ -70,7 +70,7 @@ function metricHtml(school: School, { nationalAvgRwm, laAverages }: CardContext)
const score = school.attainment_8_score; const score = school.attainment_8_score;
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null; const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
let delta = ''; let delta = '';
if (!special && laAvg != null) { if (!special && !isIndependentSchool(school) && laAvg != null) {
const diff = Math.round((score - laAvg) * 10) / 10; const diff = Math.round((score - laAvg) * 10) / 10;
// Att8 runs 0–90 in 0.1 steps; ±0.5 is meaningful, where RWM needs ±2. // 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' : ''; const cls = diff >= 0.5 ? 'sc-up' : diff <= -0.5 ? 'sc-down' : '';
+6 -4
View File
@@ -11,7 +11,7 @@
'use client'; 'use client';
import type { School } from '@/lib/types'; 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 { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import styles from './SecondarySchoolRow.module.css'; import styles from './SecondarySchoolRow.module.css';
@@ -45,10 +45,12 @@ export function SecondarySchoolRow({
const att8 = school.attainment_8_score; const att8 = school.attainment_8_score;
// The school's own Attainment 8 is a same-school figure — shown whenever it // 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). // exists (special schools included; their type tag on line 2 gives context).
// Only the vs-LA-average delta, a benchmark comparison, is dropped for // Only the vs-LA-average delta, a benchmark comparison, is dropped: for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly. // 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 = const laDelta =
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null att8 != null && !isSpecialSchool(school) && !isIndependentSchool(school) && laAvgAttainment8 != null
? att8 - laAvgAttainment8 ? att8 - laAvgAttainment8
: null; : null;
+10
View File
@@ -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); 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 * 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 * "Not applicable" are the register's ways of saying it has none; the place
+6 -3
View File
@@ -106,9 +106,12 @@ print(f'Validation passed: {{count}} GIAS rows')
""", """,
) )
# Marts fed by annual EES staging models are rebuilt by the EES DAG, even
# when they join dim_school. Selecting them here fails in any database
# where that DAG hasn't run (pipeline/tests/test_dag_selectors.py).
dbt_build = BashOperator( dbt_build = BashOperator(
task_id="dbt_build", task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_gias_establishments+ stg_gias_links+ gias_code_names+ --exclude int_ks2_with_lineage+ int_ks4_with_lineage+", bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_gias_establishments+ stg_gias_links+ gias_code_names+ --exclude int_ks2_with_lineage+ int_ks4_with_lineage+ stg_ees_ks4_destinations+ stg_ees_ks5_destinations+",
) )
sync_typesense = BashOperator( sync_typesense = BashOperator(
@@ -143,7 +146,7 @@ with DAG(
dbt_build_ofsted = BashOperator( dbt_build_ofsted = BashOperator(
task_id="dbt_build", task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ofsted_inspections+ int_ofsted_latest+ fact_ofsted_inspection+ dim_school+", bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ofsted_inspections+ int_ofsted_latest+ fact_ofsted_inspection+ dim_school+ --exclude stg_ees_ks4_destinations+ stg_ees_ks5_destinations+",
) )
sync_typesense_ofsted = BashOperator( sync_typesense_ofsted = BashOperator(
@@ -190,7 +193,7 @@ with DAG(
dbt_build_ees = BashOperator( dbt_build_ees = BashOperator(
task_id="dbt_build", task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+ stg_ees_ks4_destinations+ stg_ees_ks5_destinations+ stg_ees_ks4_destinations_national+ stg_ees_ks5_destinations_national+", bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+ stg_ees_ks4_la+ stg_ees_ks4_destinations+ stg_ees_ks5_destinations+ stg_ees_ks4_destinations_national+ stg_ees_ks5_destinations_national+",
) )
sync_typesense_ees = BashOperator( sync_typesense_ees = BashOperator(
@@ -0,0 +1,47 @@
"""KS4 school information: the fields the stream declares, and the older
names DfE used for them.
2023/24 information exists only in the 2023/24 release, whose
202324_information_about_schools_final.csv (re-issued 10 March 2026) uses the
older names. Without the renames every 2023/24 field loaded as null (audit C2).
Newer files contain none of the older names, so the renames leave them alone.
Free of the Singer SDK so CI's pytest can load it.
"""
# Declared Singer fields besides the required time_period and school_urn.
KS4_INFO_FIELDS = (
"school_laestab",
"school_name",
"establishment_type_group",
"reldenom",
"admpol_pt",
"egender",
"agerange",
"allks_pupil_count",
"allks_boys_count",
"allks_girls_count",
"endks4_pupil_count",
"ks2_scaledscore_average",
"sen_with_ehcp_pupil_percent",
"sen_pupil_percent",
"sen_no_ehcp_pupil_percent",
"attainment8_diffn",
"progress8_diffn",
"progress8_banding",
)
# 2023/24 column name → declared field.
KS4_INFO_RENAMES = {
"t_allks_pupils": "allks_pupil_count",
"t_allks_boys": "allks_boys_count",
"t_allks_girls": "allks_girls_count",
"t_pupils": "endks4_pupil_count",
"avg_ks2_scaledscore": "ks2_scaledscore_average",
"pt_sen_with_ehcp": "sen_with_ehcp_pupil_percent",
"pt_sen": "sen_pupil_percent",
"pt_sen_no_ehcp": "sen_no_ehcp_pupil_percent",
"diffn_att8": "attainment8_diffn",
"diffn_p8mea": "progress8_diffn",
"p8_banding": "progress8_banding",
}
@@ -0,0 +1,56 @@
"""DfE's KS4 "summary, all state-funded" data set (Key stage 4 performance).
One CSV holds England, regional and local-authority headline rows for every
year since 2018/19. The England stream (ees_ks4_national) and the LA stream
(ees_ks4_la) both read it. Its LA rows match DfE's published performance-table
LA averages exactly (audit H2). Suppressed values ('z', 'x') become NULL in
dbt; Progress 8 is 'z' in years with no KS2 baseline (2024/25): DfE policy,
not missing data.
Free of the Singer SDK so CI's pytest can load it.
"""
from __future__ import annotations
import pandas as pd
KS4_SUMMARY_CSV_URL = (
"https://explore-education-statistics.service.gov.uk/data-catalogue/"
"data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv"
)
# CSV column → Singer field: the same 8 headline measures at every level.
KS4_HEADLINE_COL_MAP = {
"attainment8_average": "attainment_8_score",
"progress8_average": "progress_8_score",
"engmath_94_percent": "english_maths_standard_pass_pct",
"engmath_95_percent": "english_maths_strong_pass_pct",
"ebacc_entering_percent": "ebacc_entry_pct",
"ebacc_94_percent": "ebacc_standard_pass_pct",
"ebacc_95_percent": "ebacc_strong_pass_pct",
"ebacc_aps_average": "ebacc_avg_score",
}
def headline_rows(df: pd.DataFrame, geographic_level: str) -> pd.DataFrame:
"""All-pupil rows for all state-funded schools at one geographic level
("National" or "Local authority"). Column names are lower-cased first;
a filter column the file lacks is not applied."""
df = df.copy()
df.columns = [c.strip().lower() for c in df.columns]
for col, want in (
("geographic_level", geographic_level),
("establishment_type_group", "All state-funded"),
("breakdown_topic", "Total"),
("breakdown", "Total"),
):
if col in df.columns:
df = df[df[col].str.strip().str.lower() == want.lower()]
return df
def headline_record(row: pd.Series, keys: tuple[str, ...]) -> dict[str, str]:
"""A Singer record: the identifying columns, then the headline measures."""
record = {key: str(row.get(key, "")).strip() for key in keys}
for csv_col, field in KS4_HEADLINE_COL_MAP.items():
record[field] = str(row.get(csv_col, "")).strip()
return record
@@ -0,0 +1,59 @@
"""Which release a row comes from when DfE re-publishes a year.
DfE re-publishes earlier years inside later releases: the 2024/25 KS4 results
file holds 2022/23, 2023/24 and 2024/25. The 2023/24 release's own file,
re-issued on 10 March 2026 under older column names, was read after it and
overwrote every 2023/24 row with blanks (audit C2). A stream that opts in
treats the newest release as the authority for every year it contains.
Free of the Singer SDK so CI's pytest, which installs only the backend's
requirements, can load it.
"""
from __future__ import annotations
import re
import pandas as pd
_SLUG_YEAR = re.compile(r"^(\d{4})-(\d{2})(?:-|$)")
def slug_to_time_period(slug: str) -> str | None:
"""A release slug's academic year as a time_period: '2022-23' → '202223'.
Suffixed slugs ('2024-25-revised', '2025-26-provisional') give the same
year, so newest_first places them by year; read as unknown, a revised
release went last and lost its year to the first release.
"""
match = _SLUG_YEAR.match(slug or "")
return match.group(1) + match.group(2) if match else None
def newest_first(releases: list[dict]) -> list[dict]:
"""Releases by time_period, newest first. A release whose time_period is
unknown goes last, in the order given."""
dated = [r for r in releases if r.get("time_period")]
undated = [r for r in releases if not r.get("time_period")]
return sorted(dated, key=lambda r: r["time_period"], reverse=True) + undated
def periods_in(df: pd.DataFrame) -> set[str]:
"""The years a release's rows cover."""
if "time_period" not in df.columns:
return set()
return set(df["time_period"].astype(str).str.strip()) - {""}
def drop_owned_periods(
df: pd.DataFrame, owned: set[str]
) -> tuple[pd.DataFrame, dict[str, int]]:
"""Drop the rows for years a newer release already supplied.
Returns the rows kept and, for each year dropped, how many rows went.
"""
if "time_period" not in df.columns or not owned:
return df, {}
periods = df["time_period"].astype(str).str.strip()
dropped = periods.isin(owned)
skipped = {str(k): int(v) for k, v in periods[dropped].value_counts().items()}
return df[~dropped], skipped
@@ -17,6 +17,20 @@ import requests
from singer_sdk import Stream, Tap from singer_sdk import Stream, Tap
from singer_sdk import typing as th from singer_sdk import typing as th
from tap_uk_ees.ks4_info import KS4_INFO_FIELDS, KS4_INFO_RENAMES
from tap_uk_ees.ks4_summary import (
KS4_HEADLINE_COL_MAP,
KS4_SUMMARY_CSV_URL,
headline_record,
headline_rows,
)
from tap_uk_ees.release_precedence import (
drop_owned_periods,
newest_first,
periods_in,
slug_to_time_period,
)
CONTENT_API_BASE = ( CONTENT_API_BASE = (
"https://content.explore-education-statistics.service.gov.uk/api" "https://content.explore-education-statistics.service.gov.uk/api"
) )
@@ -31,14 +45,6 @@ def get_content_release_id(publication_slug: str) -> str:
return resp.json()["id"] return resp.json()["id"]
def _slug_to_time_period(slug: str) -> str | None:
"""Convert a release slug like '2022-23' to a time_period like '202223'."""
parts = slug.split("-")
if len(parts) == 2 and len(parts[0]) == 4 and len(parts[1]) == 2:
return parts[0] + parts[1]
return None
def get_all_releases(publication_slug: str) -> list[dict]: def get_all_releases(publication_slug: str) -> list[dict]:
"""Return all releases for a publication as dicts with 'id' and 'time_period'. """Return all releases for a publication as dicts with 'id' and 'time_period'.
@@ -63,7 +69,7 @@ def get_all_releases(publication_slug: str) -> list[dict]:
total_pages = paging.get("totalPages", 1) total_pages = paging.get("totalPages", 1)
for r in releases: for r in releases:
time_period = _slug_to_time_period(r.get("slug", "")) time_period = slug_to_time_period(r.get("slug", ""))
result.append({"id": r["id"], "time_period": time_period}) result.append({"id": r["id"], "time_period": time_period})
if page >= total_pages: if page >= total_pages:
@@ -88,6 +94,8 @@ class EESDatasetStream(Stream):
target CSV path inside the ZIP (substring match, not exact). target CSV path inside the ZIP (substring match, not exact).
Subclasses may set _column_renames to map messy CSV column names to Subclasses may set _column_renames to map messy CSV column names to
clean Singer field names before yielding records. clean Singer field names before yielding records.
Subclasses may set _newest_release_owns_period when DfE re-publishes
earlier years in later releases and the newest copy is the authority.
""" """
replication_key = None replication_key = None
@@ -96,6 +104,7 @@ class EESDatasetStream(Stream):
_urn_column: str = "school_urn" # column name for URN in the CSV _urn_column: str = "school_urn" # column name for URN in the CSV
_encoding: str = "utf-8" # CSV file encoding (some DfE files use latin-1) _encoding: str = "utf-8" # CSV file encoding (some DfE files use latin-1)
_column_renames: dict = {} # CSV column name → Singer field name _column_renames: dict = {} # CSV column name → Singer field name
_newest_release_owns_period: bool = False # see release_precedence.py
def get_records(self, context): def get_records(self, context):
import pandas as pd import pandas as pd
@@ -110,6 +119,9 @@ class EESDatasetStream(Stream):
self.logger.info( self.logger.info(
"Found %d release(s) for %s", len(releases), self._publication_slug "Found %d release(s) for %s", len(releases), self._publication_slug
) )
if self._newest_release_owns_period:
releases = newest_first(releases)
owned_periods: set[str] = set()
for release in releases: for release in releases:
release_id = release["id"] release_id = release["id"]
@@ -163,6 +175,15 @@ class EESDatasetStream(Stream):
if urn_col in df.columns: if urn_col in df.columns:
df = df[df[urn_col].notna() & (df[urn_col] != "")] df = df[df[urn_col].notna() & (df[urn_col] != "")]
if self._newest_release_owns_period:
df, skipped = drop_owned_periods(df, owned_periods)
for period, count in sorted(skipped.items()):
self.logger.info(
"Skipping %d rows for %s from release %s: a newer release supplied that year",
count, period, release_id,
)
owned_periods |= periods_in(df)
self.logger.info("Emitting %d school-level rows from release %s", len(df), release_id) self.logger.info("Emitting %d school-level rows from release %s", len(df), release_id)
for _, row in df.iterrows(): for _, row in df.iterrows():
@@ -251,6 +272,9 @@ class EESKS4PerformanceStream(EESDatasetStream):
primary_keys = ["school_urn", "time_period", "breakdown_topic", "breakdown", "sex"] primary_keys = ["school_urn", "time_period", "breakdown_topic", "breakdown", "sex"]
_publication_slug = "key-stage-4-performance" _publication_slug = "key-stage-4-performance"
_target_filename = "performance_tables_schools" _target_filename = "performance_tables_schools"
# DfE's 2024/25 file re-publishes 2022/23 and 2023/24 under current names;
# the 2023/24 release's own file uses older ones (audit C2).
_newest_release_owns_period = True
schema = th.PropertiesList( schema = th.PropertiesList(
th.Property("time_period", th.StringType, required=True), th.Property("time_period", th.StringType, required=True),
th.Property("school_urn", th.StringType, required=True), th.Property("school_urn", th.StringType, required=True),
@@ -310,34 +334,20 @@ class EESKS4PerformanceStream(EESDatasetStream):
# ── KS4 Information (wide format: one row per school, context/demographics) ── # ── KS4 Information (wide format: one row per school, context/demographics) ──
# File: 202425_information_about_schools_provisional.csv (38 cols) # Files: 202425_information_about_schools_final.csv (38 cols, current names);
# 202324_information_about_schools_final.csv (60 cols, older names — the only
# source of 2023/24 information). Field list and renames: ks4_info.py.
class EESKS4InfoStream(EESDatasetStream): class EESKS4InfoStream(EESDatasetStream):
name = "ees_ks4_info" name = "ees_ks4_info"
primary_keys = ["school_urn", "time_period"] primary_keys = ["school_urn", "time_period"]
_publication_slug = "key-stage-4-performance" _publication_slug = "key-stage-4-performance"
_target_filename = "information_about_schools" _target_filename = "information_about_schools"
_column_renames = KS4_INFO_RENAMES
schema = th.PropertiesList( schema = th.PropertiesList(
th.Property("time_period", th.StringType, required=True), th.Property("time_period", th.StringType, required=True),
th.Property("school_urn", th.StringType, required=True), th.Property("school_urn", th.StringType, required=True),
th.Property("school_laestab", th.StringType), *[th.Property(field, th.StringType) for field in KS4_INFO_FIELDS],
th.Property("school_name", th.StringType),
th.Property("establishment_type_group", th.StringType),
th.Property("reldenom", th.StringType),
th.Property("admpol_pt", th.StringType),
th.Property("egender", th.StringType),
th.Property("agerange", th.StringType),
th.Property("allks_pupil_count", th.StringType),
th.Property("allks_boys_count", th.StringType),
th.Property("allks_girls_count", th.StringType),
th.Property("endks4_pupil_count", th.StringType),
th.Property("ks2_scaledscore_average", th.StringType),
th.Property("sen_with_ehcp_pupil_percent", th.StringType),
th.Property("sen_pupil_percent", th.StringType),
th.Property("sen_no_ehcp_pupil_percent", th.StringType),
th.Property("attainment8_diffn", th.StringType),
th.Property("progress8_diffn", th.StringType),
th.Property("progress8_banding", th.StringType),
).to_dict() ).to_dict()
@@ -564,37 +574,23 @@ class EESKs2NationalStream(Stream):
yield record yield record
# ── KS4 National Headlines (national level only — one row per year) ────────── # ── KS4 National and LA Headlines (one data set, two streams) ────────────────
# Dataset: "National characteristics summary data" (Key stage 4 performance). # DfE's "summary, all state-funded" data set: England, regional and LA rows,
# Official England state-funded headline measures, 2018/19 → latest. # 2018/19 → latest. URL, measures and filters: ks4_summary.py.
# Suppressed values ('z', 'x') → NULL downstream. Progress 8 is legitimately
# absent in years with no KS2 baseline (e.g. 2024/25) — that is DfE policy,
# not missing data.
_KS4_NATIONAL_CSV_URL = ( def _read_ks4_summary(logger):
"https://explore-education-statistics.service.gov.uk/data-catalogue/" """Download DfE's KS4 summary data set."""
"data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv" import pandas as pd
)
_KS4_NATIONAL_COL_MAP = { logger.info("Downloading KS4 summary data set: %s", KS4_SUMMARY_CSV_URL)
"attainment8_average": "attainment_8_score", resp = requests.get(KS4_SUMMARY_CSV_URL, timeout=60)
"progress8_average": "progress_8_score", resp.raise_for_status()
"engmath_94_percent": "english_maths_standard_pass_pct", return pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
"engmath_95_percent": "english_maths_strong_pass_pct",
"ebacc_entering_percent": "ebacc_entry_pct",
"ebacc_94_percent": "ebacc_standard_pass_pct",
"ebacc_95_percent": "ebacc_strong_pass_pct",
"ebacc_aps_average": "ebacc_avg_score",
}
class EESKs4NationalStream(Stream): class EESKs4NationalStream(Stream):
"""National KS4 headline averages — one row per academic year. """National KS4 headline averages — one row per academic year (England,
all state-funded schools, all pupils)."""
Filters to geographic_level == 'National', establishment_type_group ==
'All state-funded', breakdown_topic == 'Total', breakdown == 'Total'
so only the England-wide all-pupils row per year is emitted.
"""
name = "ees_ks4_national" name = "ees_ks4_national"
primary_keys = ["time_period"] primary_keys = ["time_period"]
@@ -602,34 +598,41 @@ class EESKs4NationalStream(Stream):
schema = th.PropertiesList( schema = th.PropertiesList(
th.Property("time_period", th.StringType, required=True), th.Property("time_period", th.StringType, required=True),
*[th.Property(out, th.StringType) for out in _KS4_NATIONAL_COL_MAP.values()], *[th.Property(out, th.StringType) for out in KS4_HEADLINE_COL_MAP.values()],
).to_dict() ).to_dict()
def get_records(self, context): def get_records(self, context):
import pandas as pd df = headline_rows(_read_ks4_summary(self.logger), "National")
self.logger.info("Downloading KS4 national headlines: %s", _KS4_NATIONAL_CSV_URL)
resp = requests.get(_KS4_NATIONAL_CSV_URL, timeout=60)
resp.raise_for_status()
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
df.columns = [c.strip().lower() for c in df.columns]
for col, want in [
("geographic_level", "national"),
("establishment_type_group", "all state-funded"),
("breakdown_topic", "total"),
("breakdown", "total"),
]:
if col in df.columns:
df = df[df[col].str.strip().str.lower() == want]
self.logger.info("Emitting %d national KS4 rows", len(df)) self.logger.info("Emitting %d national KS4 rows", len(df))
for _, row in df.iterrows(): for _, row in df.iterrows():
record = {"time_period": row.get("time_period", "").strip()} yield headline_record(row, ("time_period",))
for csv_col, field in _KS4_NATIONAL_COL_MAP.items():
record[field] = row.get(csv_col, "").strip()
yield record class EESKs4LaStream(Stream):
"""DfE's KS4 local-authority averages — one row per academic year and LA
(all state-funded schools, all pupils), from the same data set as
ees_ks4_national. They match DfE's published performance-table LA averages
and replace a mean the API took over every school, independent and special
included (audit H2). old_la_code is the GIAS LA code: schools join on it,
not on the name."""
name = "ees_ks4_la"
primary_keys = ["time_period", "old_la_code"]
replication_key = None
schema = th.PropertiesList(
th.Property("time_period", th.StringType, required=True),
th.Property("old_la_code", th.StringType, required=True),
th.Property("new_la_code", th.StringType),
th.Property("la_name", th.StringType),
*[th.Property(out, th.StringType) for out in KS4_HEADLINE_COL_MAP.values()],
).to_dict()
def get_records(self, context):
df = headline_rows(_read_ks4_summary(self.logger), "Local authority")
self.logger.info("Emitting %d LA KS4 rows", len(df))
for _, row in df.iterrows():
yield headline_record(row, ("time_period", "old_la_code", "new_la_code", "la_name"))
# ── Legacy KS2 (pre-COVID wide format from DfE performance tables) ──────────── # ── Legacy KS2 (pre-COVID wide format from DfE performance tables) ────────────
@@ -972,6 +975,7 @@ class TapUKEES(Tap):
LegacyKS4Stream(self), LegacyKS4Stream(self),
EESKs2NationalStream(self), EESKs2NationalStream(self),
EESKs4NationalStream(self), EESKs4NationalStream(self),
EESKs4LaStream(self),
] ]
@@ -0,0 +1,26 @@
"""Read a GIAS extract from the raw bytes of the download.
GIAS writes its CSVs in Windows-1252 and sends no charset, so `resp.text`
leaves requests to guess the codec. On 3 Oct 2026 it guessed windows-1250 and
"à" became "ŕ". Decode the bytes ourselves instead.
"""
from __future__ import annotations
import io
import pandas as pd
GIAS_ENCODING = "cp1252"
def read_gias_csv(content: bytes, logger=None) -> pd.DataFrame:
"""Every column as a string; a blank cell stays ''."""
# Windows-1252 leaves five bytes undefined. One stray byte must not stop
# the daily refresh of every school, so it becomes U+FFFD and is logged.
text = content.decode(GIAS_ENCODING, errors="replace")
undecodable = text.count("�")
if undecodable and logger is not None:
logger.warning("%d byte(s) in the GIAS extract could not be decoded as %s",
undecodable, GIAS_ENCODING)
return pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False)
@@ -7,6 +7,8 @@ from datetime import date, timedelta
from singer_sdk import Stream, Tap from singer_sdk import Stream, Tap
from singer_sdk import typing as th from singer_sdk import typing as th
from tap_uk_gias.gias_csv import read_gias_csv
GIAS_URL_TEMPLATE = ( GIAS_URL_TEMPLATE = (
"https://ea-edubase-api-prod.azurewebsites.net" "https://ea-edubase-api-prod.azurewebsites.net"
"/edubase/downloads/public/edubasealldata{date}.csv" "/edubase/downloads/public/edubasealldata{date}.csv"
@@ -74,9 +76,6 @@ class GIASEstablishmentsStream(Stream):
def get_records(self, context): def get_records(self, context):
"""Download GIAS CSV and yield rows.""" """Download GIAS CSV and yield rows."""
import io
import pandas as pd
import requests import requests
today = date.today() today = date.today()
@@ -94,12 +93,7 @@ class GIASEstablishmentsStream(Stream):
resp.raise_for_status() resp.raise_for_status()
df = pd.read_csv( df = read_gias_csv(resp.content, self.logger)
io.StringIO(resp.text),
encoding="latin-1",
dtype=str,
keep_default_na=False,
)
for _, row in df.iterrows(): for _, row in df.iterrows():
record = row.to_dict() record = row.to_dict()
@@ -126,9 +120,6 @@ class GIASLinksStream(Stream):
def get_records(self, context): def get_records(self, context):
"""Download GIAS links CSV and yield rows.""" """Download GIAS links CSV and yield rows."""
import io
import pandas as pd
import requests import requests
today = date.today() today = date.today()
@@ -146,12 +137,7 @@ class GIASLinksStream(Stream):
resp.raise_for_status() resp.raise_for_status()
df = pd.read_csv( df = read_gias_csv(resp.content, self.logger)
io.StringIO(resp.text),
encoding="latin-1",
dtype=str,
keep_default_na=False,
)
for _, row in df.iterrows(): for _, row in df.iterrows():
record = row.to_dict() record = row.to_dict()
+98
View File
@@ -0,0 +1,98 @@
"""Every scheduled dbt build must only build models whose parents exist.
The daily GIAS build selects `stg_gias_establishments+`, so any mart that joins
dim_school joins the daily build too. When such a mart also reads a staging
model that only the manually triggered EES DAG builds, the daily build fails in
any database where that DAG has not run since. Sync and cache invalidation then
never run either. The destinations marts did this from late August 2026.
The graph is read from the model SQL, because CI has no dbt.
"""
import re
from collections import defaultdict
from pathlib import Path
import pytest
PIPELINE = Path(__file__).resolve().parents[1]
MODELS = PIPELINE / 'transform' / 'models'
DAG_FILE = PIPELINE / 'dags' / 'school_data_pipeline.py'
REF = re.compile(r"ref\(\s*'([a-z0-9_]+)'\s*\)")
DBT_BUILD = re.compile(r'dbt_build\w*\s*=\s*BashOperator\(.*?build --profiles-dir \. --target production ([^"]+)"', re.S)
DAG_ID = re.compile(r'dag_id="([a-z0-9_]+)"')
DAILY = 'school_data_daily'
# dim_school reads int_ofsted_latest only when the relation exists
# (adapter.get_relation), so a missing table is not a failure.
OPTIONAL_PARENTS = {'int_ofsted_latest'}
def model_parents():
"""{model: models it refs}. Seeds are left out: they are loaded once and always exist."""
sql = {p.stem: p.read_text() for p in MODELS.rglob('*.sql')}
return {name: set(REF.findall(text)) & set(sql) for name, text in sql.items()}
def downstream(node, children):
seen, stack = {node}, [node]
while stack:
for child in children[stack.pop()]:
if child not in seen:
seen.add(child)
stack.append(child)
return seen
def expand(tokens, children):
out = set()
for token in tokens:
out |= downstream(token[:-1], children) if token.endswith('+') else {token}
return out
def scheduled_builds():
"""{dag_id: dbt selection arguments} for every dbt build in the DAG file."""
text = DAG_FILE.read_text()
starts = [(m.start(), m.group(1)) for m in DAG_ID.finditer(text)]
builds = {}
for i, (start, dag_id) in enumerate(starts):
end = starts[i + 1][0] if i + 1 < len(starts) else len(text)
found = DBT_BUILD.search(text, start, end)
if found:
builds[dag_id] = found.group(1)
return builds
def selected_models(args, parents):
children = defaultdict(set)
for model, ps in parents.items():
for p in ps:
children[p].add(model)
select = re.search(r'--select (.+?)(?= --exclude|$)', args).group(1).split()
excluded = re.search(r'--exclude (.+)$', args)
exclude = excluded.group(1).split() if excluded else []
return (expand(select, children) - expand(exclude, children)) & set(parents)
PARENTS = model_parents()
BUILDS = scheduled_builds()
DAILY_MODELS = selected_models(BUILDS[DAILY], PARENTS)
def test_every_dag_with_a_dbt_build_is_parsed():
assert set(BUILDS) == {
'school_data_daily', 'school_data_monthly_ofsted', 'school_data_annual_ees',
'school_data_annual_idaci', 'school_data_annual_distance',
}
@pytest.mark.parametrize('dag_id', sorted(BUILDS))
def test_selected_models_only_read_models_that_exist(dag_id):
selected = selected_models(BUILDS[dag_id], PARENTS)
# The daily build is the base layer: other DAGs may rely on what it builds.
available = selected | OPTIONAL_PARENTS | (DAILY_MODELS if dag_id != DAILY else set())
missing = {model: sorted(PARENTS[model] - available) for model in sorted(selected)
if PARENTS[model] - available}
assert missing == {}, f'{dag_id} builds models whose parents it never builds: {missing}'
+67
View File
@@ -0,0 +1,67 @@
"""KS4 school information for 2023/24 exists only in the 2023/24 release,
whose file uses DfE's older column names. The stream declared only the newer
ones, so every 2023/24 field loaded as null (audit C2). The headers below are
DfE's, copied from 202324_information_about_schools_final.csv and
202425_information_about_schools_final.csv.
"""
import importlib.util
from pathlib import Path
import pytest
MODULE = (Path(__file__).resolve().parents[1] / 'plugins' / 'extractors' / 'tap-uk-ees'
/ 'tap_uk_ees' / 'ks4_info.py')
HEADER_2023_24 = (
'time_period', 'time_identifier', 'geographic_level', 'country_code', 'country_name',
'school_laestab', 'school_urn', 'school_name', 'old_la_code', 'new_la_code', 'la_name',
'version', 'establishment_type_group', 'full_address', 'telnum', 'pcon_code', 'pcon_name',
'contflag', 'iclose', 'reldenom', 'admpol_pt', 'egender', 'feeder', 'agerange',
't_allks_pupils', 't_allks_boys', 't_allks_girls', 't_pupils', 't_boys', 'pt_boys',
't_girls', 'pt_girls', 'avg_ks2_scaledscore', 't_prior_lo', 'pt_prior_lo', 't_prior_av',
'pt_prior_av', 't_prior_hi', 'pt_prior_hi', 't_disadvantaged', 'pt_disadvantaged',
't_not_disadvantaged', 'pt_not_disadvantaged', 't_language_not_english',
'pt_language_not_english', 't_language_english', 'pt_language_english',
't_language_unknown', 'pt_language_unknown', 't_not_mobile', 'pt_not_mobile',
't_sen_with_ehcp', 'pt_sen_with_ehcp', 't_sen', 'pt_sen', 't_sen_no_ehcp',
'pt_sen_no_ehcp', 'diffn_att8', 'diffn_p8mea', 'p8_banding',
)
HEADER_2024_25 = (
'time_period', 'time_identifier', 'geographic_level', 'country_code', 'country_name',
'school_laestab', 'school_urn', 'school_name', 'old_la_code', 'new_la_code', 'la_name',
'version', 'establishment_type_group', 'full_address', 'telnum', 'pcon_code', 'pcon_name',
'contflag', 'iclose', 'reldenom', 'admpol_pt', 'egender', 'feeder', 'agerange',
'allks_pupil_count', 'allks_boys_count', 'allks_girls_count', 'endks4_pupil_count',
'ks2_scaledscore_average', 'sen_with_ehcp_pupil_count', 'sen_with_ehcp_pupil_percent',
'sen_pupil_count', 'sen_pupil_percent', 'sen_no_ehcp_pupil_count',
'sen_no_ehcp_pupil_percent', 'attainment8_diffn', 'progress8_diffn', 'progress8_banding',
)
@pytest.fixture
def ks4_info():
spec = importlib.util.spec_from_file_location('ks4_info', MODULE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_every_declared_field_is_in_the_2023_24_file_once_renamed(ks4_info):
renamed = {ks4_info.KS4_INFO_RENAMES.get(c, c) for c in HEADER_2023_24}
assert set(ks4_info.KS4_INFO_FIELDS) - renamed == set()
def test_every_declared_field_is_in_the_2024_25_file(ks4_info):
assert set(ks4_info.KS4_INFO_FIELDS) - set(HEADER_2024_25) == set()
def test_the_renames_cannot_collide_with_either_file(ks4_info):
# An old name in the current file, or a new name already in the old file,
# would let a rename overwrite a real column.
assert set(ks4_info.KS4_INFO_RENAMES) & set(HEADER_2024_25) == set()
assert set(ks4_info.KS4_INFO_RENAMES.values()) & set(HEADER_2023_24) == set()
def test_every_rename_names_a_declared_field(ks4_info):
assert set(ks4_info.KS4_INFO_RENAMES.values()) <= set(ks4_info.KS4_INFO_FIELDS)
+72
View File
@@ -0,0 +1,72 @@
"""DfE's KS4 "summary, all state-funded" data set holds England, regional and
LA rows. The England stream kept only the England row; its LA rows match
DfE's published LA averages exactly, where the API's own mean was 7 points
low (audit H2). Values below are DfE's for Kensington and Chelsea (207) and
Wandsworth (212).
"""
import importlib.util
import io
from pathlib import Path
import pandas as pd
import pytest
MODULE = (Path(__file__).resolve().parents[1] / 'plugins' / 'extractors' / 'tap-uk-ees'
/ 'tap_uk_ees' / 'ks4_summary.py')
CSV = """time_period,geographic_level,old_la_code,new_la_code,la_name,establishment_type_group,breakdown_topic,breakdown,attainment8_average,progress8_average,engmath_94_percent,engmath_95_percent,ebacc_entering_percent,ebacc_94_percent,ebacc_95_percent,ebacc_aps_average
202425,National,,,,All state-funded,Total,Total,46.1,z,64.5,45.7,40.5,26.9,17.7,4.1
202425,National,,,,All state-funded,Sex,Boys,44.1,z,62.0,43.0,38.0,24.0,16.0,3.9
202425,Regional,,,,All state-funded,Total,Total,47.2,z,66.0,47.0,41.0,28.0,18.0,4.2
202425,Local authority,207,E09000020,Kensington and Chelsea,All state-funded,Total,Total,54.5,z,77,61.4,45.6,32,26.6,4.89
202425,Local authority,207,E09000020,Kensington and Chelsea,All state-funded,Sex,Girls,57.0,z,80,64.0,48.0,35,28.0,5.1
202324,Local authority,207,E09000020,Kensington and Chelsea,All state-funded,Total,Total,54.5,0.29,76,60.0,44.0,31,25.0,4.8
202425,Local authority,212,E09000032,Wandsworth,All state-funded,Total,Total,51.8,z,72,55.0,50.0,33,24.0,4.6
202425,Local authority,212,E09000032,Wandsworth,Academies and free schools,Total,Total,52.0,z,73,56.0,51.0,34,25.0,4.7
"""
LA_KEYS = ('time_period', 'old_la_code', 'new_la_code', 'la_name')
def _df():
return pd.read_csv(io.StringIO(CSV), dtype=str, keep_default_na=False)
@pytest.fixture
def summary():
spec = importlib.util.spec_from_file_location('ks4_summary', MODULE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_la_rows_are_one_per_la_and_year(summary):
rows = summary.headline_rows(_df(), 'Local authority')
assert sorted(zip(rows['time_period'], rows['old_la_code'])) == [
('202324', '207'), ('202425', '207'), ('202425', '212')]
def test_an_la_record_carries_codes_name_and_the_headline_measures(summary):
rows = summary.headline_rows(_df(), 'Local authority')
row = rows[(rows['time_period'] == '202425') & (rows['old_la_code'] == '207')].iloc[0]
assert summary.headline_record(row, LA_KEYS) == {
'time_period': '202425', 'old_la_code': '207', 'new_la_code': 'E09000020',
'la_name': 'Kensington and Chelsea',
'attainment_8_score': '54.5', 'progress_8_score': 'z',
'english_maths_standard_pass_pct': '77', 'english_maths_strong_pass_pct': '61.4',
'ebacc_entry_pct': '45.6', 'ebacc_standard_pass_pct': '32',
'ebacc_strong_pass_pct': '26.6', 'ebacc_avg_score': '4.89',
}
def test_the_england_rows_are_one_per_year(summary):
rows = summary.headline_rows(_df(), 'National')
assert list(rows['time_period']) == ['202425']
assert summary.headline_record(rows.iloc[0], ('time_period',))['attainment_8_score'] == '46.1'
def test_column_names_and_labels_match_whatever_their_case(summary):
df = _df()
df.columns = [c.upper() for c in df.columns]
df['GEOGRAPHIC_LEVEL'] = df['GEOGRAPHIC_LEVEL'].str.upper()
assert len(summary.headline_rows(df, 'Local authority')) == 3
@@ -0,0 +1,100 @@
"""DfE re-publishes earlier years inside later KS4 releases.
The 2024/25 results file holds 2022/23, 2023/24 and 2024/25 under current
column names. The 2023/24 release's own file, re-issued in March 2026 under
older names, was read after it and overwrote every 2023/24 row with blanks
(audit C2). For the KS4 results stream the newest release owns every year it
contains.
"""
import importlib.util
import re
from pathlib import Path
import pandas as pd
import pytest
TAP_DIR = (Path(__file__).resolve().parents[1] / 'plugins' / 'extractors' / 'tap-uk-ees'
/ 'tap_uk_ees')
@pytest.fixture
def precedence():
spec = importlib.util.spec_from_file_location(
'release_precedence', TAP_DIR / 'release_precedence.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _release(period):
return {'id': f'release-{period}', 'time_period': period}
def test_releases_are_taken_newest_first_whatever_order_the_api_gives(precedence):
releases = [_release('202223'), _release('202425'), _release(None), _release('202324')]
ordered = precedence.newest_first(releases)
assert [r['time_period'] for r in ordered] == ['202425', '202324', '202223', None]
def test_a_year_a_newer_release_supplied_is_dropped_from_an_older_one(precedence):
newer = pd.DataFrame({'time_period': ['202425', '202324', '202223'], 'school_urn': ['1'] * 3})
older = pd.DataFrame({'time_period': ['202324', '202324', '201920'], 'school_urn': ['1', '2', '1']})
kept, skipped = precedence.drop_owned_periods(older, precedence.periods_in(newer))
assert list(kept['time_period']) == ['201920']
assert skipped == {'202324': 2}
def test_periods_match_despite_surrounding_spaces(precedence):
owned = precedence.periods_in(pd.DataFrame({'time_period': [' 202324 ']}))
kept, skipped = precedence.drop_owned_periods(pd.DataFrame({'time_period': ['202324']}), owned)
assert owned == {'202324'}
assert kept.empty
assert skipped == {'202324': 1}
def test_nothing_is_dropped_before_any_year_is_owned(precedence):
df = pd.DataFrame({'time_period': ['202324'], 'school_urn': ['1']})
kept, skipped = precedence.drop_owned_periods(df, set())
assert kept.equals(df)
assert skipped == {}
def test_a_file_without_time_period_is_left_alone(precedence):
df = pd.DataFrame({'school_urn': ['1']})
kept, skipped = precedence.drop_owned_periods(df, {'202324'})
assert kept.equals(df)
assert skipped == {}
assert precedence.periods_in(df) == set()
def test_only_the_ks4_results_stream_opts_in():
# A general rule would wipe KS2: the 2024/25 KS2 file holds 98,448 of the
# 955,956 rows the 2023/24 release has for 2023/24.
source = (TAP_DIR / 'tap.py').read_text()
opted_in = [chunk.split('(')[0] for chunk in source.split('\nclass ')[1:]
if re.search(r'_newest_release_owns_period\s*=\s*True', chunk)]
assert opted_in == ['EESKS4PerformanceStream']
@pytest.mark.parametrize('slug, period', [
('2024-25', '202425'),
('2024-25-revised', '202425'),
('2025-26-provisional', '202526'),
('latest', None),
('', None),
])
def test_a_release_slug_gives_its_year_whatever_its_suffix(precedence, slug, period):
# KS2 already publishes "2024-25-revised" and "2025-26-provisional". An
# unread suffix sent the release last, behind the provisional one for the
# same year, which then owned the year and dropped every revised row.
assert precedence.slug_to_time_period(slug) == period
def test_within_a_year_the_api_order_is_kept(precedence):
# The API lists a year's revised release before its first release.
releases = [{'id': 'revised', 'time_period': '202526'},
{'id': 'first', 'time_period': '202526'},
{'id': 'older', 'time_period': '202425'}]
assert [r['id'] for r in precedence.newest_first(releases)] == ['revised', 'first', 'older']
+66
View File
@@ -0,0 +1,66 @@
"""GIAS publishes its extracts in Windows-1252 and declares no charset.
The tap used to hand pandas `resp.text`, so requests guessed the codec.
On 3 Oct 2026 it guessed windows-1250, and "St Thomas à Becket" was stored
as "St Thomas ŕ Becket". The `encoding=` passed to read_csv did nothing,
because the text was already decoded.
"""
import importlib.util
import logging
from pathlib import Path
import pytest
MODULE = (Path(__file__).resolve().parents[1] / 'plugins' / 'extractors' / 'tap-uk-gias'
/ 'tap_uk_gias' / 'gias_csv.py')
@pytest.fixture
def gias_csv():
spec = importlib.util.spec_from_file_location('gias_csv', MODULE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# Byte for byte as GIAS writes it: 0xE0 à, 0x92 ’, 0xE9 é, 0xB0 °, 0xE7 ç.
EXTRACT = (
b'"URN","EstablishmentName","HeadLastName"\r\n'
b'"138950","St Thomas \xe0 Becket Catholic Secondary School","Smith"\r\n'
b'"100000","The Dean and Chapter of St Paul\x92s Cathedral","Pr\xe9vert"\r\n'
b'"140677","North Star 180\xb0","Fran\xe7ois"\r\n'
b'"100001","No head recorded",""\r\n'
)
def test_names_decode_as_windows_1252(gias_csv):
df = gias_csv.read_gias_csv(EXTRACT)
assert list(df['EstablishmentName']) == [
'St Thomas à Becket Catholic Secondary School',
'The Dean and Chapter of St Paul’s Cathedral',
'North Star 180°',
'No head recorded',
]
assert list(df['HeadLastName']) == ['Smith', 'Prévert', 'François', '']
def test_the_codec_requests_guessed_is_not_used(gias_csv):
# What the tap stored on 3 Oct: the same bytes read as windows-1250.
assert 'ŕ' in EXTRACT.decode('cp1250')
names = ' '.join(gias_csv.read_gias_csv(EXTRACT)['EstablishmentName'])
assert 'ŕ' not in names
def test_values_stay_strings(gias_csv):
df = gias_csv.read_gias_csv(EXTRACT)
assert df.loc[0, 'URN'] == '138950'
def test_a_byte_windows_1252_leaves_undefined_does_not_stop_the_load(gias_csv, caplog):
# 0x81 has no Windows-1252 character. One odd name must not block the daily
# refresh of every school, but it must be visible in the log.
extract = b'"URN","EstablishmentName"\r\n"100002","Odd \x81 Name"\r\n'
with caplog.at_level(logging.WARNING):
df = gias_csv.read_gias_csv(extract, logger=logging.getLogger('gias'))
assert df.loc[0, 'EstablishmentName'] == 'Odd � Name'
assert 'could not be decoded' in caplog.text
+3 -1
View File
@@ -3,7 +3,9 @@
Casts a string column to numeric, treating any non-numeric value as NULL. Casts a string column to numeric, treating any non-numeric value as NULL.
Handles all EES suppression codes (z, c, x, q, u, etc.) without needing Handles all EES suppression codes (z, c, x, q, u, etc.) without needing
an explicit list — any string that doesn't look like a number becomes NULL. an explicit list — any string that doesn't look like a number becomes NULL.
A trailing percent sign is accepted: DfE's 2023/24 KS2 information file
writes percentages as "34%" (audit C2).
#} #}
{% macro safe_numeric(col) -%} {% macro safe_numeric(col) -%}
CASE WHEN {{ col }} ~ '^-?[0-9]+(\.[0-9]+)?$' THEN {{ col }}::numeric ELSE NULL END CASE WHEN {{ col }} ~ '^-?[0-9]+(\.[0-9]+)?%?$' THEN rtrim({{ col }}, '%')::numeric ELSE NULL END
{%- endmacro %} {%- endmacro %}
@@ -279,11 +279,24 @@ models:
tests: [not_null, unique] tests: [not_null, unique]
- name: fact_ks4_national_averages - name: fact_ks4_national_averages
description: Computed national KS4 averages (means across state schools in our dataset — not official DfE figures) — one row per academic year description: Official DfE KS4 national headline averages (England, all state-funded schools, all pupils) — one row per academic year
columns: columns:
- name: year - name: year
tests: [not_null, unique] tests: [not_null, unique]
- name: fact_ks4_la_averages
description: Official DfE KS4 local-authority averages (all state-funded schools, all pupils) — one row per academic year and LA; la_code is the GIAS LA code
columns:
- name: year
tests: [not_null]
- name: la_code
tests: [not_null]
- name: la_name
tests: [not_null]
tests:
- unique:
column_name: "year || '-' || la_code"
- name: fact_deprivation - name: fact_deprivation
description: IDACI deprivation index — one row per URN description: IDACI deprivation index — one row per URN
columns: columns:
@@ -0,0 +1,22 @@
{{ config(materialized='table') }}
-- Mart: OFFICIAL DfE KS4 local-authority averages — one row per academic year
-- and LA (all state-funded schools, all pupils), from the same EES data set
-- as fact_ks4_national_averages. Feeds "vs LA avg" on search rows and map
-- cards, replacing a mean the API took over every school in the LA,
-- independent and special included (audit H2). la_code is the GIAS LA code.
select
year,
la_code,
la_name,
attainment_8_score,
progress_8_score,
english_maths_standard_pass_pct,
english_maths_strong_pass_pct,
ebacc_entry_pct,
ebacc_standard_pass_pct,
ebacc_strong_pass_pct,
ebacc_avg_score
from {{ ref('stg_ees_ks4_la') }}
order by year, la_code
@@ -79,6 +79,9 @@ sources:
- name: ees_ks4_national - name: ees_ks4_national
description: Official KS4 national headline averages from DfE EES data catalogue — one row per academic year description: Official KS4 national headline averages from DfE EES data catalogue — one row per academic year
- name: ees_ks4_la
description: Official KS4 local-authority averages (all state-funded schools) from the same DfE EES data set as ees_ks4_national — one row per academic year and LA
# Phonics: no school-level data on EES (only national/LA level) # Phonics: no school-level data on EES (only national/LA level)
- name: fbit_finance - name: fbit_finance
@@ -0,0 +1,22 @@
version: 2
unit_tests:
- name: stg_ees_ks2_reads_percentages_written_with_a_sign
description: >
DfE's 2023/24 KS2 information file writes percentages as "34%", and every
2023/24 percentage loaded as null (audit C2). Plain numbers still load;
suppression codes stay null. School 147411's 2023/24 figures are DfE's.
model: stg_ees_ks2
given:
- input: source('raw', 'ees_ks2_attainment')
rows:
- {school_urn: '147411', time_period: '202324', subject: 'Reading, writing and maths', breakdown_topic: 'All pupils', breakdown: 'Total', expected_standard_pupil_percent: '70'}
- {school_urn: '100000', time_period: '202425', subject: 'Reading, writing and maths', breakdown_topic: 'All pupils', breakdown: 'Total', expected_standard_pupil_percent: '79'}
- input: source('raw', 'ees_ks2_info')
rows:
- {school_urn: '147411', time_period: '202324', totpups: '818', telig: '112', ptfsm6cla1a: '34%', ptealgrp2: '56%', psenelk: '20%', psenele: 'c', ptmobn: '87.5%'}
- {school_urn: '100000', time_period: '202425', totpups: '792', telig: '105', ptfsm6cla1a: '32', ptealgrp2: 'x', psenelk: '14', psenele: '2', ptmobn: '87'}
expect:
rows:
- {urn: 147411, year: 202324, total_pupils: 818, rwm_expected_pct: 70, disadvantaged_pct: 34, eal_pct: 56, sen_support_pct: 20, sen_ehcp_pct: null, stability_pct: 87.5}
- {urn: 100000, year: 202425, total_pupils: 792, rwm_expected_pct: 79, disadvantaged_pct: 32, eal_pct: null, sen_support_pct: 14, sen_ehcp_pct: 2, stability_pct: 87}
@@ -0,0 +1,21 @@
-- Staging model: official DfE KS4 local-authority averages — one row per
-- academic year and LA (all state-funded schools, all pupils). Same EES data
-- set as stg_ees_ks4_national. la_code is the GIAS LA code
-- (dim_location.local_authority_code). Suppressed values ('z', 'x') are
-- coerced to NULL by safe_numeric.
select
cast(trim(time_period) as integer) as year,
cast(trim(old_la_code) as integer) as la_code,
trim(la_name) as la_name,
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
{{ safe_numeric('progress_8_score') }} as progress_8_score,
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
{{ safe_numeric('ebacc_avg_score') }} as ebacc_avg_score
from {{ source('raw', 'ees_ks4_la') }}
where time_period ~ '^[0-9]+$'
and old_la_code ~ '^[0-9]+$'
@@ -0,0 +1,15 @@
version: 2
unit_tests:
- name: stg_ees_ks4_la_casts_codes_years_and_measures
description: DfE's LA rows, keyed by the GIAS LA code. Suppressed values ('z') become null.
model: stg_ees_ks4_la
given:
- input: source('raw', 'ees_ks4_la')
rows:
- {time_period: '202425', old_la_code: '207', new_la_code: 'E09000020', la_name: 'Kensington and Chelsea', attainment_8_score: '54.5', progress_8_score: 'z', english_maths_standard_pass_pct: '77'}
- {time_period: '202324', old_la_code: '207', new_la_code: 'E09000020', la_name: 'Kensington and Chelsea', attainment_8_score: '54.5', progress_8_score: '0.29', english_maths_standard_pass_pct: '76'}
expect:
rows:
- {year: 202425, la_code: 207, la_name: 'Kensington and Chelsea', attainment_8_score: 54.5, progress_8_score: null, english_maths_standard_pass_pct: 77}
- {year: 202324, la_code: 207, la_name: 'Kensington and Chelsea', attainment_8_score: 54.5, progress_8_score: 0.29, english_maths_standard_pass_pct: 76}
@@ -0,0 +1,16 @@
-- Where a year's KS2 school information loaded (pupil counts present), its
-- percentages loaded too. DfE's files give a disadvantaged % for 96–97% of
-- those schools; 2023/24 loaded none because the file writes "34%" (audit
-- C2). A year without an information file (2022/23) has no pupil counts and
-- is skipped.
select
year,
count(total_pupils) as with_pupils,
count(*) filter (where total_pupils is not null and disadvantaged_pct is not null)
as with_disadvantaged
from {{ ref('stg_ees_ks2') }}
group by year
having count(total_pupils) >= 1000
and count(*) filter (where total_pupils is not null and disadvantaged_pct is not null)
< 0.9 * count(total_pupils)
@@ -0,0 +1,13 @@
-- DfE publishes an average for about 152 LAs a year. Fewer than 145 in the
-- latest year (or none at all) means the LA filter in tap_uk_ees
-- (ks4_summary.headline_rows) stopped matching, e.g. after DfE renamed a label.
with latest as (
select max(year) as year from {{ ref('fact_ks4_la_averages') }}
)
select l.year, count(f.la_code) as las
from latest l
left join {{ ref('fact_ks4_la_averages') }} f on f.year = l.year
group by l.year
having count(f.la_code) < 145
@@ -0,0 +1,20 @@
{{ config(severity='warn') }}
-- DfE's LA averages cover the latest year with school results. They come from
-- a fixed EES data set (ks4_summary.KS4_SUMMARY_CSV_URL); when DfE publishes a
-- new year under a new data set, the school results move on and this mart does
-- not, so /api/la-averages serves an empty map and every "vs LA avg" goes.
-- A warning, not an error: it must not hold back a new year's school results.
with schools as (
select max(year) as year from {{ ref('stg_ees_ks4') }} where attainment_8_score is not null
),
las as (
select max(year) as year from {{ ref('fact_ks4_la_averages') }}
)
select s.year as latest_school_year, l.year as latest_la_year
from schools s
cross join las l
where l.year is null or l.year < s.year
@@ -0,0 +1,12 @@
-- Every year loaded from EES has an Attainment 8 for at least half its
-- schools. DfE's files reach 82% each year; 2023/24 loaded at 0% after DfE
-- re-issued its file under older column names (audit C2). Pre-2019 years come
-- from stg_legacy_ks4 and are not checked here.
select
year,
count(*) as schools,
count(attainment_8_score) as with_attainment_8
from {{ ref('stg_ees_ks4') }}
group by year
having count(attainment_8_score) < 0.5 * count(*)