S0380.192: drop placeholder roof surfaces from Simplified room-in-roof (Elmhurst)

A Simplified room-in-roof (RdSAP 10 §3.9.1, PDF p.21) does NOT measure
its slope / flat-ceiling / stud-wall surfaces — the Elmhurst Summary
lodges placeholder Length/Height cells (a 40 m flat-ceiling height, a
32 m slope on a 4.65 m-wide gable). The spec instead derives one
timber-framed "remaining area" from the floor area:
    A_RR       = 12.5 × √(A_RR_floor / 1.5)            §3.9.1(d)
    A_RR_final = A_RR − ΣA_RR_gable/other              §3.9.1(e)

The cascade already computes A_RR_final itself (heat_transmission.py:
`12.5 × √(A_RR_floor/1.5) − rr_walls_in_a_rr_area` residual), but only
when `detailed_surfaces` carries no roof-going kind (`has_roof_lodgement`
gate). `_map_elmhurst_rir_surface` emitted the placeholder slope/ceiling
rows as raw L×H for every assessment type, flipping that gate and billing
1024 m² + 160 m² of explicit roof area — a 7.5× fabric-heat-loss
explosion (cert 001431 sim case 2: SAP −14.6 vs worksheet 69, space
heating 114 378 vs ~15 000 kWh).

Fix: for a Simplified assessment, drop the roof-going surfaces in the
mapper so the cascade's residual formula fires. This matches how the API
path already (correctly) handles the same Simplified RR — scalar gable
fields, no roof-going detailed_surfaces (golden cert 6035) — and the
gables-only cert 000565. Detailed (§3.10) assessments still measure these
surfaces and keep them.

With the fix, sim case 2 total external area = 232.94 (worksheet exact),
roof 78.33 (was 2725.89), SAP 69.29 → worksheet integer 69. A small
residual (~450 kWh main fuel) remains — a separate fabric gap to walk
next. 2308 passed (+2), 0 failed; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 08:57:16 +00:00
parent ec9ef0e8bb
commit 9cb98344fa
2 changed files with 92 additions and 1 deletions

View file

@ -3416,6 +3416,25 @@ def _map_elmhurst_rir_surface(
if prefix is None:
return None
kind = _RIR_KIND_FROM_NAME_PREFIX[prefix]
# RdSAP 10 §3.9.1 (PDF p.21) Simplified assessment: the roof-going
# surfaces (slope / flat ceiling / stud wall) are NOT measured — the
# Summary lodges placeholder Length/Height cells (e.g. a 40 m ceiling
# height, a 32 m slope on a 4.65 m-wide gable). The spec instead
# derives one timber-framed "remaining area" from the floor area:
# A_RR = 12.5 × √(A_RR_floor / 1.5) §3.9.1(d)
# A_RR_final = A_RR ΣA_RR_gable/other §3.9.1(e)
# The cascade computes A_RR_final itself (heat_transmission.py — the
# `12.5 × √(A_RR_floor / 1.5) rr_walls_in_a_rr_area` residual),
# but ONLY when `detailed_surfaces` carries no roof-going kind
# (`has_roof_lodgement` gate). Emitting these placeholder rows flips
# that gate and bills their raw L×H as explicit roof area (a 7.5×
# heat-loss explosion). Drop them for Simplified so the cascade's
# residual formula fires — matching how the API path already handles
# the same Simplified RR (scalar gable fields, no roof-going
# detailed_surfaces; cert 6035) and the gables-only cert 000565.
# Detailed (§3.10) assessments DO measure these surfaces — keep them.
if is_simplified and kind in ("slope", "flat_ceiling", "stud_wall"):
return None
u_value_override: Optional[float] = None
if kind == "gable_wall" and surface.gable_type == "Sheltered":
kind = "gable_wall_external"

View file

@ -14,10 +14,18 @@ area fraction); SAP 10.3 specification (13-01-2026) Tables 4a/4e/12.
from __future__ import annotations
from typing import Final
from typing import Final, Optional
import pytest
from datatypes.epc.domain.mapper import (
_map_elmhurst_room_in_roof, # pyright: ignore[reportPrivateUsage]
)
from datatypes.epc.surveys.elmhurst_site_notes import (
RoomInRoof as ElmhurstRoomInRoof,
RoomInRoofSurface as ElmhurstRoomInRoofSurface,
)
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
@ -2120,6 +2128,70 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N
assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71
def _placeholder_rir_surfaces() -> "list[ElmhurstRoomInRoofSurface]":
# A §3.9.1 Simplified Room-in-Roof lodges the roof-going Length/Height
# cells as placeholders (a 40 m flat-ceiling height, a 32 m slope on a
# 4.65 m gable) — Elmhurst ignores them and derives the area from the
# floor area. Gables ARE measured (4.65 × 2.45 = 11.39).
def surf(
name: str, length: float, height: float,
gable_type: Optional[str] = None, default_u: Optional[float] = None,
) -> "ElmhurstRoomInRoofSurface":
return ElmhurstRoomInRoofSurface(
name=name, length_m=length, height_m=height, insulation="",
insulation_type=None, gable_type=gable_type,
default_u_value=default_u, u_value_known=False, u_value=0.0,
)
return [
surf("Flat Ceiling 1", 4.00, 40.00), # placeholder
surf("Slope 1", 32.00, 32.00), # placeholder
surf("Gable Wall 1", 4.65, 2.45, gable_type="Exposed", default_u=0.29),
surf("Gable Wall 2", 4.65, 2.45, gable_type="Party", default_u=0.25),
]
def test_elmhurst_simplified_rir_drops_placeholder_roof_surfaces() -> None:
# Arrange — RdSAP 10 §3.9.1 (PDF p.21): a Simplified RR's slope /
# flat ceiling / stud wall are not measured; emitting their
# placeholder L×H as `detailed_surfaces` makes the cascade bill them
# as explicit roof area (7.5× heat-loss explosion) instead of firing
# the spec's `A_RR = 12.5√(A_floor/1.5) Σwalls` residual formula.
rir = ElmhurstRoomInRoof(
floor_area_m2=29.75, construction_age_band="A",
assessment="Simplified Type 1", surfaces=_placeholder_rir_surfaces(),
)
# Act
mapped = _map_elmhurst_room_in_roof(rir)
# Assert — roof-going surfaces dropped, both gables retained.
assert mapped is not None
kinds = sorted(s.kind for s in (mapped.detailed_surfaces or []))
assert "slope" not in kinds
assert "flat_ceiling" not in kinds
assert kinds == ["gable_wall", "gable_wall_external"]
def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None:
# Arrange — a Detailed (§3.10) assessment DOES measure slope / flat
# ceiling, so they must be retained (regression guard so the
# Simplified drop doesn't bleed into Detailed lodgements).
rir = ElmhurstRoomInRoof(
floor_area_m2=29.75, construction_age_band="A",
assessment="Detailed", surfaces=_placeholder_rir_surfaces(),
)
# Act
mapped = _map_elmhurst_room_in_roof(rir)
# Assert — slope + flat ceiling retained under the Detailed path.
assert mapped is not None
kinds = sorted(s.kind for s in (mapped.detailed_surfaces or []))
assert "slope" in kinds
assert "flat_ceiling" in kinds
def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None:
# Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas
# boilers (including mains gas, LPG and biogas)". The code identifies