Slice 100a: API path — surface Detailed-RR per-surface areas

Two RR shapes coexist in real-API JSON: cohort certs (6035, 0240,
schema test 21_0_1.json) lodge `room_in_roof_type_1` (RdSAP §3.9.1
Simplified Type 1 — gable lengths only, cascade applies the 2.45 m
default storey height); cert 9501 lodges `room_in_roof_details`
(RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-
ceiling detail). The schema only modelled the Simplified-Type-1
wrapper, so `from_dict` parsed cert 9501's Detailed-RR block as
None and the API mapper built `SapRoomInRoof` with `detailed_
surfaces=None`. The cascade then defaulted to Simplified Type 2
"all elements" (RR floor area × Table 18 col(4) age-B U=2.30) for
the whole RR → roof HLC 149.43 W/K vs worksheet 18.10 (Δ +131.32).

Changes:
- Add `RoomInRoofDetails` dataclass to both schema 21.0.0 and 21.0.1
  with the 10 fields the JSON lodges: gable_wall_type_{1,2} +
  gable_wall_length_{1,2} + gable_wall_height_{1,2} + flat_ceiling_
  length_1 + flat_ceiling_height_1 + flat_ceiling_insulation_
  type_1 + flat_ceiling_insulation_thickness_1. `SapRoomInRoof`
  gains a sibling `room_in_roof_details` field next to the legacy
  `room_in_roof_type_1`; both shapes are now lossless.
- Extract `_api_build_room_in_roof` mapper helper that reads from
  whichever block is present and populates
  `SapRoomInRoof.detailed_surfaces` from the Detailed-RR block.
  Gables route to `gable_wall_external` for flats (top-floor flats
  with RR sit at the end of the building, no neighbour above) and
  to `gable_wall` (party at U=0.25) otherwise — mirrors the Summary
  mapper's `_map_elmhurst_rir_surface` heuristic.
- Replace both inline `SapRoomInRoof(...)` builds in
  `from_rdsap_schema_21_0_0` and `from_rdsap_schema_21_0_1` with
  the helper.

Effect on cert 9501 API path:
- roof HLC 149.43 → 18.10 (= worksheet 18.10 exact)
- walls HLC 168.74 → 218.81 (= worksheet 218.81 exact)
- (37) total HLC 382.19 → 297.54 (worksheet 296.68; Δ +0.86)
- sap_continuous still -9.27 vs worksheet because TFA on the API
  path is still 81.28 (missing the 31.8 m² RR floor area) — next
  slice closes that.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 22:01:41 +00:00
parent 0735c7e81c
commit 7d46018386
4 changed files with 172 additions and 40 deletions

View file

@ -497,6 +497,43 @@ _API_0330_JSON = (
/ "0330-2249-8150-2326-4121.json"
)
_API_9501_JSON = (
Path(__file__).parents[3]
/ "domain/sap10_calculator/rdsap/tests/fixtures/golden"
/ "9501-3059-8202-7356-0204.json"
)
def test_api_9501_room_in_roof_surfaces_populated() -> None:
# Arrange — cert 9501's API JSON lodges measured RR detail under
# `sap_room_in_roof.room_in_roof_details`: two gable walls
# (5.51 m × 2.45 m + 6.51 m × 2.45 m) and a flat ceiling (5.5 m ×
# 1.0 m, 300 mm insulation). The schema's `SapRoomInRoof` dataclass
# exposed the inner block under the wrong field name
# `room_in_roof_type_1` (the legacy Simplified Type 1 wrapper),
# so `from_dict` parsed the inner block as None — the API mapper
# then built `SapRoomInRoof` with no per-surface area data, and
# the cascade defaulted to the Simplified Type 2 "all elements"
# branch (RR floor_area × Table 18 col(4) age-B U=2.30) for the
# whole RR → roof HLC 149.43 vs worksheet 18.10 (Δ +131).
doc = json.loads(_API_9501_JSON.read_text())
# Act
epc = EpcPropertyDataMapper.from_api_response(doc)
# Assert — RR surfaces present and match worksheet element table:
# Gable Wall 1 = 13.50 m², Gable Wall 2 = 15.95 m², Flat Ceiling 1
# = 5.50 m² (per worksheet §3 element table).
rir = epc.sap_building_parts[0].sap_room_in_roof
assert rir is not None
assert rir.detailed_surfaces is not None
kinds_by_area = sorted((s.kind, s.area_m2) for s in rir.detailed_surfaces)
assert kinds_by_area == [
("flat_ceiling", 5.5),
("gable_wall_external", 13.50),
("gable_wall_external", 15.95),
]
def test_api_0330_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
# Arrange — cert 0330-2249-8150-2326-4121 (second boiler validation

View file

@ -1354,26 +1354,9 @@ class EpcPropertyDataMapper:
bp.roof_insulation_thickness,
bp.construction_age_band,
),
sap_room_in_roof=(
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
sap_room_in_roof=_api_build_room_in_roof(
bp.sap_room_in_roof,
is_flat=schema.property_type == 2,
),
sap_alternative_wall_1=(
SapAlternativeWall(
@ -1668,26 +1651,9 @@ class EpcPropertyDataMapper:
bp.roof_insulation_thickness,
bp.construction_age_band,
),
sap_room_in_roof=(
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
sap_room_in_roof=_api_build_room_in_roof(
bp.sap_room_in_roof,
is_flat=schema.property_type == 2,
),
sap_alternative_wall_1=(
SapAlternativeWall(
@ -2376,6 +2342,94 @@ def _api_build_sap_floor_dimensions(
return out
def _api_build_room_in_roof(
bp_rir: Any, *, is_flat: bool = False,
) -> Optional[SapRoomInRoof]:
"""Build `SapRoomInRoof` from the API schema's per-bp RR block. Two
real-API shapes coexist:
- `room_in_roof_type_1` (cohort certs 6035, 0240): RdSAP §3.9.1
Simplified Type 1 gable lengths only, cascade applies the
2.45 m default storey height.
- `room_in_roof_details` (cert 9501): RdSAP §3.9 Detailed RR
per-surface lengths + heights + flat-ceiling detail.
When the Detailed block is present, build `detailed_surfaces` so the
cascade's per-surface RR branch (heat_transmission.py:629) picks
up exact gable + flat-ceiling areas instead of falling through to
the Table 18 col(4) "all elements" default U.
"""
if bp_rir is None:
return None
rir = SapRoomInRoof(
floor_area=_measurement_value(bp_rir.floor_area),
construction_age_band=bp_rir.construction_age_band,
)
type_1 = getattr(bp_rir, "room_in_roof_type_1", None)
if type_1 is not None:
# RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights —
# the cascade applies the 2.45 m default storey height).
rir.gable_1_length_m = type_1.gable_wall_length_1
rir.gable_2_length_m = type_1.gable_wall_length_2
details = getattr(bp_rir, "room_in_roof_details", None)
if details is not None:
rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat)
return rir
def _api_rir_detailed_surfaces(
details: Any, *, is_flat: bool,
) -> Optional[List[SapRoomInRoofSurface]]:
"""Translate the API `room_in_roof_details` block into the per-surface
list the cascade's Detailed-RR branch consumes.
Gable walls route to `gable_wall_external` when `is_flat=True` (top-
floor flats with RR sit at the end of their building, no neighbour
above) and to `gable_wall` (party at U=0.25) otherwise. Mirrors the
Summary mapper's `_map_elmhurst_rir_surface` heuristic.
"""
surfaces: List[SapRoomInRoofSurface] = []
gable_specs = (
(details.gable_wall_length_1, details.gable_wall_height_1),
(details.gable_wall_length_2, details.gable_wall_height_2),
)
gable_kind = "gable_wall_external" if is_flat else "gable_wall"
for length, height in gable_specs:
if length is not None and height is not None and length > 0 and height > 0:
area = _round_half_up_2dp(float(length), float(height))
surfaces.append(SapRoomInRoofSurface(kind=gable_kind, area_m2=area))
if (
details.flat_ceiling_length_1 is not None
and details.flat_ceiling_height_1 is not None
and details.flat_ceiling_length_1 > 0
and details.flat_ceiling_height_1 > 0
):
area = _round_half_up_2dp(
float(details.flat_ceiling_length_1),
float(details.flat_ceiling_height_1),
)
thickness = _parse_rir_insulation_thickness_mm(
details.flat_ceiling_insulation_thickness_1
)
surfaces.append(
SapRoomInRoofSurface(
kind="flat_ceiling",
area_m2=area,
insulation_thickness_mm=thickness,
)
)
return surfaces or None
def _parse_rir_insulation_thickness_mm(value: Any) -> Optional[int]:
"""Parse the API's `flat_ceiling_insulation_thickness_*` string
(e.g. "300mm") to an integer mm. Returns None when missing or
unparseable so the cascade defers to the spec default."""
if value is None:
return None
s = str(value).strip()
m = re.match(r"^(\d+)\s*mm$", s)
return int(m.group(1)) if m else None
def _api_resolve_sloping_ceiling_thickness(
roof_construction: Optional[int],
roof_insulation_thickness: Union[str, int, None],

View file

@ -185,12 +185,29 @@ class RoomInRoofType1:
gable_wall_length_2: Optional[float] = None
@dataclass
class RoomInRoofDetails:
"""RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling
detail. See `rdsap_schema_21_0_1.RoomInRoofDetails`."""
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
gable_wall_height_1: Optional[float] = None
gable_wall_height_2: Optional[float] = None
flat_ceiling_length_1: Optional[float] = None
flat_ceiling_height_1: Optional[float] = None
flat_ceiling_insulation_type_1: Optional[int] = None
flat_ceiling_insulation_thickness_1: Optional[str] = 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
room_in_roof_details: Optional[RoomInRoofDetails] = None
@dataclass

View file

@ -194,11 +194,35 @@ class RoomInRoofType1:
gable_wall_length_2: Optional[float] = None
@dataclass
class RoomInRoofDetails:
"""RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling
detail. Newer cert vintages lodge full per-surface measured detail under
`room_in_roof_details` instead of the Simplified Type 1 wrapper. Used
by `EpcPropertyDataMapper.from_api_response` to populate
`SapRoomInRoof.detailed_surfaces` with `gable_wall_external` /
`flat_ceiling` entries the cascade's Detailed-RR branch consumes."""
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
gable_wall_height_1: Optional[float] = None
gable_wall_height_2: Optional[float] = None
flat_ceiling_length_1: Optional[float] = None
flat_ceiling_height_1: Optional[float] = None
flat_ceiling_insulation_type_1: Optional[int] = None
flat_ceiling_insulation_thickness_1: Optional[str] = None
@dataclass
class SapRoomInRoof:
floor_area: Union[int, float]
construction_age_band: str
# Two real-API shapes coexist: older certs (cohort 6035, 0240, test
# fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; newer
# certs (9501) lodge the Detailed-RR block. Accept both.
room_in_roof_type_1: Optional[RoomInRoofType1] = None
room_in_roof_details: Optional[RoomInRoofDetails] = None
@dataclass