Carry the surveyed room-in-roof block onto the SAP building part 🟥

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-14 21:59:41 +00:00
parent 895df42afe
commit 9599fd8239
2 changed files with 96 additions and 0 deletions

View file

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

View file

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