Slice 40: room_in_roof_type_1 gable lengths flow through schema-21 to EpcPropertyData

Schema-21.0.0/0.1's SapRoomInRoof dataclass declared only floor_area
and construction_age_band. Real certs lodge gable wall lengths under
sap_room_in_roof.room_in_roof_type_1 (RdSAP §3.9.1 Simplified Type 1).
from_dict silently dropped the whole block at deserialization, so the
mapper never had a chance to surface the lengths on EpcPropertyData.

Fix: add RoomInRoofType1 dataclass to both schema-21 variants;
extend SapRoomInRoof with `room_in_roof_type_1: Optional[...]`;
update the mapper to populate EpcPropertyData.SapRoomInRoof
gable_1_length_m / gable_2_length_m from the new field.

Calculator behaviour unchanged this slice: heat_transmission.py:243
requires BOTH length AND height to contribute gable area, and the
cert lodges length only (RdSAP §3.9.1 uses a default 2.45 m storey
height — not yet plumbed). Cert 0240's −12 SAP residual unchanged.

Schema scope: both 21.0.0 and 21.0.1 schemas (identical SapBuildingPart
mapper code, kept consistent). Older schemas (17/18/19/20) don't carry
this RR shape on their dataclasses and are out of scope per the prior
cohort scope decision.

Unblocks the follow-up slices that close the RR cascade: default
H_gable in calculator or mapper, parse "Roof room(s), insulated
(assumed)" description for the U-value override, etc.

930/930 Elmhurst cascade green. 14/14 golden cohort green at pinned
residuals (no shift, as expected). 76/76 mapper tests green.
Pyright net-zero (32 errors before and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 14:14:25 +00:00
parent 1d7c13b995
commit fb3973457a
5 changed files with 85 additions and 1 deletions

View file

@ -1233,6 +1233,19 @@ class EpcPropertyDataMapper:
SapRoomInRoof(
floor_area=_measurement_value(bp.sap_room_in_roof.floor_area),
construction_age_band=bp.sap_room_in_roof.construction_age_band,
# RdSAP §3.9.1 Simplified Type 1: gable lengths
# only (no heights — the cascade applies the
# 2.45 m default storey height per §3.9.1).
gable_1_length_m=(
bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_1
if bp.sap_room_in_roof.room_in_roof_type_1 is not None
else None
),
gable_2_length_m=(
bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_2
if bp.sap_room_in_roof.room_in_roof_type_1 is not None
else None
),
)
if bp.sap_room_in_roof
else None
@ -1478,6 +1491,19 @@ class EpcPropertyDataMapper:
SapRoomInRoof(
floor_area=_measurement_value(bp.sap_room_in_roof.floor_area),
construction_age_band=bp.sap_room_in_roof.construction_age_band,
# RdSAP §3.9.1 Simplified Type 1: gable lengths
# only (no heights — the cascade applies the
# 2.45 m default storey height per §3.9.1).
gable_1_length_m=(
bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_1
if bp.sap_room_in_roof.room_in_roof_type_1 is not None
else None
),
gable_2_length_m=(
bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_2
if bp.sap_room_in_roof.room_in_roof_type_1 is not None
else None
),
)
if bp.sap_room_in_roof
else None

View file

@ -587,6 +587,25 @@ class TestFromRdSapSchema21_0_1:
def test_party_wall_length(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m == 7.9
# --- room-in-roof (sap_room_in_roof.room_in_roof_type_1) ---
def test_sap_room_in_roof_gable_lengths_extracted_from_room_in_roof_type_1(
self, result: EpcPropertyData
) -> None:
# Arrange — schema-21.0.1 lodges Simplified Type 1 gable lengths
# under sap_room_in_roof.room_in_roof_type_1. The cascade requires
# them on EpcPropertyData.SapRoomInRoof.gable_1_length_m /
# gable_2_length_m for the §3.9.2 area cascade. Without this the
# length data is silently dropped at deserialization.
# Act
rir = result.sap_building_parts[0].sap_room_in_roof
# Assert
assert rir is not None
assert rir.gable_1_length_m == 6.4
assert rir.gable_2_length_m == 6.4
# --- ventilation (sap_ventilation) ---
def test_sap_ventilation_extract_fans_count_flows_through_to_calculator_input(

View file

@ -166,11 +166,26 @@ class SapFloorDimension:
floor_construction: Optional[int] = None
@dataclass
class RoomInRoofType1:
"""RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only.
`gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.;
full enum not yet mapped). `gable_wall_length_*` is the run of the
external gable in metres. Heights are NOT lodged here the cascade
applies the §3.9.1 default storey height (2.45 m)."""
gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None
gable_wall_length_2: Optional[float] = None
@dataclass
class SapRoomInRoof:
"""Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0."""
floor_area: Union[int, float]
construction_age_band: str
room_in_roof_type_1: Optional[RoomInRoofType1] = None
@dataclass

View file

@ -176,10 +176,25 @@ class SapFloorDimension:
floor_construction: Optional[int] = None
@dataclass
class RoomInRoofType1:
"""RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only.
`gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.;
full enum not yet mapped). `gable_wall_length_*` is the run of the
external gable in metres. Heights are NOT lodged here the cascade
applies the §3.9.1 default storey height (2.45 m)."""
gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None
gable_wall_length_2: Optional[float] = None
@dataclass
class SapRoomInRoof:
floor_area: Union[int, float]
construction_age_band: str
room_in_roof_type_1: Optional[RoomInRoofType1] = None
@dataclass

View file

@ -126,7 +126,16 @@
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"floor_heat_loss": 7,
"sap_room_in_roof": {"floor_area": 100, "construction_age_band": "B"},
"sap_room_in_roof": {
"floor_area": 100,
"construction_age_band": "B",
"room_in_roof_type_1": {
"gable_wall_type_1": 0,
"gable_wall_type_2": 0,
"gable_wall_length_1": 6.4,
"gable_wall_length_2": 6.4
}
},
"roof_construction": 4,
"wall_construction": 4,
"building_part_number": 1,