S0380.196: API Simplified Type 1 RR gables deduct from A_RR shell (RdSAP 10 §3.9.1(e) p.21)

Golden cert 6035's residual (SAP -2 / PE +19.16 / CO2 +0.42t) was a real
API-mapper bug, NOT lodged divergence (prior claim retracted).

The API `room_in_roof_type_1` block lodges gable walls by length only (no
height). The mapper carried just the scalar `gable_*_length_m`, and the
cascade's `_part_geometry` gable formula silently drops height-less gables
(needs a height) -> the whole A_RR shell `12.5√(A_RR_floor/1.5)` billed as
roof at U_RR=2.30 instead of the §3.9.1(e) residual
`A_RR − Σ gables`. On 6035 that over-counted roof by 22.78 m² × 2.30 =
+52.4 W/K (roof 130.73 -> 78.33, matching the site-notes case-4 replica at
1e-4 — cross-mapper parity).

RdSAP 10 §3.9.1(e) (PDF p.21): "the area of the room-in-roof gable walls
... is deducted from A_RR to give the residual roof area." Fix: route the
Type 1 gables through `detailed_surfaces` (gable area = L × the §3.9.1
default RR storey height 2.45 m; gable_wall_type 0=Party->gable_wall U=0.25,
1=Exposed->gable_wall_external "as common wall" per Table 4 p.22) so the
cascade's Detailed-RR residual fires — the same path the site-notes mapper
already uses.

Re-pinned golden residuals:
- 6035: SAP -2 -> +0 (exact), PE +19.16 -> +1.84, CO2 +0.42 -> +0.01
- 0240: same fix applies (2 Party gables L=6.4); PE +5.80 -> +3.91,
  CO2 +0.32 -> +0.22, SAP integer unchanged

Also corrected the stale "gable_wall_type 0 = external" schema comment
(6035's Summary proves 0=Party, 1=Exposed) and added a strict
UnmappedApiCode raise for unknown gable_wall_type codes.

Suite: 2342 passed, 1 skipped. New code: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 10:37:26 +00:00
parent af477678c2
commit 8861dac694
4 changed files with 155 additions and 23 deletions

View file

@ -2710,20 +2710,84 @@ def _api_build_sap_floor_dimensions(
return out
# RdSAP 10 §3.9.1 (PDF p.21): a Simplified Type 1 RR gable has no measured
# height — the worksheet applies the default RR storey height of 2.45 m, so
# the gable area is L × 2.45 (cert 6035 Summary lodges H=2.45 explicitly,
# matching this default; gable area 4.65 × 2.45 = 11.39 m²).
_RIR_TYPE_1_GABLE_HEIGHT_M: Final[float] = 2.45
# GOV.UK API `room_in_roof_type_1.gable_wall_type_*` integer → the
# `SapRoomInRoofSurface.kind` the cascade's Detailed-RR branch routes by
# U-value. Established from cert 6035's Summary (gable_wall_type_1=1 ↔
# "Exposed" U=0.29; gable_wall_type_2=0 ↔ "Party" U=0.25):
# 0 = Party → `gable_wall` (Table 4 p.22 row 2, U=0.25)
# 1 = Exposed → `gable_wall_external` (Table 4 p.22 row 1, "as common wall")
_API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = {
0: "gable_wall",
1: "gable_wall_external",
}
def _api_type_1_gable_kind(gable_type: Optional[int]) -> str:
"""Map a `gable_wall_type_*` code to the cascade's RR surface kind.
`None` (type unlodged) defaults to `gable_wall` (party) the modal
RR gable and the conservative choice (party billing at U=0.25 vs the
main-wall U). A lodged integer outside the known set raises
`UnmappedApiCode` so a new gable variant forces an explicit mapping
rather than silently mis-routing its U-value (mirror of the strict-
raise pattern on the other API helpers)."""
if gable_type is None:
return "gable_wall"
if gable_type not in _API_TYPE_1_GABLE_TYPE_TO_KIND:
raise UnmappedApiCode("gable_wall_type", gable_type)
return _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type]
def _api_type_1_gable_surfaces(
type_1: Any,
) -> Optional[List[SapRoomInRoofSurface]]:
"""Translate the Simplified Type 1 scalar gable fields into the
per-surface list the cascade's Detailed-RR branch consumes.
Gable area = L × the §3.9.1 default RR storey height (2.45 m); the
`gable_wall_type_*` code routes the kind (Exposed vs Party). U-values
are left to the cascade (Exposed falls through to the main-wall U;
Party uses the fixed 0.25). Returns None when no gable length is
lodged so the cascade keeps its existing residual-only behaviour."""
surfaces: List[SapRoomInRoofSurface] = []
for gable_type, length in (
(type_1.gable_wall_type_1, type_1.gable_wall_length_1),
(type_1.gable_wall_type_2, type_1.gable_wall_length_2),
):
if length is None or length <= 0:
continue
surfaces.append(
SapRoomInRoofSurface(
kind=_api_type_1_gable_kind(gable_type),
area_m2=_round_half_up_2dp(
float(length), _RIR_TYPE_1_GABLE_HEIGHT_M
),
)
)
return surfaces or None
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.
Simplified Type 1 gable lengths only (no heights). Built into
`detailed_surfaces` using the 2.45 m default RR storey height so
the cascade's residual deducts the gables from the A_RR shell.
- `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.
For BOTH shapes we build `detailed_surfaces` so the cascade's
per-surface RR branch picks up exact gable + flat-ceiling areas and
the §3.9.1(e) residual roof (A_RR shell Σ gables), instead of
billing the whole shell at the Table 18 col(4) "all elements" U.
"""
if bp_rir is None:
return None
@ -2733,10 +2797,20 @@ def _api_build_room_in_roof(
)
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).
# RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights).
rir.gable_1_length_m = type_1.gable_wall_length_1
rir.gable_2_length_m = type_1.gable_wall_length_2
# Route the gables through `detailed_surfaces` so the cascade's
# Detailed-RR residual deducts each gable from the A_RR shell
# (RdSAP 10 §3.9.1(e) p.21: A_RR_final = 12.5√(A_RR_floor/1.5)
# Σ gables) — the same path the site-notes mapper builds. The
# scalar `gable_*_length_m` fields alone can't trigger this: the
# cascade's `_part_geometry` gable formula needs a height and
# silently drops height-less gables, billing the WHOLE shell as
# roof (a ~52 W/K over-count on cert 6035). Gable area = L × the
# §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)
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)

View file

@ -175,10 +175,11 @@ class SapFloorDimension:
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_*` is the Table 4 gable variant (0 = Party,
1 = Exposed established from cert 6035's Summary; other variants
not yet seen). `gable_wall_length_*` is the run of the gable in
metres. Heights are NOT lodged here the mapper applies the §3.9.1
default RR 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

View file

@ -207,10 +207,11 @@ class SapFloorDimension:
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_*` is the Table 4 gable variant (0 = Party,
1 = Exposed established from cert 6035's Summary; other variants
not yet seen). `gable_wall_length_*` is the run of the gable in
metres. Heights are NOT lodged here the mapper applies the §3.9.1
default RR 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

View file

@ -41,6 +41,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
cert_to_demand_inputs,
cert_to_inputs,
heat_transmission_section_from_cert,
)
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden"
@ -82,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-1,
expected_pe_resid_kwh_per_m2=+5.8007,
expected_co2_resid_tonnes_per_yr=+0.3173,
expected_pe_resid_kwh_per_m2=+3.9138,
expected_co2_resid_tonnes_per_yr=+0.2213,
notes=(
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
@ -120,7 +121,13 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"extract-fans default (age J, 4 hab rooms → 2 fans). "
"Cascade ventilation HLC rises ~0.07 ACH × volume → SH "
"demand rises proportionally; PE +2.5225 → +5.8007, CO2 "
"+0.1395 → +0.3173. SAP integer unchanged at 72."
"+0.1395 → +0.3173. SAP integer unchanged at 72. "
"Slice S0380.196 (the 6035 RR fix) also applies here: this "
"cert's `room_in_roof_type_1` lodges two gables (L=6.4, both "
"Party) with no height, previously dropped → roof over-count. "
"Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) "
"from the A_RR shell → roof drops, tightening PE +5.8007 → "
"+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72."
),
),
_GoldenExpectation(
@ -193,9 +200,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=-2,
expected_pe_resid_kwh_per_m2=+19.1566,
expected_co2_resid_tonnes_per_yr=+0.4211,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=+1.8379,
expected_co2_resid_tonnes_per_yr=+0.0103,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"S0380.189 fixed the dominant driver: walls are solid brick "
@ -223,7 +230,24 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"WHC=901 + main code 104). Eq D1 monthly blend (mean ~80%) "
"produces ~150 kWh/yr more HW fuel than the pre-slice flat-"
"winter calc → PE residual +46.0952 → +47.2928, CO2 +1.0495 "
"→ +1.0779."
"→ +1.0779. "
"Slice S0380.196 CLOSED the residual (the prior 'lodged "
"divergence' claim is RETRACTED — it was a real API-mapper "
"bug). The API `room_in_roof_type_1` block lodges two gable "
"walls (L=4.65 each) but no heights; the mapper carried only "
"the scalar lengths, and the cascade's `_part_geometry` gable "
"formula silently drops height-less gables → the whole "
"55.67 m² A_RR shell billed as roof at U_RR=2.30 instead of "
"the §3.9.1(e) residual `12.5√(29.75/1.5) 2×11.39 = 32.89`. "
"That over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof "
"130.73 → 78.33, matching the site-notes case-4 replica at "
"1e-4). Fix: route the Type 1 gables through `detailed_"
"surfaces` (gable area = L × the §3.9.1 default RR storey "
"height 2.45 m; Exposed → gable_wall_external, Party → "
"gable_wall) so the cascade's Detailed-RR residual fires. "
"SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 "
"+0.42 → +0.01. Remaining +1.84 PE is unrelated gains/HW "
"(no worksheet for 6035 itself to pin further)."
),
),
_GoldenExpectation(
@ -731,3 +755,35 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number(
assert main.main_heating_index_number == expected_pcdb_id
if expected_winter_eff is not None:
assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3
def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None:
"""Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_
type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e)
(PDF p.21) the gable areas deduct from the A_RR shell the residual
roof area is `12.5(A_RR_floor/1.5) Σ gables`, NOT the full shell.
The API mapper must route these scalar gables through
`detailed_surfaces` (gable area = L × the §3.9.1 default RR storey
height 2.45 m) so the cascade's Detailed-RR residual fires, exactly
as the site-notes path does. Before the fix the gables (no lodged
height) were silently dropped the whole 55.67 shell billed at
U_RR=2.30, a +52 W/K roof over-count and the entire 6035 residual.
Cross-mapper parity: this is the value the site-notes case-4 replica
(`worksheet/_elmhurst_worksheet_001431_6035.py`) pins to its
worksheet at 1e-4:
loft (41.7329.75=11.98) × U_roof(300 mm) = 1.6772
ext (7.21) × U_roof(300 mm) = 1.0094
RR residual (55.67 2×11.39 = 32.89) × U_RR(age A=2.30) = 75.647
78.3336 W/K
"""
# Arrange
doc = _load_cert("6035-7729-2309-0879-2296")
epc = EpcPropertyDataMapper.from_api_response(doc)
# Act
roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k
# Assert
assert abs(roof_w_per_k - 78.3336) <= 1e-4