Cohort residual slice 13: Detailed §3.10 RR geometry — per-surface lodgement

Adds `SapRoomInRoofSurface` dataclass (kind + area + insulation thickness
+ insulation type) and an optional `detailed_surfaces` list on
`SapRoomInRoof`. When `detailed_surfaces` is present, the Simplified
A_RR formula is bypassed and the calculator iterates each surface,
applying the appropriate Table 17 / Table 4 U-value:

  slope         → roof_w_per_k   via u_rr_slope        (Table 17 col 1)
  flat_ceiling  → roof_w_per_k   via u_rr_flat_ceiling (Table 17 col 2)
  stud_wall     → roof_w_per_k   via u_rr_stud_wall    (Table 17 col 3)
  gable_wall    → party_walls_w_per_k at U=0.25         (Table 4 "as
                                                        common wall")

This mapping mirrors the U985 worksheet for 000477 where RR stud walls
+ slope + flat-ceiling lines sit under (30) and RR gable walls sit
under (32). The §3.9 deduction of `A_RR_floor` from the storey-below
roof area still applies.

Synthetic test pins a 1-storey + RR dwelling with 4 detailed surfaces
(slope/stud_wall/flat_ceiling/gable_wall) at hand-computed U-values
from Table 17 and Table 4, abs=0.001 tolerance.

Reference: RdSAP 10 (10-06-2025) §3.10 page 24-25; Figure 4; Table 17
page 44; Table 4 page 22.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 19:36:10 +00:00
parent 3ff864bf86
commit 1928e5a2d6
3 changed files with 157 additions and 2 deletions

View file

@ -263,6 +263,22 @@ class SapFloorDimension:
is_exposed_floor: bool = False
@dataclass(frozen=True)
class SapRoomInRoofSurface:
"""One surface lodged via the RdSAP10 §3.10 Detailed measurement path.
Each RR can carry up to two of each surface kind (flat ceiling,
sloping ceiling, stud wall, gable wall) per spec Figure 4. The U-value
is resolved from Table 17 when `insulation_thickness_mm` is set, or
Table 18 col (4) age-band default otherwise.
"""
kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall"
area_m2: float
insulation_thickness_mm: Optional[int] = None
insulation_type: Optional[str] = None # "mineral_wool" / "eps" / "pur" / "pir"
@dataclass
class SapRoomInRoof:
floor_area: Union[int, float]
@ -280,6 +296,11 @@ class SapRoomInRoof:
gable_1_height_m: Optional[float] = None
gable_2_length_m: Optional[float] = None
gable_2_height_m: Optional[float] = None
# RdSAP10 §3.10 Detailed measurement path. When `detailed_surfaces` is
# set, each entry contributes A × U directly and the Simplified A_RR
# formula is bypassed. The storey-below roof area still deducts
# `floor_area` per §3.9.
detailed_surfaces: Optional[List[SapRoomInRoofSurface]] = None
# RdSAP10 wall_construction integer encoding. The gov-EPC API doesn't publish

View file

@ -57,6 +57,9 @@ from domain.ml.rdsap_uvalues import (
u_party_wall,
u_roof,
u_rr_default_all_elements,
u_rr_flat_ceiling,
u_rr_slope,
u_rr_stud_wall,
u_wall,
u_window,
)
@ -185,7 +188,11 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
rir = part.sap_room_in_roof
if rir is not None and rir.floor_area > 0:
rr_floor_area = float(rir.floor_area)
rr_a_rr = 12.5 * sqrt(rr_floor_area / 1.5)
# Simplified A_RR formula only fires when no Detailed (§3.10)
# per-surface lodgement is present. With Detailed lodgement the
# main loop iterates `rir.detailed_surfaces` directly.
if not rir.detailed_surfaces:
rr_a_rr = 12.5 * sqrt(rr_floor_area / 1.5)
# RdSAP10 §3.9.2 Simplified Type 2 — accessible common walls
# under 1.8 m treat the space as RR. Common wall area = L ×
# (0.25 + H). The 0.25 m accounts for the structural gap between
@ -394,6 +401,7 @@ def heat_transmission_from_cert(
rr_a_rr = geom["rr_simplified_a_rr_m2"]
rr_common = geom["rr_common_wall_area_m2"]
rr_gable = geom["rr_gable_area_m2"]
rr_detailed_area = 0.0
if rr_a_rr > 0:
rir = part.sap_room_in_roof
assert rir is not None # rr_a_rr > 0 ⇒ rir present per _part_geometry
@ -403,6 +411,39 @@ def heat_transmission_from_cert(
country=country, age_band=rir.construction_age_band,
)
roof += u_rr * a_rr_final
elif part.sap_room_in_roof is not None and part.sap_room_in_roof.detailed_surfaces:
# RdSAP10 §3.10 Detailed RR — iterate per-surface lodgement.
# Slope / flat_ceiling / stud_wall route to roof (worksheet
# line (30)); gable_wall routes to party_walls at U=0.25
# (worksheet line (32), per Table 4 "as common wall").
rir = part.sap_room_in_roof
for surf in rir.detailed_surfaces:
kind = surf.kind
area = surf.area_m2
rr_detailed_area += area
if kind == "slope":
roof += area * u_rr_slope(
country=country, age_band=rir.construction_age_band,
insulation_thickness_mm=surf.insulation_thickness_mm,
insulation_type=surf.insulation_type,
)
elif kind == "flat_ceiling":
roof += area * u_rr_flat_ceiling(
country=country, age_band=rir.construction_age_band,
insulation_thickness_mm=surf.insulation_thickness_mm,
insulation_type=surf.insulation_type,
)
elif kind == "stud_wall":
roof += area * u_rr_stud_wall(
country=country, age_band=rir.construction_age_band,
insulation_thickness_mm=surf.insulation_thickness_mm,
insulation_type=surf.insulation_type,
)
elif kind == "gable_wall":
# Treated as party-style at U=0.25 per Table 4. Lines
# 196-197 of the U985 worksheet for 000477 confirm
# this — RR gable walls land under (32) with U=0.25.
party += 0.25 * area
floor += uf * floor_area_total
party += upw * party_area
windows += window_u * w_area
@ -414,7 +455,7 @@ def heat_transmission_from_cert(
# the external surfaces per the spec — A_RR contributes to (31)
# alongside walls + roof + floor + openings.
part_external_area = (
main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rr_a_rr
main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rr_a_rr + rr_detailed_area
)
total_external_area += part_external_area
bridging += y * part_external_area

View file

@ -22,6 +22,7 @@ from datatypes.epc.domain.epc_property_data import (
EnergyElement,
SapAlternativeWall,
SapRoomInRoof,
SapRoomInRoofSurface,
)
from domain.ml.tests._fixtures import (
@ -1437,3 +1438,95 @@ def test_room_in_roof_simplified_type_2_common_walls_route_to_walls_w_per_k() ->
expected_roof = (40.0 - 10.0) * 0.40 + a_rr_final * 2.30
assert result.walls_w_per_k == pytest.approx(expected_walls, abs=0.001)
assert result.roof_w_per_k == pytest.approx(expected_roof, abs=0.001)
def test_room_in_roof_detailed_per_surface_lodgement_routes_each_to_correct_line_ref() -> None:
"""RdSAP10 §3.10 Detailed measurement path. When a SapRoomInRoof
lodges `detailed_surfaces`, the Simplified A_RR formula is bypassed
and each surface contributes A × U directly via Tables 17 / 18:
slope roof_w_per_k at u_rr_slope by thickness/type
flat_ceiling roof_w_per_k at u_rr_flat_ceiling
stud_wall roof_w_per_k at u_rr_stud_wall
gable_wall party_walls_w_per_k at U=0.25 (Table 4 "as common wall")
The mapping mirrors the U985 worksheet for 000477 where RR stud walls
+ slope + flat-ceiling all sit under (30) and RR gable walls sit
under (32) at U=0.25.
Synthetic dwelling (no Type 2 common walls):
- 1 storey × 40 , 24 m perimeter, 2.5 m height; cavity uninsulated
age B U_wall = 1.5; gross_wall = 60 .
- RR floor_area=10, detailed surfaces:
slope 10 , 100 mm mineral wool Table 17 col 1a = 0.40
stud_wall 5 , 100 mm mineral wool Table 17 col 3a = 0.36
flat_ceiling 8 , 200 mm mineral wool Table 17 col 2a = 0.29
gable_wall 7 , (treated as party) U = 0.25
- No windows, no doors.
Expected:
roof_w_per_k = (40 10) × 0.40 + 10 × 0.40 + 5 × 0.36 + 8 × 0.29
= 12.0 + 4.00 + 1.80 + 2.32 = 20.12 W/K
party_walls_w_per_k = 7 × 0.25 = 1.75 W/K
walls_w_per_k = 60 × 1.5 = 90 W/K (RR detailed surfaces don't add
here they're all on (30)/(32))
"""
# Arrange
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=40.0, room_height_m=2.5,
heat_loss_perimeter_m=24.0, party_wall_length_m=0.0, floor=0,
),
],
sap_room_in_roof=SapRoomInRoof(
floor_area=10.0, construction_age_band="B",
detailed_surfaces=[
SapRoomInRoofSurface(
kind="slope", area_m2=10.0,
insulation_thickness_mm=100, insulation_type="mineral_wool",
),
SapRoomInRoofSurface(
kind="stud_wall", area_m2=5.0,
insulation_thickness_mm=100, insulation_type="mineral_wool",
),
SapRoomInRoofSurface(
kind="flat_ceiling", area_m2=8.0,
insulation_thickness_mm=200, insulation_type="mineral_wool",
),
SapRoomInRoofSurface(
kind="gable_wall", area_m2=7.0,
),
],
),
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=50.0,
habitable_rooms_count=3,
country_code="ENG",
sap_building_parts=[main],
)
# Act
result = heat_transmission_from_cert(
epc, window_total_area_m2=0.0, window_avg_u_value=None, door_count=0,
)
# Assert
expected_roof = (
(40.0 - 10.0) * 0.40 # storey-below roof (post-§3.9 deduction)
+ 10.0 * 0.40 # slope @ Table 17 (1a) 100mm
+ 5.0 * 0.36 # stud_wall @ Table 17 (3a) 100mm
+ 8.0 * 0.29 # flat_ceiling @ Table 17 (2a) 200mm
)
expected_party = 7.0 * 0.25 # gable_wall @ U_party
expected_walls = 60.0 * 1.5
assert result.roof_w_per_k == pytest.approx(expected_roof, abs=0.001)
assert result.party_walls_w_per_k == pytest.approx(expected_party, abs=0.001)
assert result.walls_w_per_k == pytest.approx(expected_walls, abs=0.001)