Collapse full-SAP door openings onto door count and area-weighted U-value 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-15 13:39:53 +00:00
parent 70460935b8
commit 36929accf7
2 changed files with 62 additions and 7 deletions

View file

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

View file

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