diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 5b127006..55942f4a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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: diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index a5413a2f..4ccfd756 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -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 diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index e26de635..d80ee557 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -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