diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 5c54b9fc..0102e5f3 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -46,7 +46,13 @@ from datatypes.epc.schema.rdsap_schema_17_1 import ( from datatypes.epc.schema.sap_schema_17_1 import ( SapSchema17_1, EnergyElement as EnergyElement_SAP_17_1, + SapOpeningType as SapOpeningType_SAP_17_1, ) + +# full-SAP opening-type codes: 1/2/3 = door, 4 = window, 5 = roof window. +_SAP_OPENING_TYPE_WINDOW: Final[int] = 4 +# 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 ( RdSapSchema18_0, EnergyElement as EnergyElement_18_0, @@ -666,7 +672,8 @@ class EpcPropertyDataMapper: walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), main_heating=[], - sap_windows=[], + # D2: vertical-window openings (opening-type 4) → sap_windows. + sap_windows=EpcPropertyDataMapper._sap_17_1_windows(schema), sap_building_parts=[], sap_heating=SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), @@ -685,6 +692,49 @@ class EpcPropertyDataMapper: ), ) + @staticmethod + def _sap_17_1_windows(schema: SapSchema17_1) -> List[SapWindow]: + """D2: collapse vertical-window openings (opening-type 4) onto the + engine's `SapWindow` model. Openings carry measured width/height + + orientation; the joined opening-type carries the measured U-value, + frame factor and (optionally) solar transmittance. Doors (1/2/3) and + roof windows (5) are handled separately; unknown codes fail loud.""" + types: Dict[Union[str, int], SapOpeningType_SAP_17_1] = { + ot.name: ot for ot in schema.sap_opening_types + } + windows: List[SapWindow] = [] + 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 != _SAP_OPENING_TYPE_WINDOW: + continue + windows.append( + SapWindow( + frame_material=None, + glazing_gap=0, + orientation=op.orientation if op.orientation is not None else 0, + window_type=ot.frame_type if ot.frame_type is not None else 0, + glazing_type=ot.glazing_type, + window_width=float(op.width), + window_height=float(op.height), + draught_proofed=False, + window_location=op.location or "", + window_wall_type=0, + permanent_shutters_present=False, + frame_factor=ot.frame_factor, + window_transmission_details=WindowTransmissionDetails( + u_value=ot.u_value, + data_source=str(ot.data_source), + solar_transmittance=( + ot.solar_transmittance + if ot.solar_transmittance is not None + else _SAP_DEFAULT_SOLAR_TRANSMITTANCE + ), + ), + ) + ) + return windows + @staticmethod def from_rdsap_schema_17_1(schema: RdSapSchema17_1) -> EpcPropertyData: es = schema.sap_energy_source diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index fe31b8e3..0caff7b0 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -106,3 +106,36 @@ class TestFromSapSchema17_1FabricDescriptions: schema = from_dict(SapSchema17_1, load(fixture)) result = EpcPropertyDataMapper.from_sap_schema_17_1(schema) assert result.walls and result.walls[0].description + + +class TestFromSapSchema17_1Windows: + """Slice 4a (D2): vertical-window openings (opening-type 4) collapse onto + sap_windows with measured per-window geometry and U-value.""" + + @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_maps_every_window_opening(self, sample: EpcPropertyData) -> None: + # Sample lodges 5 window openings (+1 door, 0 roof windows). + assert len(sample.sap_windows) == 5 + + def test_window_area_from_measured_width_height( + self, sample: EpcPropertyData + ) -> None: + # First window opening: width 1 × height 5.92 — engine computes area + # as window_width × window_height, so both must pass through. + w = sample.sap_windows[0] + assert w.window_width == 1 + assert w.window_height == 5.92 + + def test_window_u_value_from_opening_type(self, sample: EpcPropertyData) -> None: + # Joined opening-type "Windows (1)" lodges u_value 1.4 — must land in + # window_transmission_details so the engine area-weights the real U. + details = sample.sap_windows[0].window_transmission_details + assert details is not None + assert details.u_value == 1.4 + + def test_window_orientation_passes_through(self, sample: EpcPropertyData) -> None: + assert sample.sap_windows[0].orientation == 8 diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index bc332a78..cc5397f0 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -9,12 +9,49 @@ corpus.jsonl`), since the custom `from_dict` ignores unmodelled keys and raises only on a missing *required* field. """ -from dataclasses import dataclass -from typing import List, Union +from dataclasses import dataclass, field +from typing import List, Optional, Union from .common import DescriptionV1 +@dataclass +class SapOpeningType: + """A reusable opening definition (door / window / roof window) joined to + each `SapOpening` by `name`. `type`: 1/2/3 = door, 4 = window, 5 = roof + window/rooflight. `u_value` is the measured per-opening U.""" + + name: Union[str, int] + type: int + u_value: float + glazing_type: int + data_source: int + frame_type: Optional[int] = None + frame_factor: Optional[float] = None + solar_transmittance: Optional[float] = None + + +@dataclass +class SapOpening: + """A placed opening within a building part. `type` is the join key into + `sap_opening_types[].name`. `width`/`height` give the measured area.""" + + name: Union[str, int] + type: Union[str, int] + width: Union[int, float] + height: Union[int, float] + location: Optional[str] = None + orientation: Optional[int] = None + + +@dataclass +class SapBuildingPart: + """A building part. Modelled incrementally — slice 4 needs `sap_openings`; + slice 5 (perimeter) will add `sap_walls` / `sap_floor_dimensions`.""" + + sap_openings: List[SapOpening] = field(default_factory=list) + + @dataclass class EnergyElement: """A fabric/system element with its lodged description. On full SAP the @@ -46,3 +83,5 @@ class SapSchema17_1: roofs: List[EnergyElement] walls: List[EnergyElement] floors: List[EnergyElement] + sap_opening_types: List[SapOpeningType] + sap_building_parts: List[SapBuildingPart]