Extract the PasHub detailed room-in-roof block 🟩

_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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-14 22:09:44 +00:00
parent 518c682793
commit 955b4dc1e8
2 changed files with 48 additions and 0 deletions

View file

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

View file

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