Collapse full-SAP window openings onto the engine's sap_windows model 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-15 13:32:20 +00:00
parent 0eaf87b106
commit 70460935b8
3 changed files with 125 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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]