From 955b4dc1e895524bea7aaed0d941f9316b3bcbdc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jul 2026 22:09:44 +0000 Subject: [PATCH] =?UTF-8?q?Extract=20the=20PasHub=20detailed=20room-in-roo?= =?UTF-8?q?f=20block=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _parse_room_in_roof captures the RdSAP §3.10 block (age range, floor area, gables with Exposed/Party type, slopes, common walls, flat ceiling as length x height) on main and extension roof spaces; both part builders now carry it to sap_room_in_roof (issue #1590 bug 5 — closes the last #1590 subtask). Harness MAE 1.748 (754917 corrects to pashub's own pre-SAP; RIR surfaces now billed per-surface instead of as insulated loft). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/documents_parser/extractor.py | 47 +++++++++++++++++++++++++++ datatypes/epc/domain/mapper.py | 1 + 2 files changed, 48 insertions(+) diff --git a/backend/documents_parser/extractor.py b/backend/documents_parser/extractor.py index 42ad98a56..b5e0f9582 100644 --- a/backend/documents_parser/extractor.py +++ b/backend/documents_parser/extractor.py @@ -19,6 +19,8 @@ from datatypes.epc.surveys.pashub_rdsap_site_notes import ( MainHeating, PasHubRdSapSiteNotes, PvArrayDetail, + RoomInRoofDetail, + RoomInRoofSurfaceDetail, Renewables, RoomCountElements, RoofSpace, @@ -672,6 +674,49 @@ class PasHubRdSapSiteNotesExtractor: except (ValueError, IndexError): return None, val + def _parse_rir_surface( + self, data: List[str], prefix: str, with_type: bool = False + ) -> Optional[RoomInRoofSurfaceDetail]: + """One room-in-roof surface's "{prefix} length/height[/type]" lines + ("6.64 m" → 6.64). None when the surface isn't lodged.""" + length = self._get_in(data, f"{prefix} length:") + height = self._get_in(data, f"{prefix} height:") + if length is None and height is None: + return None + return RoomInRoofSurfaceDetail( + length_m=float(length.split()[0]) if length else None, + height_m=float(height.split()[0]) if height else None, + gable_type=self._get_in(data, f"{prefix} type:") if with_type else None, + ) + + def _parse_room_in_roof(self, data: List[str]) -> Optional[RoomInRoofDetail]: + """The RdSAP §3.10 detailed room-in-roof block lodged under "Are there + rooms in the roof? Yes" — dropped detail bills ~the whole RIR shell as + well-insulated loft (issue #1590 bug 5).""" + if not self._bool_in(data, "Are there rooms in the roof?"): + return None + area_raw = self._get_in(data, "Floor area of room in roof:") + + def surfaces( + prefix: str, with_type: bool = False + ) -> Optional[List[RoomInRoofSurfaceDetail]]: + out = [ + s + for n in (1, 2) + if (s := self._parse_rir_surface(data, f"{prefix} {n}", with_type)) + is not None + ] + return out or None + + return RoomInRoofDetail( + age_range=self._get_in(data, "Roof room age range:"), + floor_area_m2=float(area_raw.split()[0]) if area_raw else None, + gables=surfaces("Gable wall", with_type=True), + slopes=surfaces("Slope"), + common_walls=surfaces("Common wall"), + flat_ceilings=surfaces("Flat ceiling"), + ) + def _parse_roof_space_detail(self, data: List[str]) -> RoofSpaceDetail: thickness_mm, thickness_str = self._parse_insulation_thickness( self._get_in(data, "Roofs - Insulation Thickness:") @@ -687,6 +732,7 @@ class PasHubRdSapSiteNotesExtractor: rooms_in_roof=self._bool_in(data, "Are there rooms in the roof?"), insulation_thickness_mm=thickness_mm, insulation_thickness=thickness_str, + room_in_roof=self._parse_room_in_roof(data), ) def _parse_extension_roof_space( @@ -707,6 +753,7 @@ class PasHubRdSapSiteNotesExtractor: rooms_in_roof=self._bool_in(data, "Are there rooms in the roof?"), insulation_thickness_mm=thickness_mm, insulation_thickness=thickness_str, + room_in_roof=self._parse_room_in_roof(data), ) def _parse_floor_measurements(self, data: List[str]) -> List[FloorMeasurement]: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 7cb9acef5..7a20c567a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6085,6 +6085,7 @@ def _map_extension_building_part( roof_location, roof_thickness = _map_roof(roof) return SapBuildingPart( identifier=BuildingPartIdentifier.extension(ext_c.id), + sap_room_in_roof=_pashub_room_in_roof(roof) if roof is not None else None, construction_age_band=_extract_age_band(ext_c.age_range), # PasHub surveys never lodge a basement wall; pin the flag so a # "System Build" wall (code 6, which doubles as the gov-API basement