From 345154c6b7fab848d6d8acb9da9d96e1df0236db Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 15 Jun 2026 14:37:52 +0000 Subject: [PATCH] =?UTF-8?q?Map=20full-SAP=20measured=20ventilation:=20air?= =?UTF-8?q?=20permeability,=20MV=20kind,=20sheltered=20sides=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 45 ++++++++++++++++++- .../epc/domain/tests/test_from_sap_schema.py | 35 +++++++++++++++ datatypes/epc/schema/sap_schema_17_1.py | 20 +++++++++ .../test_real_cert_sap_accuracy.py | 15 ++++--- 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2badbdf1..78243a09 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -64,6 +64,21 @@ _SAP_KNOWN_WALL_TYPES: Final[frozenset[int]] = frozenset({1, 2, 3, 4, 5}) # isn't parseable from a wall description, the engine derives from the newest # RdSAP age band M. Used only for that fallback + secondary age-band logic. _SAP_DEFAULT_AGE_BAND: Final[str] = "M" +# full-SAP standard `ventilation_type` code → MechanicalVentilationKind name +# (None = natural). MVHR (7) is deferred to natural — like the RdSAP API path +# (_API_MECHANICAL_VENTILATION_TO_KIND code 4), its (24a) formula needs the +# PCDB heat-recovery efficiency, not yet plumbed; mapping to MVHR with a null +# efficiency would mis-model it as MV. None of the corpus lodges 7. +_SAP_VENTILATION_TYPE_TO_MV_KIND: Final[Dict[int, Optional[str]]] = { + 1: None, # natural (with intermittent extract fans) + 2: None, # passive stack — treated as natural + 3: None, # positive input from loft → natural + 4: "EXTRACT_OR_PIV_OUTSIDE", # positive input from outside + 5: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, centralised (MEV c) + 6: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, decentralised (MEV dc) + 7: None, # MVHR — deferred (efficiency not plumbed) + 8: "MV", # balanced mechanical, no heat recovery +} # rdsap_uvalues WALL_CAVITY = 4 (D7 fallback; U comes from the description). _SAP_DEFAULT_WALL_CONSTRUCTION: Final[int] = 4 # SAP-typical glazing solar transmittance when an opening-type omits it. @@ -675,7 +690,6 @@ class EpcPropertyDataMapper: has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", solar_water_heating=False, - wet_rooms_count=0, extensions_count=0, heated_rooms_count=0, open_chimneys_count=0, @@ -683,6 +697,13 @@ class EpcPropertyDataMapper: # habitable_rooms_count (Table 27). Back-solve the count whose # Table-27 fraction best matches the measured living_area/TFA. habitable_rooms_count=_sap_back_solved_habitable_rooms(schema), + # D5-vent: measured ventilation (air permeability AP4, MV kind, + # sheltered sides, wet rooms, MEV PCDB index). + wet_rooms_count=schema.sap_ventilation.wet_rooms_count or 0, + sap_ventilation=_sap_17_1_ventilation(schema), + mechanical_ventilation_index_number=( + schema.sap_ventilation.mechanical_vent_system_index_number + ), # D2: door openings (1/2/3) → counts + area-weighted U. New-build # doors are treated insulated, so insulated_door_count == door_count. door_count=door_count, @@ -2523,6 +2544,28 @@ _SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = { } +def _sap_17_1_ventilation(schema: SapSchema17_1) -> SapVentilation: + """D5-vent: map full-SAP `sap_ventilation` onto the domain `SapVentilation`. + The measured `air_permeability` feeds the engine's AP4 path directly (vs the + RdSAP age-band default); `ventilation_type` → MechanicalVentilationKind name + (unknown code fails loud).""" + sv = schema.sap_ventilation + mv_kind: Optional[str] = None + if sv.ventilation_type is not None: + if sv.ventilation_type not in _SAP_VENTILATION_TYPE_TO_MV_KIND: + raise UnmappedApiCode("ventilation_type", sv.ventilation_type) + mv_kind = _SAP_VENTILATION_TYPE_TO_MV_KIND[sv.ventilation_type] + return SapVentilation( + air_permeability_ap4_m3_h_m2=sv.air_permeability, + mechanical_ventilation_kind=mv_kind, + sheltered_sides=sv.sheltered_sides_count, + pressure_test=str(sv.pressure_test) if sv.pressure_test is not None else None, + extract_fans_count=sv.extract_fans_count, + open_flues_count=sv.open_flues_count, + flueless_gas_fires_count=sv.flueless_gas_fires_count, + ) + + # SAP main_fuel_type code for mains gas. _SAP_MAINS_GAS_FUEL_CODE: Final[int] = 1 diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index e0c21b11..e462991b 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -254,6 +254,41 @@ class TestFromSapSchema17_1EnergySource: assert sample.sap_energy_source.wind_turbines_count == 0 +class TestFromSapSchema17_1Ventilation: + """Slice D5-vent: full-SAP sap_ventilation → measured air permeability (AP4), + ventilation_type → MechanicalVentilationKind, sheltered sides, wet rooms and + the MEV PCDB index.""" + + @pytest.fixture + def sample(self) -> EpcPropertyData: + schema = from_dict(SapSchema17_1, load("sap_17_1.json")) + return EpcPropertyDataMapper.from_sap_schema_17_1(schema) + + def test_measured_air_permeability_fed_as_ap4(self, sample: EpcPropertyData) -> None: + assert sample.sap_ventilation is not None + assert sample.sap_ventilation.air_permeability_ap4_m3_h_m2 == 2.6 + + def test_ventilation_type_6_is_extract(self, sample: EpcPropertyData) -> None: + # ventilation_type 6 = MEV decentralised → EXTRACT_OR_PIV_OUTSIDE. + assert sample.sap_ventilation is not None + assert sample.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" + + def test_sheltered_sides_and_wet_rooms(self, sample: EpcPropertyData) -> None: + assert sample.sap_ventilation is not None + assert sample.sap_ventilation.sheltered_sides == 1 + assert sample.wet_rooms_count == 2 + + def test_mev_index_for_pcdb_lookup(self, sample: EpcPropertyData) -> None: + assert sample.mechanical_ventilation_index_number == 500229 + + def test_unknown_ventilation_type_fails_loud(self) -> None: + data = load("sap_17_1.json") + data["sap_ventilation"]["ventilation_type"] = 99 + schema = from_dict(SapSchema17_1, data) + with pytest.raises(UnmappedApiCode): + EpcPropertyDataMapper.from_sap_schema_17_1(schema) + + class TestFromSapSchema17_1Heating: """Slice D6: full-SAP sap_heating (differing field names) maps onto the domain SapHeating + MainHeatingDetail the calculator consumes.""" diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index 44986c22..2ec199b9 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -88,6 +88,25 @@ class SapBuildingPart: building_part_number: Optional[int] = None +@dataclass +class SapVentilation: + """Measured ventilation. `air_permeability` is the as-tested AP4 value the + engine consumes directly (vs RdSAP's age-band default). `ventilation_type` + is the standard SAP code (1 natural … 5/6 MEV, 7 MVHR, 8 MV).""" + + air_permeability: Optional[float] = None + pressure_test: Optional[int] = None + ventilation_type: Optional[int] = None + sheltered_sides_count: Optional[int] = None + wet_rooms_count: Optional[int] = None + extract_fans_count: Optional[int] = None + open_flues_count: Optional[int] = None + flueless_gas_fires_count: Optional[int] = None + open_fireplaces_count: Optional[int] = None + mechanical_vent_system_index_number: Optional[int] = None + mechanical_vent_duct_type: Optional[int] = None + + @dataclass class SapEnergySource: """Electricity tariff, on-site generation and lighting. Lighting outlet @@ -171,6 +190,7 @@ class SapSchema17_1: sap_building_parts: List[SapBuildingPart] sap_heating: SapHeating sap_energy_source: SapEnergySource = field(default_factory=SapEnergySource) + sap_ventilation: SapVentilation = field(default_factory=SapVentilation) # measured living-room area (m²); the engine consumes it via a back-solved # habitable_rooms_count (Table 27). Optional — 100% present in the corpus. living_area: Optional[Union[int, float]] = None diff --git a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py index 3cad3725..6d8b89ed 100644 --- a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py +++ b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py @@ -92,17 +92,18 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # support has landed (datatypes/epc/domain/mapper.py from_sap_schema_17_1; # design: scripts/hyde/mapping_decisions.md), so the chain now runs through # the RdSAP SAP-10 engine end-to-end. Lodged rating is 83; the engine - # produces 81 (−2) — a small, expected residual: full SAP carries measured - # fabric the RdSAP engine partly re-derives, plus the mapper fabricates - # RdSAP proxies absent from full SAP (age band, habitable-room count back- - # solved from the measured living area). PINNED TO THE OBSERVED 81, not the - # lodged 83 — the mapping is deliberately not tuned to hit the lodged value; - # the −2 is to be reconciled with the domain expert against a worksheet. + # produces 77 (−6). The mapper faithfully feeds the cert's measured + # decentralised MEV (ventilation_type 6 → EXTRACT_OR_PIV_OUTSIDE), which the + # engine prices as added extract-ventilation loss (the −4 driver isolated; + # measured air permeability / fabric drive the rest). PINNED TO THE OBSERVED + # 77, not the lodged 83 — the mapping is deliberately not tuned; the −6 (and + # the engine's full-SAP MEV treatment) is for the separate SAP-calc + # verification task to reconcile against a worksheet. RealCertExpectation( schema="SAP-Schema-17.1", sample="uprn_10092973954", cert_num="0862-3892-7875-2690-2325", - sap_score=81, + sap_score=77, ), # UPRN 10002468137 → cert 0215-2818-7357-9703-2145. RdSAP-Schema-17.1, # all-electric high-heat-retention storage heaters on Economy 7, solid-