fix(mapper): map dropped §3.9.2 Simplified Type-2 room-in-roof (API)

The gov API lodges a §3.9.2 Simplified Type-2 RR (a room-in-roof bounded by
continuous common walls) under `room_in_roof_type_2` — gable + common-wall
lengths AND heights. The block was undeclared → `from_dict` dropped it →
neither the Type-1 nor Detailed path fired → the cascade's Simplified branch
billed the WHOLE A_RR shell (12.5√(floor/1.5)) at the Table-18-col-4 default
with no gable/common-wall deduction (over-count → under-rate; 7 corpus certs
at signed −5.02).

Fix: declare `RoomInRoofType2` on rdsap_schema_21_0_0/_21_0_1 + SapRoomInRoof,
and build `detailed_surfaces` by MIRRORING the worksheet-validated Summary
path (`_map_elmhurst_rir_surface`, is_simplified) rather than back-solving:
  common wall → L × (0.25 + H)                    (billed at the main-wall U)
  gable       → L × (0.25 + H) − Σ (H − H_cw)²/2  (RdSAP 10 §3.9.2 + Table 4)
The gable correction sums all common walls (exposed/party/sheltered, incl.
the H=0 absent-gable negative-area case that deducts from the A_RR residual);
a Connected gable sums only the common walls it overtops. The
`gable_wall_type_*` code routes the kind (0/1/2/3 = Party/Exposed/Sheltered/
Connected). A raw-L×H prototype scattered; the §3.9.2 quadratic is the
missing piece.

Validation is cross-mapper parity, NOT a corpus back-solve: `_api_type_2_
surfaces` produces surfaces IDENTICAL to the Summary path on cohort cert
000565 (connected_wall 3.68, gable_wall_external 16.08/27.68, common walls,
and the −0.17 absent-gable quadratic), and 000565 is pinned to 1e-4 in the
harness — so the API RR fabric is now correct by construction. The remaining
type-2 cohort SAP scatter is unrelated per-cert causes (stone walls,
secondary fuel), not the RR.

Gauges: corpus within-0.5 67.6% → 67.9% (MAE 0.979 → 0.959); /tmp 71.7% →
71.8% (MAE 0.838 → 0.822). Harness 47/47 (000565 unchanged); regression =
the 3 pre-existing fails; pyright net-zero (65=65).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 15:01:11 +00:00
parent 419e340477
commit 6385a0be85
4 changed files with 164 additions and 3 deletions

View file

@ -3931,12 +3931,88 @@ def _api_build_room_in_roof(
# §3.9.1 default RR storey height (2.45 m); the type code routes
# the U-value (Exposed → main-wall U, Party → 0.25).
rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1)
type_2 = getattr(bp_rir, "room_in_roof_type_2", None)
if type_2 is not None:
rir.detailed_surfaces = _api_type_2_surfaces(type_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_type_2_surfaces(
type_2: Any,
) -> Optional[List[SapRoomInRoofSurface]]:
"""Translate the §3.9.2 Simplified Type 2 block into the per-surface
list the cascade's Detailed-RR branch consumes — MIRRORING the
worksheet-validated Summary path (`_map_elmhurst_rir_surface`,
is_simplified, validated to 1e-4 by cohort cert 000565). Unlike the
Type 1 block (gable lengths only, billed raw L × 2.45), Type 2 lodges
gable + common-wall lengths AND heights, so the spec's §3.9.2 areas
apply:
common wall `L × (0.25 + H)` (billed at uw)
gable `L × (0.25 + H_gable)
Σ_each_common (H_gable H_common,n)² / 2`
The gable correction is taken over ALL common walls for an exposed/
party/sheltered gable (the worksheet evaluates it literally, incl. the
H_gable=0 absent-gable case a negative area that deducts from the
A_RR residual without billing a physical wall); a Connected gable
(Table 4 row 4, U=0) sums only the common walls it overtops, matching
the Summary's connected-gable branch. The `gable_wall_type_*` code
routes the kind (0 Party / 1 Exposed / 2 Sheltered / 3 Connected) via
`_api_type_1_gable_kind`; U-values are left to the cascade (no per-
gable U is lodged on the API path)."""
cw_heights = [
float(h)
for length, h in (
(type_2.common_wall_length_1, type_2.common_wall_height_1),
(type_2.common_wall_length_2, type_2.common_wall_height_2),
)
if length is not None and h is not None and length > 0 and h > 0
]
surfaces: List[SapRoomInRoofSurface] = []
gable_specs = (
(type_2.gable_wall_type_1, type_2.gable_wall_length_1,
type_2.gable_wall_height_1),
(type_2.gable_wall_type_2, type_2.gable_wall_length_2,
type_2.gable_wall_height_2),
)
for gable_type, length, height in gable_specs:
# Length is mandatory; H may be 0 for the §3.9.2 absent-gable
# quadratic (only when common walls drive the correction).
if length is None or length <= 0 or height is None:
continue
if height <= 0 and not cw_heights:
continue
kind = _api_type_1_gable_kind(gable_type)
length_m, height_m = float(length), float(height)
if cw_heights:
if kind == "connected_wall":
correction = sum(
((height_m - h) ** 2) / 2.0 for h in cw_heights if height_m > h
)
else:
correction = sum(((height_m - h) ** 2) / 2.0 for h in cw_heights)
area = _round_half_up_2dp(1.0, length_m * (0.25 + height_m) - correction)
else:
area = _round_half_up_2dp(length_m, height_m)
surfaces.append(SapRoomInRoofSurface(kind=kind, area_m2=area))
common_specs = (
(type_2.common_wall_length_1, type_2.common_wall_height_1),
(type_2.common_wall_length_2, type_2.common_wall_height_2),
)
for length, height in common_specs:
if length is None or height is None or length <= 0 or height <= 0:
continue
surfaces.append(
SapRoomInRoofSurface(
kind="common_wall",
area_m2=_round_half_up_2dp(float(length), 0.25 + float(height)),
)
)
return surfaces or None
def _api_rir_detailed_surfaces(
details: Any,
*,

View file

@ -2228,3 +2228,47 @@ class TestRoomInRoofDetailedSlopeAndStudWall:
assert studs[0].insulation_thickness_mm == 75
assert abs(commons[0].area_m2 - 10.32) <= 1e-9
assert commons[0].insulation_thickness_mm is None
class TestRoomInRoofType2SimplifiedQuadratic:
"""RdSAP 10 §3.9.2 Simplified Type 2 RR — the gov API lodges gable +
common-wall lengths AND heights under `room_in_roof_type_2`. The block
was undeclared dropped the cascade billed the whole A_RR shell at
the Table-18-col-4 default (over-count under-rate, 7 corpus certs at
signed 5.02). The mapper now MIRRORS the worksheet-validated Summary
§3.9.2 areas (cross-mapper parity, proven identical on cohort cert
000565): common walls L×(0.25+H), gables L×(0.25+H) Σ(HH_cw)²/2."""
def test_from_api_response_applies_3_9_2_gable_quadratic(self) -> None:
# Arrange — two common walls (L=8, H=1 → cw_heights [1,1]); an
# exposed gable (L=10, H=2) and a party gable (L=6, H=2).
# common wall = round(8 × (0.25+1)) = 10.00
# exposed gable= round(10 × (0.25+2) 2×(21)²/2) = round(22.51) = 21.50
# party gable = round(6 × (0.25+2) 1.0) = round(13.51) = 12.50
cert = load("21_0_1.json")
rir = cert["sap_building_parts"][0]["sap_room_in_roof"]
rir.pop("room_in_roof_type_1", None)
rir["room_in_roof_type_2"] = {
"gable_wall_type_1": 1, "gable_wall_length_1": 10.0,
"gable_wall_height_1": 2.0,
"gable_wall_type_2": 0, "gable_wall_length_2": 6.0,
"gable_wall_height_2": 2.0,
"common_wall_length_1": 8.0, "common_wall_height_1": 1.0,
"common_wall_length_2": 8.0, "common_wall_height_2": 1.0,
}
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert
rir_part = result.sap_building_parts[0].sap_room_in_roof
assert rir_part is not None
surfaces = rir_part.detailed_surfaces
assert surfaces is not None
ext = [s for s in surfaces if s.kind == "gable_wall_external"]
party = [s for s in surfaces if s.kind == "gable_wall"]
commons = [s for s in surfaces if s.kind == "common_wall"]
assert len(ext) == 1 and abs(ext[0].area_m2 - 21.50) <= 1e-9
assert len(party) == 1 and abs(party[0].area_m2 - 12.50) <= 1e-9
assert len(commons) == 2
assert abs(commons[0].area_m2 - 10.00) <= 1e-9

View file

@ -236,12 +236,29 @@ class RoomInRoofDetails:
common_wall_height_2: Optional[float] = None
@dataclass
class RoomInRoofType2:
"""RdSAP §3.9.2 Simplified Type 2 RR — gable + common-wall geometry.
See `rdsap_schema_21_0_1.RoomInRoofType2`. Previously dropped."""
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
common_wall_length_1: Optional[float] = None
common_wall_length_2: Optional[float] = None
common_wall_height_1: Optional[float] = None
common_wall_height_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
room_in_roof_type_2: Optional[RoomInRoofType2] = None
room_in_roof_details: Optional[RoomInRoofDetails] = None

View file

@ -278,14 +278,38 @@ class RoomInRoofDetails:
common_wall_height_2: Optional[float] = None
@dataclass
class RoomInRoofType2:
"""RdSAP §3.9.2 Simplified Type 2 RR — a room-in-roof bounded by
continuous common walls (accessible common-wall height < 1.8 m, so the
space counts as RR not a separate storey). Lodges gable + common-wall
lengths AND heights (unlike Type 1, gable lengths only). `gable_wall_
type_*` is the Table 4 variant (0 Party / 1 Exposed / 2 Sheltered /
3 Connected). Previously undeclared dropped by `from_dict`, so the
cascade billed the whole A_RR shell at the Table-18-col-4 default
(over-count under-rate)."""
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
common_wall_length_1: Optional[float] = None
common_wall_length_2: Optional[float] = None
common_wall_height_1: Optional[float] = None
common_wall_height_2: Optional[float] = 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.
# Three real-API shapes coexist: older certs (cohort 6035, 0240, test
# fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; some lodge
# the §3.9.2 Simplified Type 2 wrapper (gable + common-wall geometry);
# newer certs (9501) lodge the Detailed-RR block. Accept all three.
room_in_roof_type_1: Optional[RoomInRoofType1] = None
room_in_roof_type_2: Optional[RoomInRoofType2] = None
room_in_roof_details: Optional[RoomInRoofDetails] = None