Cohort residual slice 12: Simplified Type 2 RR geometry (common walls <1.8m)

Extends `SapRoomInRoof` with six optional fields capturing the RdSAP10
§3.9.2 Simplified Type 2 lodgement: common_wall_length_m / height_m
plus two gable length/height pairs.

Type 2 fires when `common_wall_height_m` is set and < 1.8 m (otherwise
the space is a separate storey). Geometry per spec page 23:
  A_common_wall = L × (0.25 + H)
  A_gable       = L × (0.25 + H_gable)
                  − Σ ((H_gable − H_common_wall_i)² / 2)
  A_RR_final    = A_RR − Σ A_common_wall − Σ A_gable
                  (− party / sheltered / connected when lodged, future
                  slice when a fixture exercises them)

Common walls and gables route to walls_w_per_k at U_main_wall (per spec:
"Common wall U-value is inferred from the U-value of the main wall in
the building part below"). A_RR_final routes to roof_w_per_k at
u_rr_default_all_elements (Table 18 col 4).

Synthetic test: 1-storey cavity-uninsulated dwelling at age B + RR
(floor 10 m², common_wall_length 5 m × 1 m height). Pins
walls_w_per_k = 60 × 1.5 + 6.25 × 1.5 = 99.375 W/K and
roof_w_per_k = 30 × 0.40 + 26.025 × 2.30 = 71.857 W/K at abs=0.001.

No production fixture exercises Type 2 yet — synthetic test is the
unit-level guard until a Type 2 cert lands in the corpus.

Reference: RdSAP 10 (10-06-2025) §3.9.2 page 22-23.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 19:32:14 +00:00
parent 4df056859e
commit 3ff864bf86
3 changed files with 129 additions and 8 deletions

View file

@ -267,6 +267,19 @@ class SapFloorDimension:
class SapRoomInRoof:
floor_area: Union[int, float]
construction_age_band: str
# RdSAP10 §3.9.2 Simplified Type 2 — RR built into a roof space that
# has continuous common walls outside the RR boundaries. The space is
# treated as Room-in-Roof when the height of accessible common walls
# is < 1.8 m (otherwise it counts as a separate storey).
common_wall_length_m: Optional[float] = None
common_wall_height_m: Optional[float] = None
# Optional gable lengths/heights for the Type 2 quadratic correction:
# A_gable = L × (0.25 + H) Σ ((H H_common_wall_i)² / 2)
# If absent, the gable contribution is 0 (Simplified Type 1).
gable_1_length_m: Optional[float] = None
gable_1_height_m: Optional[float] = None
gable_2_length_m: Optional[float] = None
gable_2_height_m: Optional[float] = None
# RdSAP10 wall_construction integer encoding. The gov-EPC API doesn't publish

View file

@ -180,10 +180,40 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
# their own U-values, deducted from A_RR_final per step e.
rr_floor_area = 0.0
rr_a_rr = 0.0
rr_common_wall_area = 0.0
rr_gable_area = 0.0
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)
# 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
# RR floor and the storey-below ceiling.
if rir.common_wall_height_m is not None and rir.common_wall_length_m is not None:
rr_common_wall_area = (
rir.common_wall_length_m * (0.25 + rir.common_wall_height_m)
)
# Gable walls of the Type 2 RR. Quadratic correction subtracts
# the triangular slice above each common wall:
# A_gable = L × (0.25 + H_gable) ((H_gable H_common_wall)² / 2)
# summed across up to two common walls bordering the gable.
h_common = rir.common_wall_height_m
for gable_length, gable_height in (
(rir.gable_1_length_m, rir.gable_1_height_m),
(rir.gable_2_length_m, rir.gable_2_height_m),
):
if gable_length is None or gable_height is None:
continue
area = gable_length * (0.25 + gable_height)
if h_common is not None:
# The spec uses two common-wall heights in the
# correction; we apply twice in the absence of separate
# H_common_wall_1 / H_common_wall_2 lodgement (the modal
# case is a symmetric gable).
correction = 2.0 * ((gable_height - h_common) ** 2) / 2.0
area = max(0.0, area - correction)
rr_gable_area += area
return {
"ground_floor_area_m2": ground.total_floor_area_m2 or 0.0,
"top_floor_area_m2": max(0.0, (top.total_floor_area_m2 or 0.0) - rr_floor_area),
@ -191,6 +221,8 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
"party_wall_area_m2": party_wall,
"rr_floor_area_m2": rr_floor_area,
"rr_simplified_a_rr_m2": rr_a_rr,
"rr_common_wall_area_m2": rr_common_wall_area,
"rr_gable_area_m2": rr_gable_area,
}
@ -349,21 +381,28 @@ def heat_transmission_from_cert(
walls += uw * main_wall_area + alt_walls_contribution
roof += ur * roof_area
# RdSAP10 §3.9.1 Simplified RR contribution. A_RR is treated as
# timber-framed roof structure with U from Table 18 column (4)
# "Room-in-roof, all elements" (the as-built / unknown default
# per footnote (1)). The age band lives on the SapRoomInRoof, not
# the SapBuildingPart, so we read it from there. Lines under (30)
# in the U985 worksheet — same line ref as the regular roof, so
# we fold the contribution into roof_w_per_k.
# RdSAP10 §3.9.1/§3.9.2 Simplified RR contribution. A_RR is the
# total RR exposed area (12.5 × √(A_RR_floor / 1.5)); from that,
# Type 2 common walls (and any gables) are deducted to obtain
# A_RR_final, treated as timber-framed roof structure with U from
# Table 18 column (4) "Room-in-roof, all elements". The age band
# lives on the SapRoomInRoof, not the SapBuildingPart, so we read
# it from there. Common walls + gables of the RR contribute at
# U_main_wall per spec page 23 ("Common wall U-value is inferred
# from the U-value of the main wall in the building part below";
# gables fall under the same Table 4 rule).
rr_a_rr = geom["rr_simplified_a_rr_m2"]
rr_common = geom["rr_common_wall_area_m2"]
rr_gable = geom["rr_gable_area_m2"]
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
walls += uw * (rr_common + rr_gable)
a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable)
u_rr = u_rr_default_all_elements(
country=country, age_band=rir.construction_age_band,
)
roof += u_rr * rr_a_rr
roof += u_rr * a_rr_final
floor += uf * floor_area_total
party += upw * party_area
windows += window_u * w_area

View file

@ -1368,3 +1368,72 @@ def test_room_in_roof_simplified_type_1_adds_a_rr_timber_framed_area_to_roof_w_p
a_rr = 12.5 * math.sqrt(15.0 / 1.5)
expected_roof_w_per_k = (40.0 - 15.0) * 0.40 + a_rr * 2.30
assert result.roof_w_per_k == pytest.approx(expected_roof_w_per_k, abs=0.001)
def test_room_in_roof_simplified_type_2_common_walls_route_to_walls_w_per_k() -> None:
"""RdSAP10 §3.9.2 Simplified Type 2 (RR with accessible common walls
under 1.8 m). Per spec page 23:
A_common_wall = L_common_wall × (0.25 + H_common_wall)
A_RR = 12.5 × (A_RR_floor / 1.5) (same as Type 1)
A_RR_final = A_RR ΣA_common_wall (no gables in this test)
Common walls of the RR contribute at U_common = U_main_wall (the
building part's own wall U-value, inferred per spec "Common wall
U-value is inferred from the U-value of the main wall in the
building part below"). A_RR_final is timber-framed roof structure at
U_RR_default (Table 18 col 4).
Synthetic dwelling:
- 1 storey × 40 , 24 m perimeter, 2.5 m height gross_wall = 60.
- Cavity uninsulated age B U_wall = 1.5 (Table 6 cavity as-built).
- RR floor_area=10, common_wall_length_m=5.0, common_wall_height_m=1.0.
- No gables, no windows, no doors.
Expected:
A_common_wall = 5 × (0.25 + 1.0) = 6.25
A_RR = 12.5 × (10/1.5) 32.275
A_RR_final = 32.275 6.25 = 26.025
walls_w_per_k = 60 × 1.5 + 6.25 × 1.5 = 99.375 W/K
roof_w_per_k = (40 10) × 0.40 + 26.025 × 2.30
= 12.0 + 59.857 = 71.857 W/K
"""
# Arrange
import math
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=4, # WALL_CAVITY
wall_insulation_type=4, # "as-built / assumed" → no insulation
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",
common_wall_length_m=5.0, common_wall_height_m=1.0,
),
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=50.0, # 40 + 10
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
a_common = 5.0 * (0.25 + 1.0)
a_rr = 12.5 * math.sqrt(10.0 / 1.5)
a_rr_final = a_rr - a_common
expected_walls = 60.0 * 1.5 + a_common * 1.5
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)