Slice 41: schema-21.0.1 ventilation completeness — 7 vent / draught fields plumbed

Audit of raw-JSON keys vs RdSapSchema21_0_1 across the 9-fixture
golden cohort surfaced 7 vent / draught fields silently dropped at
deserialization: blocked_chimneys_count, open_flues_count,
closed_flues_count, boilers_flues_count, other_flues_count, psv_count,
has_draught_lobby. cert_to_inputs reads all of them for the §2
infiltration cascade; without them the calc treats every dwelling as
flue-free / vent-free / no draught lobby and under-counts ACH.

Fix: declare the 7 fields on RdSapSchema21_0_1; extend the mapper to
surface blocked_chimneys_count on EpcPropertyData top-level (already
declared) and the other 6 on SapVentilation (extends the slice 37
extract_fans_count work). has_draught_lobby coerces "true"/"false"
strings to bool to match the SapVentilation type.

Cohort residual shifts after re-pinning:
- LN12 (0390-2254) — SAP +1 → 0 (FIRST CERT TO HIT LODGED SAP EXACTLY).
  blocked_chimneys=2 reduces infiltration, tightens both SAP and PE
  (PE −10.62 → −3.14, CO2 −0.11 → +0.04).
- 0300 — PE +18.92 → +17.34, CO2 −0.43 → −0.54 (open_flues=1 +
  has_draught_lobby=true cross-cancel near-zero).
- 0390-2954 — PE −25.62 → −27.64, CO2 −2.45 → −2.58 (has_draught_lobby=true).
- 8135 — PE −17.58 → −14.37, CO2 −0.22 → −0.15 (blocked_chimneys=1).
- Other 5 fixtures (0240, DE22, 6035, 7536, plus retired 9390): no shift
  — their certs lodge zeros or no vent fields beyond what Slice 37 plumbed.

Rounded-SAP cohort distribution post-slice:
  0 (LN12), +1 (8135), +2 (9390), +3 (7536), +8 (DE22, spec-drift),
  -6 (6035), -7 (0390-2954), -9 (0300), -12 (0240, RR-driven).

Schema scope: 21.0.1 only. 21.0.0 schema's SapBuildingPart shares the
same mapper code but no 21.0.0 fixtures live in the cohort to anchor
against; defer to a future slice if needed.

930/930 Elmhurst cascade green. 14/14 golden cohort green at new
pinned residuals. 77/77 mapper tests green. Pyright net-zero (34
errors before and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 14:27:32 +00:00
parent fb3973457a
commit 81392208c4
5 changed files with 76 additions and 18 deletions

View file

@ -1563,6 +1563,7 @@ class EpcPropertyDataMapper:
# Dwelling-level inputs used as ML features.
multiple_glazed_proportion=schema.multiple_glazed_proportion,
extract_fans_count=schema.extract_fans_count,
blocked_chimneys_count=schema.blocked_chimneys_count,
insulated_door_u_value=schema.insulated_door_u_value,
mechanical_vent_duct_placement=schema.mechanical_vent_duct_placement,
mechanical_vent_duct_insulation=schema.mechanical_vent_duct_insulation,
@ -1615,9 +1616,21 @@ class EpcPropertyDataMapper:
# SapVentilation slice — calculator reads cert→§2 ventilation
# counts via epc.sap_ventilation.*; without this the cascade
# defaults to zero on all flue / fan / vent counts and
# under-states infiltration.
# under-states infiltration. has_draught_lobby arrives from
# the API as the string "true"/"false"; we coerce here so the
# cascade sees a typed bool (None → False at the read site).
sap_ventilation=SapVentilation(
extract_fans_count=schema.extract_fans_count,
open_flues_count=schema.open_flues_count,
closed_flues_count=schema.closed_flues_count,
boiler_flues_count=schema.boilers_flues_count,
other_flues_count=schema.other_flues_count,
passive_vents_count=schema.psv_count,
has_draught_lobby=(
schema.has_draught_lobby == "true"
if schema.has_draught_lobby is not None
else None
),
),
)

View file

@ -622,6 +622,28 @@ class TestFromRdSapSchema21_0_1:
assert sv is not None
assert sv.extract_fans_count == 2
def test_ventilation_completeness_all_seven_vent_fields_flow_through(
self, result: EpcPropertyData
) -> None:
# Arrange — schema-21.0.1 carries seven vent / draught fields the
# cert→inputs cascade reads for the §2 infiltration calculation.
# Without these the calc treats the dwelling as flue-free / vent-
# free / no draught lobby, under-counting infiltration ACH.
# blocked_chimneys is top-level; the other 6 live on SapVentilation.
# Act
sv = result.sap_ventilation
# Assert
assert result.blocked_chimneys_count == 1
assert sv is not None
assert sv.open_flues_count == 1
assert sv.closed_flues_count == 1
assert sv.boiler_flues_count == 1
assert sv.other_flues_count == 1
assert sv.passive_vents_count == 2
assert sv.has_draught_lobby is True
# --- renewable heat incentive (RHI) ---
def test_renewable_heat_incentive(self, result: EpcPropertyData) -> None:

View file

@ -362,6 +362,16 @@ class RdSapSchema21_0_1:
extract_fans_count: Optional[int] = None
wet_rooms_count: Optional[int] = None
open_chimneys_count: Optional[int] = None
# Ventilation / draught completeness — surfaced into SapVentilation
# (or EpcPropertyData top-level for chimney counts) so the §2 cascade
# gets the real flue / vent / draught lobby state instead of zeros.
blocked_chimneys_count: Optional[int] = None
open_flues_count: Optional[int] = None
closed_flues_count: Optional[int] = None
boilers_flues_count: Optional[int] = None
other_flues_count: Optional[int] = None
psv_count: Optional[int] = None
has_draught_lobby: Optional[str] = None # "true" / "false" / "unknown"
insulated_door_u_value: Optional[float] = None
suggested_improvements: Optional[List[SuggestedImprovement]] = None
mechanical_vent_duct_type: Optional[int] = None

View file

@ -164,6 +164,13 @@
],
"open_chimneys_count": 1,
"extract_fans_count": 2,
"blocked_chimneys_count": 1,
"open_flues_count": 1,
"closed_flues_count": 1,
"boilers_flues_count": 1,
"other_flues_count": 1,
"psv_count": 2,
"has_draught_lobby": "true",
"solar_water_heating": "N",
"habitable_room_count": 5,
"heating_cost_current": 365.98,

View file

@ -96,17 +96,21 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0300-2747-7640-2526-2135",
actual_sap=78,
expected_sap_resid=-9,
expected_pe_resid_kwh_per_m2=+18.92,
expected_co2_resid_tonnes_per_yr=-0.4273,
notes="Large semi-detached, TFA 526, age D, gas boiler PCDB-listed (no Table 4b code).",
expected_pe_resid_kwh_per_m2=+17.3417,
expected_co2_resid_tonnes_per_yr=-0.5359,
notes=(
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
"(no Table 4b code). Cert lodges open_flues_count=1 + "
"has_draught_lobby=true."
),
),
_GoldenExpectation(
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=-7,
expected_pe_resid_kwh_per_m2=-25.62,
expected_co2_resid_tonnes_per_yr=-2.4491,
notes="Large detached, TFA 360, age F, oil PCDB-listed.",
expected_pe_resid_kwh_per_m2=-27.6371,
expected_co2_resid_tonnes_per_yr=-2.5816,
notes="Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges has_draught_lobby=true.",
),
_GoldenExpectation(
cert_number="6035-7729-2309-0879-2296",
@ -128,9 +132,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="8135-1728-8500-0511-3296",
actual_sap=72,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-17.58,
expected_co2_resid_tonnes_per_yr=-0.2234,
notes="Semi-detached, TFA 102, age C, gas PCDB-listed.",
expected_pe_resid_kwh_per_m2=-14.3709,
expected_co2_resid_tonnes_per_yr=-0.1537,
notes="Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges blocked_chimneys_count=1.",
),
_GoldenExpectation(
cert_number="2130-1033-4050-5007-8395",
@ -155,17 +159,19 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0390-2254-6420-2126-5561",
actual_sap=65,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-10.6249,
expected_co2_resid_tonnes_per_yr=-0.1059,
expected_sap_resid=0,
expected_pe_resid_kwh_per_m2=-3.1420,
expected_co2_resid_tonnes_per_yr=+0.0413,
notes=(
"End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, "
"no PV, no secondary, postcode LN12 (PCDB Table 172 match). "
"Cleanest bread-and-butter cert in the cohort; the +1 SAP / -10.6 "
"PE residual is the post-sap_ventilation-fix floor under the "
"remaining mapper gaps (notably schema-21 doesn't carry "
"led_/cfl_fixed_lighting_bulbs_count for this cert, so the §5 "
"lighting efficacy falls back to defaults)."
"Cleanest bread-and-butter cert in the cohort and the first to "
"hit SAP = exact lodged value (post Slice 41 vent-completeness "
"sweep — cert lodges blocked_chimneys_count=2 which reduces "
"infiltration vs the pre-fix zero default). PE / CO2 residuals "
"are now small enough that the remaining drivers are likely "
"lighting efficacy (schema-21 doesn't carry led_/cfl bulb "
"counts for this cert) + boiler PCDB winter efficiency lookup."
),
),
# Retired early at P2.2: 9390-2722-3520-2105-8715 (mid-floor flat,