Cohort residual slice 11: Simplified Type 1 RR geometry — _part_geometry + heat_transmission

Implements RdSAP10 §3.9.1 Simplified Type 1 (True Room-in-Roof, no
common walls):

  A_RR = 12.5 × √(A_RR_floor / 1.5)

When the cert lodges only a `SapRoomInRoof(floor_area, construction_
age_band)` (no gable / party / sheltered / connected wall lengths),
ΣA_RR_gable/other = 0 → A_RR_final = A_RR, treated as timber-framed
roof structure with U from Table 18 col (4) "Room-in-roof, all elements".
The storey-below roof area (§3.8) is deducted by A_RR_floor per §3.9.

Changes:
  - `_part_geometry`: returns new keys `rr_floor_area_m2` and
    `rr_simplified_a_rr_m2`; existing `top_floor_area_m2` now subtracts
    `rr_floor_area_m2` (the §3.9 deduction).
  - Main loop: `roof += U_RR × A_RR` where U_RR is from
    `u_rr_default_all_elements(country, rir.construction_age_band)`.
    A_RR also joins the (31) external-area total for thermal-bridging.

Test: synthetic 2-storey + RR (15 m² floor) at age B → roof_w_per_k
math closes at abs=0.001 vs hand-computed 100.92 W/K.

Cohort impact (post-slice-11 vs post-slice-8):
  - 000474, 000490 unchanged at Δ=0 ✓
  - 000480: Δ=+12 → +4   (RR Simplified resolved most of the gap)
  - 000487: Δ=+11 → +3   (same)
  - 000516: Δ=+12 → +4   (same)
  - 000477: Δ=+2  → −6   (overshoot — the U985 PDF uses detailed §3.10
    per-surface RR lodgement; Simplified Type 1 at U=2.30 is too high
    for an RR with measured retrofit insulation. Closes once Detailed
    lands + 000477 fixture upgrades to detailed lodgement, slice 14.)

Reference: RdSAP 10 (10-06-2025) §3.9.1 page 21-22; Table 18 page 45.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 19:24:48 +00:00
parent 0ff814451f
commit 4df056859e
2 changed files with 103 additions and 3 deletions

View file

@ -56,9 +56,11 @@ from domain.ml.rdsap_uvalues import (
u_floor,
u_party_wall,
u_roof,
u_rr_default_all_elements,
u_wall,
u_window,
)
from math import sqrt
_WALL_INSULATION_NONE: Final[int] = 4
@ -147,6 +149,8 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
"top_floor_area_m2": 0.0,
"gross_wall_area_m2": 0.0,
"party_wall_area_m2": 0.0,
"rr_floor_area_m2": 0.0,
"rr_simplified_a_rr_m2": 0.0,
}
fds = list(part.sap_floor_dimensions)
ground = next((fd for fd in fds if fd.floor == 0), fds[0])
@ -165,11 +169,28 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
(fd.party_wall_length_m or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M)
for fd in fds
)
# RdSAP10 §3.9.1 Simplified Type 1 (True Room-in-Roof): when an RR is
# lodged with only its floor area (no gable/party/sheltered/connected
# wall lengths), the spec's empirical formula treats it as one chunk
# of timber-framed roof structure of area A_RR = 12.5 × √(A_RR_floor
# / 1.5). The storey-below roof area (§3.8) is deducted by A_RR_floor
# — the regular roof "becomes" the residual footprint not under the
# RR. Type 2 (common walls < 1.8m) and the Detailed §3.10 path are
# additive on top — they decompose A_RR into per-surface areas with
# their own U-values, deducted from A_RR_final per step e.
rr_floor_area = 0.0
rr_a_rr = 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)
return {
"ground_floor_area_m2": ground.total_floor_area_m2 or 0.0,
"top_floor_area_m2": top.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),
"gross_wall_area_m2": gross_wall,
"party_wall_area_m2": party_wall,
"rr_floor_area_m2": rr_floor_area,
"rr_simplified_a_rr_m2": rr_a_rr,
}
@ -328,6 +349,21 @@ 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.
rr_a_rr = geom["rr_simplified_a_rr_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
u_rr = u_rr_default_all_elements(
country=country, age_band=rir.construction_age_band,
)
roof += u_rr * rr_a_rr
floor += uf * floor_area_total
party += upw * party_area
windows += window_u * w_area
@ -335,9 +371,11 @@ def heat_transmission_from_cert(
# (31) — total external element area used by both the worksheet
# readout and the (36) thermal-bridging multiplier. Excludes the
# party wall (party walls have their own line (32)) per RdSAP
# §5.15: bridging applies to *exposed* area only.
# §5.15: bridging applies to *exposed* area only. RR area joins
# 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
main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rr_a_rr
)
total_external_area += part_external_area
bridging += y * part_external_area

View file

@ -21,6 +21,7 @@ from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EnergyElement,
SapAlternativeWall,
SapRoomInRoof,
)
from domain.ml.tests._fixtures import (
@ -1306,3 +1307,64 @@ def test_section_3_partial_match_against_elmhurst_worksheet(fixture: ModuleType)
# (gap #1). Non-RR fixtures get exact (31) + (36) asserted by the
# dedicated `test_section_3_non_rr_line_31_and_36_match_elmhurst_worksheet`.
assert result.total_external_element_area_m2 > 0
def test_room_in_roof_simplified_type_1_adds_a_rr_timber_framed_area_to_roof_w_per_k() -> None:
"""RdSAP10 §3.9.1 Simplified Type 1 (True Room-in-Roof, no common
walls). Formula (page 22 step d): A_RR = 12.5 × (A_RR_floor / 1.5);
this area is treated as timber-framed construction and assigned a
U-value from Table 18 column (4) "Room-in-roof, all elements" by age
band when no gable / party / sheltered / connected wall lengths are
lodged (ΣA_RR_gable/other = 0 A_RR_final = A_RR).
Also (§3.9 page 21): A_RR_floor is deducted from the storey-below
roof area determined by §3.8 (which takes the greatest floor area
across levels).
Synthetic dwelling for arithmetic clarity:
- 2 storeys × 40 each §3.8 roof area = 40
- RR floor area = 15
- §3.9 deducted top-floor roof area = 40 15 = 25
- A_RR = 12.5 × (15 / 1.5) = 12.5 × 10 39.5285
- Age band B U_roof (Table 18 col 1) = 0.40; U_RR (Table 18
col 4 "as built") = 2.30.
- roof_w_per_k = 25 × 0.40 + 39.5285 × 2.30 = 10.00 + 90.9156
= 100.9156 W/K.
"""
# Arrange
import math
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=3,
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=20.0, party_wall_length_m=0.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=40.0, room_height_m=2.5,
heat_loss_perimeter_m=20.0, party_wall_length_m=0.0, floor=1,
),
],
sap_room_in_roof=SapRoomInRoof(floor_area=15.0, construction_age_band="B"),
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=95.0, # 40 + 40 + 15
habitable_rooms_count=5,
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_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)