Back-solve habitable-room count from full-SAP measured living area 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-15 13:58:03 +00:00
parent af26688846
commit 5ebeb71090
3 changed files with 54 additions and 1 deletions

View file

@ -678,7 +678,10 @@ class EpcPropertyDataMapper:
extensions_count=0,
heated_rooms_count=0,
open_chimneys_count=0,
habitable_rooms_count=0,
# D3: full SAP measures living_area; the engine reads it only via
# 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),
# 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,
@ -2455,6 +2458,30 @@ class EpcPropertyDataMapper:
# ---------------------------------------------------------------------------
# RdSAP 10 Table 27 (p.52) living-area fraction by habitable-room count.
# Mirrored here read-only to back-solve a room count from full SAP's measured
# living_area (single home is the calculator; this is the inverse lookup).
_SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = {
1: 0.75, 2: 0.50, 3: 0.30, 4: 0.25, 5: 0.21, 6: 0.18, 7: 0.16, 8: 0.14,
}
def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int:
"""D3: pick the habitable-room count whose Table 27 fraction is closest to
the measured living_area/total_floor_area, so the engine's Table-27 path
reproduces the measured living-area fraction. Falls back to 0 (engine's
0.21 SAP-convention default) when living_area or floor area is absent."""
if not schema.living_area or not schema.total_floor_area:
return 0
measured_fraction = float(schema.living_area) / float(schema.total_floor_area)
return min(
_SAP_LIVING_AREA_FRACTION_BY_ROOMS,
key=lambda rooms: abs(
_SAP_LIVING_AREA_FRACTION_BY_ROOMS[rooms] - measured_fraction
),
)
def _sap_17_1_building_part(
bp: SapBuildingPart_SAP_17_1, index: int
) -> SapBuildingPart:

View file

@ -209,6 +209,29 @@ class TestFromSapSchema17_1UnknownOpeningType:
EpcPropertyDataMapper.from_sap_schema_17_1(schema)
class TestFromSapSchema17_1LivingArea:
"""Slice 6 (D3): full SAP measures living_area but the engine only reads it
via habitable_rooms_count (Table 27). Back-solve the room count whose Table
27 fraction is closest to the measured living_area/TFA so the existing
engine path reproduces the measured living-area fraction."""
def _map(self, fixture: str) -> EpcPropertyData:
schema = from_dict(SapSchema17_1, load(fixture))
return EpcPropertyDataMapper.from_sap_schema_17_1(schema)
def test_sample_back_solves_rooms(self) -> None:
# 23.45 / 68 = 0.345 → closest Table 27 fraction is 3 rooms (0.30).
assert self._map("sap_17_1.json").habitable_rooms_count == 3
def test_low_living_fraction_back_solves_high_room_count(self) -> None:
# 15.19 / 114 = 0.133 → bottom of Table 27 → 8 rooms.
assert self._map("sap_17_1_house.json").habitable_rooms_count == 8
def test_flat_back_solves_rooms(self) -> None:
# 14.87 / 41 = 0.363 → 3 rooms (0.30 closest).
assert self._map("sap_17_1_flat.json").habitable_rooms_count == 3
class TestFromSapSchema17_1Perimeter:
"""Slice 5 (D1): full SAP lodges no heat-loss perimeter; derive it from the
measured exposed-wall areas (wall_type 1/2/3) ÷ Σ storey-heights, with party

View file

@ -121,3 +121,6 @@ class SapSchema17_1:
floors: List[EnergyElement]
sap_opening_types: List[SapOpeningType]
sap_building_parts: List[SapBuildingPart]
# 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