From 9599fd823978bc3d3d9a69d839223a6babeac22b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jul 2026 21:59:41 +0000 Subject: [PATCH] =?UTF-8?q?Carry=20the=20surveyed=20room-in-roof=20block?= =?UTF-8?q?=20onto=20the=20SAP=20building=20part=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/domain/tests/test_from_site_notes.py | 69 +++++++++++++++++++ .../epc/surveys/pashub_rdsap_site_notes.py | 27 ++++++++ 2 files changed, 96 insertions(+) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 42f8e62bb..58956d9a8 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -122,6 +122,75 @@ class TestPhotovoltaicArrayMapping: assert result.sap_energy_source.photovoltaic_arrays is None +class TestRoomInRoofMapping: + """A surveyed room-in-roof block (RdSAP §3.10 detailed measurement) must + reach `sap_building_parts[*].sap_room_in_roof` — left None, ~32 m² of + poorly-insulated RIR surfaces are billed as well-insulated loft (issue + #1590 bug 5; fixture 499617935574 over-rates by ~2-3.5 SAP). Values below + are that survey's verbatim lodgement. Gable types code per Table 4: + Exposed → gable_wall_external, Party → gable_wall. + """ + + _RIR = { + "age_range": "B: 1900 - 1929", + "floor_area_m2": 32.35, + "gables": [ + {"length_m": 6.64, "height_m": 2.74, "gable_type": "Exposed"}, + {"length_m": 6.64, "height_m": 2.74, "gable_type": "Party"}, + ], + "slopes": [ + {"length_m": 4.94, "height_m": 1.73}, + {"length_m": 5.0, "height_m": 2.95}, + ], + "common_walls": [ + {"length_m": 4.94, "height_m": 1.45}, + {"length_m": 4.94, "height_m": 1.3}, + ], + "flat_ceilings": [{"length_m": 4.94, "height_m": 5.17}], + } + + def test_room_in_roof_reaches_building_part(self) -> None: + # Arrange + data = load("pashub_rdsap_site_notes_example1.json") + data["roof_space"]["main_building"]["rooms_in_roof"] = True + data["roof_space"]["main_building"]["room_in_roof"] = self._RIR + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + rir = EpcPropertyDataMapper.from_site_notes(survey).sap_building_parts[ + 0 + ].sap_room_in_roof + + # Assert + assert rir is not None + assert rir.floor_area == 32.35 + assert rir.construction_age_band == "B" + surfaces = rir.detailed_surfaces + assert surfaces is not None + kinds_areas = [(s.kind, round(s.area_m2, 2)) for s in surfaces] + assert kinds_areas == [ + ("gable_wall_external", round(6.64 * 2.74, 2)), + ("gable_wall", round(6.64 * 2.74, 2)), + ("slope", round(4.94 * 1.73, 2)), + ("slope", round(5.0 * 2.95, 2)), + ("common_wall", round(4.94 * 1.45, 2)), + ("common_wall", round(4.94 * 1.3, 2)), + ("flat_ceiling", round(4.94 * 5.17, 2)), + ] + + def test_no_rooms_in_roof_stays_absent(self) -> None: + # Arrange — the fixture as lodged (rooms_in_roof=false). + survey = from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json") + ) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert + assert result.sap_building_parts[0].sap_room_in_roof is None + + class TestSystemBuildWallIsNotBasement: """RdSAP wall-construction code 6 is overloaded: PasHub "System Build (i.e Any Other)" maps to 6, but 6 doubles as the gov-API basement-wall diff --git a/datatypes/epc/surveys/pashub_rdsap_site_notes.py b/datatypes/epc/surveys/pashub_rdsap_site_notes.py index ba629f41e..4423f077a 100644 --- a/datatypes/epc/surveys/pashub_rdsap_site_notes.py +++ b/datatypes/epc/surveys/pashub_rdsap_site_notes.py @@ -105,6 +105,32 @@ class BuildingMeasurements: extensions: Optional[List[ExtensionMeasurements]] = None +@dataclass +class RoomInRoofSurfaceDetail: + """One surveyed room-in-roof surface (gable / slope / common wall / flat + ceiling): length × height, an optional measured U, and — for gables — + the Exposed/Party type. Raw survey values; SAP coding happens at the + mapper boundary (ADR-0015).""" + + length_m: Optional[float] = None + height_m: Optional[float] = None + u_value: Optional[float] = None + gable_type: Optional[str] = None + + +@dataclass +class RoomInRoofDetail: + """The surveyed §Roof-Space room-in-roof block (RdSAP §3.10 detailed + measurement): age range, floor area, and the per-surface detail.""" + + age_range: Optional[str] = None + floor_area_m2: Optional[float] = None + gables: Optional[List[RoomInRoofSurfaceDetail]] = None + slopes: Optional[List[RoomInRoofSurfaceDetail]] = None + common_walls: Optional[List[RoomInRoofSurfaceDetail]] = None + flat_ceilings: Optional[List[RoomInRoofSurfaceDetail]] = None + + @dataclass class RoofSpaceDetail: construction_type: str @@ -115,6 +141,7 @@ class RoofSpaceDetail: # Numeric thickness (mm) when known; string (e.g. "As built") when not measured insulation_thickness_mm: Optional[int] = None insulation_thickness: Optional[str] = None + room_in_roof: Optional[RoomInRoofDetail] = None @dataclass