diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0102e5f3..7dd3b3c2 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2,7 +2,7 @@ import re from dataclasses import replace from datetime import date from decimal import ROUND_HALF_UP, Decimal -from typing import Any, Dict, Final, List, Optional, Sequence, Union, cast +from typing import Any, Dict, Final, List, Optional, Sequence, Tuple, Union, cast from datatypes.epc.schema.helpers import from_dict from datatypes.epc.domain.epc_property_data import ( @@ -51,6 +51,7 @@ from datatypes.epc.schema.sap_schema_17_1 import ( # full-SAP opening-type codes: 1/2/3 = door, 4 = window, 5 = roof window. _SAP_OPENING_TYPE_WINDOW: Final[int] = 4 +_SAP_OPENING_TYPE_DOORS: Final[frozenset[int]] = frozenset({1, 2, 3}) # SAP-typical glazing solar transmittance when an opening-type omits it. _SAP_DEFAULT_SOLAR_TRANSMITTANCE: Final[float] = 0.63 from datatypes.epc.schema.rdsap_schema_18_0 import ( @@ -632,10 +633,10 @@ class EpcPropertyDataMapper: @staticmethod def from_sap_schema_17_1(schema: SapSchema17_1) -> EpcPropertyData: # Built incrementally via TDD — see scripts/hyde/mapping_decisions.md. - # Tracer slice: identity + scalars are mapped; the load-bearing - # collections (walls/floors/roofs, sap_windows, sap_building_parts, - # door/room counts) are left empty here and filled by later slices - # (D1 perimeter, D2 openings, D3 living-area, D4 fabric-U). + # Identity + scalars + fabric descriptions (D4) + openings (D2) are + # mapped; sap_building_parts (D1 perimeter) and habitable_rooms_count + # (D3 living-area) are still filled by later slices. + door_count, insulated_door_u = _sap_door_aggregates(schema) return EpcPropertyData( uprn=schema.uprn, dwelling_type=( @@ -653,13 +654,16 @@ 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, - door_count=0, wet_rooms_count=0, extensions_count=0, heated_rooms_count=0, open_chimneys_count=0, habitable_rooms_count=0, - insulated_door_count=0, + # 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, + insulated_door_count=door_count, + insulated_door_u_value=insulated_door_u, cfl_fixed_lighting_bulbs_count=0, led_fixed_lighting_bulbs_count=0, incandescent_fixed_lighting_bulbs_count=0, @@ -2384,6 +2388,31 @@ class EpcPropertyDataMapper: # --------------------------------------------------------------------------- +def _sap_door_aggregates(schema: SapSchema17_1) -> Tuple[int, Optional[float]]: + """D2: collapse door openings (opening-type 1/2/3) onto the engine's door + model — a count and a single U-value. When a cert lodges doors with + differing U (≈5% of full-SAP certs), the U is area-weighted so the larger + door dominates the door heat loss. Returns (door_count, u_value); U is None + when no doors are lodged.""" + types: Dict[Union[str, int], SapOpeningType_SAP_17_1] = { + ot.name: ot for ot in schema.sap_opening_types + } + count = 0 + weighted_u_area = 0.0 + total_area = 0.0 + for bp in schema.sap_building_parts: + for op in bp.sap_openings: + ot = types.get(op.type) + if ot is None or ot.type not in _SAP_OPENING_TYPE_DOORS: + continue + count += 1 + area = float(op.width) * float(op.height) + weighted_u_area += ot.u_value * area + total_area += area + u_value = weighted_u_area / total_area if total_area > 0 else None + return count, u_value + + def _clear_basement_flag_when_system_built( epc: EpcPropertyData, ) -> EpcPropertyData: diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 0caff7b0..018e0cc6 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -139,3 +139,29 @@ class TestFromSapSchema17_1Windows: def test_window_orientation_passes_through(self, sample: EpcPropertyData) -> None: assert sample.sap_windows[0].orientation == 8 + + +class TestFromSapSchema17_1Doors: + """Slice 4b (D2): door openings (opening-type 1/2/3) collapse onto the + engine's door_count + insulated_door_u_value. New-build doors are treated + insulated, so insulated_door_count == door_count.""" + + def _map(self, fixture: str) -> EpcPropertyData: + schema = from_dict(SapSchema17_1, load(fixture)) + return EpcPropertyDataMapper.from_sap_schema_17_1(schema) + + def test_single_door_count_and_u(self) -> None: + result = self._map("sap_17_1.json") + assert result.door_count == 1 + assert result.insulated_door_u_value == 1.4 + + def test_doors_treated_insulated(self) -> None: + result = self._map("sap_17_1.json") + assert result.insulated_door_count == 1 + + def test_multi_door_u_is_area_weighted(self) -> None: + # Flat lodges 2 doors with distinct U (1.4 over 1.89 m², 1.8 over 2.12 m²) + # → area-weighted mean so the dominant door drives the door heat loss. + result = self._map("sap_17_1_flat.json") + assert result.door_count == 2 + assert result.insulated_door_u_value == pytest.approx(1.6114713, abs=1e-6)