fix(ees): newest KS4 release owns every year it contains (C2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e987ef06e
commit
4db1131d0f
3 files changed
+142
No files matched your search
@@ -0,0 +1,44 @@
|
||||
"""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 pandas as pd
|
||||
|
||||
|
||||
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,8 @@ import requests
|
||||
from singer_sdk import Stream, Tap
|
||||
from singer_sdk import typing as th
|
||||
|
||||
from tap_uk_ees.release_precedence import drop_owned_periods, newest_first, periods_in
|
||||
|
||||
CONTENT_API_BASE = (
|
||||
"https://content.explore-education-statistics.service.gov.uk/api"
|
||||
)
|
||||
@@ -88,6 +90,8 @@ class EESDatasetStream(Stream):
|
||||
target CSV path inside the ZIP (substring match, not exact).
|
||||
Subclasses may set _column_renames to map messy CSV column names to
|
||||
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
|
||||
@@ -96,6 +100,7 @@ class EESDatasetStream(Stream):
|
||||
_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)
|
||||
_column_renames: dict = {} # CSV column name → Singer field name
|
||||
_newest_release_owns_period: bool = False # see release_precedence.py
|
||||
|
||||
def get_records(self, context):
|
||||
import pandas as pd
|
||||
@@ -110,6 +115,9 @@ class EESDatasetStream(Stream):
|
||||
self.logger.info(
|
||||
"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:
|
||||
release_id = release["id"]
|
||||
@@ -163,6 +171,15 @@ class EESDatasetStream(Stream):
|
||||
if urn_col in df.columns:
|
||||
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)
|
||||
|
||||
for _, row in df.iterrows():
|
||||
@@ -251,6 +268,9 @@ class EESKS4PerformanceStream(EESDatasetStream):
|
||||
primary_keys = ["school_urn", "time_period", "breakdown_topic", "breakdown", "sex"]
|
||||
_publication_slug = "key-stage-4-performance"
|
||||
_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(
|
||||
th.Property("time_period", th.StringType, required=True),
|
||||
th.Property("school_urn", th.StringType, required=True),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""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']
|
||||
Reference in new issue
Block a user