feat(pipeline): dim_school/dim_location store GIAS codes; seed drift test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-07-09 10:45:15 +01:00
co-authored by Claude Fable 5
parent d898e6279b
commit fa6c929a3a
4 changed files with 78 additions and 32 deletions
@@ -8,9 +8,10 @@ models:
tests: [not_null, unique] tests: [not_null, unique]
- name: school_name - name: school_name
tests: [not_null] tests: [not_null]
- name: phase - name: phase_code
description: > description: >
Primary / Secondary / All-through etc. May be null for a small number GIAS PhaseOfEducation code (2 = Primary, 4 = Secondary, 7 = All-through,
etc. — see seeds/gias_code_names.csv). May be null for a small number
of independent schools where GIAS publishes "Not Applicable", no of independent schools where GIAS publishes "Not Applicable", no
statutory age range, and the school name gives no hint. statutory age range, and the school name gives no hint.
tests: tests:
@@ -27,10 +28,26 @@ models:
- not_null - not_null
- accepted_values: - accepted_values:
values: [true, false] values: [true, false]
- name: status - name: status_code
description: GIAS EstablishmentStatus code (1 = Open, 3 = Open but proposed to close)
tests: tests:
- accepted_values: - accepted_values:
values: ["Open", "Open, but proposed to close"] values: [1, 3]
- name: school_type_code
tests:
- accepted_values:
severity: warn
values: [1, 2, 3, 5, 6, 7, 8, 10, 11, 12, 14, 15, 18, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 49, 56, 57]
- name: religious_character_code
tests:
- accepted_values:
severity: warn
values: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49]
- name: admissions_policy_code
tests:
- accepted_values:
severity: warn
values: [0, 2, 4]
- name: dim_location - name: dim_location
description: School location dimension with PostGIS geometry description: School location dimension with PostGIS geometry
@@ -32,4 +32,4 @@ select
end as longitude end as longitude
from {{ ref('stg_gias_establishments') }} s from {{ ref('stg_gias_establishments') }} s
-- Must match dim_school's status filter exactly (the API inner-joins the two). -- Must match dim_school's status filter exactly (the API inner-joins the two).
where s.status in ('Open', 'Open, but proposed to close') where s.status_code in (1, 3)
+23 -27
View File
@@ -19,16 +19,17 @@ select
s.urn, s.urn,
s.local_authority_code * 1000 + s.establishment_number as laestab, s.local_authority_code * 1000 + s.establishment_number as laestab,
s.school_name, s.school_name,
-- Phase in GIAS code space (see seeds/gias_code_names.csv):
-- 2 = Primary, 4 = Secondary, 7 = All-through, 0 = Not applicable.
case case
-- 1. Trust GIAS phase when it's a real value (not the catch-all "Not Applicable") -- 1. Trust GIAS phase when it's a real value (0 = the catch-all "Not Applicable")
when s.phase is not null when s.phase_code is not null and s.phase_code != 0
and lower(trim(s.phase)) not in ('not applicable', '', 'unknown') then s.phase_code
then s.phase
-- 2. Infer from statutory age range (independent schools still publish these) -- 2. Infer from statutory age range (independent schools still publish these)
when s.statutory_high_age is not null and s.statutory_high_age <= 11 then 'Primary' when s.statutory_high_age is not null and s.statutory_high_age <= 11 then 2
when s.statutory_low_age is not null and s.statutory_low_age >= 11 then 'Secondary' when s.statutory_low_age is not null and s.statutory_low_age >= 11 then 4
when s.statutory_low_age is not null and s.statutory_high_age is not null when s.statutory_low_age is not null and s.statutory_high_age is not null
and s.statutory_low_age < 11 and s.statutory_high_age > 11 then 'All-through' and s.statutory_low_age < 11 and s.statutory_high_age > 11 then 7
-- 3. Fallback: infer from school name (covers independents with missing ages) -- 3. Fallback: infer from school name (covers independents with missing ages)
when s.school_name ilike '%primary%' when s.school_name ilike '%primary%'
or s.school_name ilike '%infant%' or s.school_name ilike '%infant%'
@@ -36,31 +37,27 @@ select
or s.school_name ilike '%preparatory%' or s.school_name ilike '%preparatory%'
or s.school_name ilike '% prep school%' or s.school_name ilike '% prep school%'
or s.school_name ilike '% prep %' or s.school_name ilike '% prep %'
then 'Primary' then 2
when s.school_name ilike '%secondary%' when s.school_name ilike '%secondary%'
or s.school_name ilike '%high school%' or s.school_name ilike '%high school%'
or s.school_name ilike '%grammar%' or s.school_name ilike '%grammar%'
or s.school_name ilike '%senior school%' or s.school_name ilike '%senior school%'
or s.school_name ilike '%upper school%' or s.school_name ilike '%upper school%'
then 'Secondary' then 4
-- 4. Give up — leave phase null so the UI renders no pill -- 4. Give up — null renders no phase pill
else null else null
end as phase, end as phase_code,
s.school_type, s.school_type_code,
s.academy_trust_name, s.academy_trust_name,
s.academy_trust_uid, s.academy_trust_uid,
s.religious_character, s.religious_character_code,
s.gender, s.gender,
s.statutory_low_age || '-' || s.statutory_high_age as age_range, s.statutory_low_age || '-' || s.statutory_high_age as age_range,
-- Authoritative sixth-form flag (spec §3): GIAS OfficialSixthForm. -- GIAS OfficialSixthForm in code space: 1 = has, 2 = does not, 0 = N/A.
-- "Not applicable" (nurseries, primaries, PRUs) => false. Blank GIAS -- Null (rare, new establishments) falls back to the statutory age range.
-- value (rare, new establishments) falls back to the statutory age range.
-- lower(trim()) guards against casing/whitespace variants in raw GIAS
-- data, same as the phase derivation above — an unmatched variant would
-- otherwise silently fall through to the age-range fallback.
case case
when lower(trim(s.official_sixth_form)) = 'has a sixth form' then true when s.official_sixth_form_code = 1 then true
when lower(trim(s.official_sixth_form)) in ('does not have a sixth form', 'not applicable') then false when s.official_sixth_form_code in (0, 2) then false
else coalesce(s.statutory_high_age >= 18, false) else coalesce(s.statutory_high_age >= 18, false)
end as has_sixth_form, end as has_sixth_form,
s.capacity, s.capacity,
@@ -70,9 +67,9 @@ select
s.telephone, s.telephone,
s.open_date, s.open_date,
s.close_date, s.close_date,
s.status, s.status_code,
s.nursery_provision, s.nursery_provision,
s.admissions_policy, s.admissions_policy_code,
-- Latest Ofsted (populated after monthly Ofsted pipeline runs) -- Latest Ofsted (populated after monthly Ofsted pipeline runs)
{% if ofsted_relation is not none %} {% if ofsted_relation is not none %}
@@ -91,7 +88,6 @@ from schools s
{% if ofsted_relation is not none %} {% if ofsted_relation is not none %}
left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
{% endif %} {% endif %}
-- "Open, but proposed to close" schools are still operating (pupils enrolled, -- 1 = Open; 3 = Open, but proposed to close (still operating; drops out when
-- results published)include them; they drop out automatically once GIAS -- GIAS flips to Closed — marts fully rebuild each run).
-- flips them to "Closed" (marts are fully rebuilt each run). where s.status_code in (1, 3)
where s.status in ('Open', 'Open, but proposed to close')
@@ -0,0 +1,33 @@
-- Warn when the live GIAS CSV carries a (code, name) pair we don't have in
-- the dictionary seed — i.e. DfE added or renamed a value. Fix by rerunning
-- pipeline/scripts/generate_gias_codes.py and committing the regenerated
-- dictionaries + seed together.
{{ config(severity='warn') }}
with raw_pairs as (
{% for field_key, code_col, name_col in [
('school_type', 'TypeOfEstablishment (code)', 'TypeOfEstablishment (name)'),
('establishment_status', 'EstablishmentStatus (code)', 'EstablishmentStatus (name)'),
('phase_of_education', 'PhaseOfEducation (code)', 'PhaseOfEducation (name)'),
('official_sixth_form', 'OfficialSixthForm (code)', 'OfficialSixthForm (name)'),
('religious_character', 'ReligiousCharacter (code)', 'ReligiousCharacter (name)'),
('admissions_policy', 'AdmissionsPolicy (code)', 'AdmissionsPolicy (name)')
] %}
select distinct
'{{ field_key }}' as field,
cast(nullif(trim("{{ code_col }}"), '') as integer) as code,
nullif(trim("{{ name_col }}"), '') as name
from {{ source('raw', 'gias_establishments') }}
where nullif(trim("{{ code_col }}"), '') is not null
and nullif(trim("{{ name_col }}"), '') is not null
{% if not loop.last %}union all{% endif %}
{% endfor %}
)
select r.*
from raw_pairs r
left join {{ ref('gias_code_names') }} s
on s.field = r.field
and s.code = r.code
and s.name = r.name
where s.field is null