diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 8068485f..722e92c2 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 + ), ), ) diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index cebad47c..ce83ddce 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -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: diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index db89194b..8fdadb72 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -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 diff --git a/datatypes/epc/schema/tests/fixtures/21_0_1.json b/datatypes/epc/schema/tests/fixtures/21_0_1.json index a8c8d645..9eaed954 100644 --- a/datatypes/epc/schema/tests/fixtures/21_0_1.json +++ b/datatypes/epc/schema/tests/fixtures/21_0_1.json @@ -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, diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py index 347b5850..8af56e93 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py @@ -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,