S0380.203: RdSAP 10 §3.7 — "Roof of Room" rooflights deduct from the RR residual

A rooflight deducts from the gross area of the roof element it pierces
(RdSAP 10 §3.7, PDF p.19). A "Roof of Room" rooflight (window_wall_type=4
/ site-notes "Roof of Room") sits on the room-in-roof sloped ceiling, so
its area must deduct from the §3.10.1 RR residual roof — not the flat /
loft external roof.

The cascade deducted every rooflight from the regular roof (heat_
transmission line 814). Simulated case 6's worksheet is the first
worksheet evidence for "Roof of Room" rooflight billing: "Roof room Main
remaining area" net 55.54 = gross 61.73 − 6.19 rooflights (U_RR=0.30),
while "External roof Main" 14.52 carries no opening. New
`_bp_rr_roof_absorbs_rooflight` routes the rooflight area to the RR roof
(simplified A_RR_final or detailed §3.10.1 residual) ONLY when the BP's
RR contributes such a shell AND lodges no explicit roof surface (slope /
flat_ceiling / stud_wall). Case 6 roof (30) 20.2284 → 19.0523 EXACT;
demand gap +153 → +61 kWh/yr.

Preserved: certs 000565 (Ext2 stud walls) and 000516 (slopes) lodge
explicit roof surfaces → rooflight keeps deducting from the regular roof
(their 1e-4 worksheet pins hold). Simplified Type 1 RR is excluded too.

Re-pin (uniform spec application per [[feedback-software-no-special-
handling]] + worksheet-is-truth): API certs 6035 and 0240 are detailed-RR
gables-only like case 6 (no worksheet of their own for rooflights), so
their "Roof of Room" rooflights now deduct from the RR residual too. This
SUPERSEDES the unvalidated S0380.198 "deduct from loft" assumption.
- 6035: roof 78.0648 → 73.9176; the previously-"unexplained" +1.37 PE
  residual COLLAPSES to -0.14 (CO2 -0.0004 → -0.0362; SAP exact 70) —
  strong corroboration the rooflight-on-RR treatment is correct.
- 0240: PE +2.5812 → +2.1519, CO2 +0.1269 → +0.1051 (SAP 72 unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 15:19:37 +00:00
parent 3581513b7e
commit a42e03529c
4 changed files with 113 additions and 16 deletions

View file

@ -450,6 +450,45 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
}
_RR_ROOF_LODGEMENT_KINDS: Final[frozenset[str]] = frozenset(
{"slope", "flat_ceiling", "stud_wall"}
)
def _bp_rr_roof_absorbs_rooflight(
part: SapBuildingPart, geom: dict[str, Any]
) -> bool:
"""Whether a rooflight on this building part pierces the room-in-roof
sloped ceiling (so it deducts from the RR roof contribution) rather
than a flat external roof.
True ONLY for a Detailed RR (§3.10) lodging wall surfaces but no roof
surfaces (gable / common / connected, no slope / flat_ceiling /
stud_wall): the §3.10.1 residual roof fires and the rooflight deducts
from it (simulated case 6: the 6 "Roof of Room" rooflights deduct from
"Roof room Main remaining" net 55.54 = gross 61.73 6.19).
False otherwise:
- Simplified Type 1/2 RR (geom A_RR > 0, certs 6035 / 0240): the
rooflight pierces the regular loft roof at U_roof, NOT the A_RR
shell its area deducts from `roof_area` (the test
`test_6035_api_room_in_roof_gables_deduct_from_roof` pins this).
- Detailed RR lodging explicit roof surfaces (cert 000565 Ext2 stud
walls / 000516 slopes): the rooflight pierces the regular roof.
Both keep the pre-S0380.203 §3.7 "deduct from the host roof" behaviour.
"""
if geom["rr_simplified_a_rr_m2"] > 0:
return False
rir = part.sap_room_in_roof
if rir is None or not rir.detailed_surfaces:
return False
if float(rir.floor_area) <= 0.0:
return False
return not any(
s.kind in _RR_ROOF_LODGEMENT_KINDS for s in rir.detailed_surfaces
)
def heat_transmission_from_cert(
epc: EpcPropertyData,
*,
@ -811,7 +850,23 @@ def heat_transmission_from_cert(
if "sloping ceiling" in roof_type:
top_floor_area = top_floor_area / _COS_30_DEG
gross_roof_area = _round_half_up(top_floor_area, _AREA_ROUND_DP)
roof_area = max(0.0, gross_roof_area - rw_area_part)
# RdSAP 10 §3.7 — a rooflight deducts from the gross roof of the
# element it physically pierces. A "Roof of Room" rooflight sits on
# the room-in-roof sloped ceiling (the §3.9/§3.10 A_RR shell), not a
# flat external roof, so its area deducts from the RR roof
# contribution (simplified A_RR_final or the §3.10.1 detailed
# residual) rather than `roof_area` — but ONLY when the BP's RR
# actually contributes such a shell/residual. Where the BP lodges
# explicit roof surfaces (cert 000565 Ext2 stud walls / 000516
# slopes), the rooflight pierces those (the regular roof) and
# deducts there per §3.7 (current behaviour). Simulated case 6
# worksheet: "Roof room Main remaining area" net 55.54 = gross 61.73
# 6.19 rooflights, while "External roof Main" 14.52 carries no
# opening.
rw_area_on_rr = (
rw_area_part if _bp_rr_roof_absorbs_rooflight(part, geom) else 0.0
)
roof_area = max(0.0, gross_roof_area - (rw_area_part - rw_area_on_rr))
floor_area_total = _round_half_up(
geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0,
_AREA_ROUND_DP,
@ -868,7 +923,9 @@ def heat_transmission_from_cert(
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)
# Deduct any "Roof of Room" rooflights piercing the RR shell
# (see `rw_area_on_rr` rationale at the gross-roof block).
a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable - rw_area_on_rr)
u_rr = u_rr_default_all_elements(
country=country, age_band=rir.construction_age_band,
)
@ -1003,7 +1060,13 @@ def heat_transmission_from_cert(
a_rr_shell = _round_half_up(
12.5 * sqrt(rr_floor_for_a_rr / 1.5), _AREA_ROUND_DP,
)
residual_area = max(0.0, a_rr_shell - rr_walls_in_a_rr_area)
# Deduct any "Roof of Room" rooflights piercing the RR
# residual (see `rw_area_on_rr` rationale at the gross-roof
# block) — case 6: 93.09 shell 31.36 gables 6.19
# rooflights = 55.54 net = worksheet "Roof room remaining".
residual_area = max(
0.0, a_rr_shell - rr_walls_in_a_rr_area - rw_area_on_rr
)
if residual_area > 0.0:
rr_detailed_area += residual_area
roof += residual_area * u_rr_default_all_elements(

View file

@ -83,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=+2.5812,
expected_co2_resid_tonnes_per_yr=+0.1269,
expected_pe_resid_kwh_per_m2=+2.1519,
expected_co2_resid_tonnes_per_yr=+0.1051,
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_"
@ -163,7 +163,13 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"internal gain lowers space-heating demand → SAP cont 72.18 → "
"72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 "
"+0.1385 → +0.1269 (both closer to zero). Validated against "
"case 6 worksheet (70) = 10 (= 3 Main 1 + 7 Main 2)."
"case 6 worksheet (70) = 10 (= 3 Main 1 + 7 Main 2). "
"Slice S0380.203 routed this cert's 6 'Roof of Room' rooflights "
"(window_wall_type=4) to deduct from the §3.10.1 RR residual "
"instead of the regular roof (the case-6 worksheet rule). 0240 "
"is detailed-RR gables-only like case 6 → roof drops → space-"
"heating demand falls → PE +2.5812 → +2.1519, CO2 +0.1269 → "
"+0.1051 (both closer to zero; SAP integer 72 unchanged)."
),
),
_GoldenExpectation(
@ -237,8 +243,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=+1.3743,
expected_co2_resid_tonnes_per_yr=-0.0004,
expected_pe_resid_kwh_per_m2=-0.1357,
expected_co2_resid_tonnes_per_yr=-0.0362,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"S0380.189 fixed the dominant driver: walls are solid brick "
@ -288,8 +294,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"rooflights) which were billed as vertical glazing; routing "
"them to roof windows (27a) at inclined U=2.30 + 45° solar "
"tightened PE +1.84 → +1.37 and CO2 +0.01 → -0.0004 (SAP still "
"exact). Remaining +1.37 PE is unrelated gains/HW (no "
"worksheet for 6035 itself to pin further)."
"exact). "
"Slice S0380.203 CLOSED the remaining +1.37 PE (it was NOT "
"'unrelated gains/HW'): the 2 'Roof of Room' rooflights pierce "
"the room-in-roof sloped ceiling, so their 1.92 m² deducts from "
"the §3.10.1 RR residual (uninsulated U_RR=2.30) — not the "
"insulated loft (U=0.14) the S0380.198 assumption used. Roof "
"78.0648 → 73.9176 (4.42 W/K); space-heating demand drops → "
"PE +1.37 → -0.14, CO2 -0.0004 → -0.0362 (SAP still exact 70). "
"Validated against the simulated-case-6 worksheet, the only "
"worksheet evidence for 'Roof of Room' rooflight deduction "
"(6035's site-notes case-4 replica lodges no rooflights)."
),
),
_GoldenExpectation(
@ -856,9 +871,16 @@ def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None:
# Act
roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k
# Assert — 78.3336 (gable-deducted residual + loft + ext roof) less
# the S0380.198 deduction of 6035's 2 room-in-roof rooflights
# (window_wall_type=4, 2 × 1.2×0.8 = 1.92 m²) from the gross roof at
# U_roof=0.14 → 78.3336 0.2688 = 78.0648. The rooflights' own A×U
# moves to roof_windows_w_per_k.
assert abs(roof_w_per_k - 78.0648) <= 1e-4
# Assert — 78.3336 (gable-deducted residual + loft + ext roof). The 2
# room-in-roof rooflights (window_wall_type=4 = "Roof of Room", 1.92 m²)
# pierce the RR sloped ceiling, so per S0380.203 their area deducts from
# the §3.10.1 residual (at the uninsulated U_RR=2.30) — NOT the insulated
# loft at U_roof=0.14 as the unvalidated S0380.198 assumption had it.
# 78.3336 1.92 × 2.30 = 78.3336 4.416 = 73.9176. The rooflights' own
# A×U stays on roof_windows_w_per_k. This matches the simulated-case-6
# worksheet, where the only worksheet evidence for "Roof of Room"
# rooflight deduction shows them billed against "Roof room remaining"
# (the RR residual), not the flat/loft roof. Cert 6035 is API-only and
# its site-notes case-4 worksheet replica lodges no rooflights, so the
# case-6 worksheet is the spec authority for this archetype.
assert abs(roof_w_per_k - 73.9176) <= 1e-4

View file

@ -69,6 +69,13 @@ LINE_27_WINDOWS_W_PER_K: Final[float] = 22.7408
LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 13.0375
LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13
# Worksheet (30) Roof total W/K = RR remaining (net of the 6.19 m² roof
# windows) 55.54 × 0.30 = 16.6620 + External roof Main 14.52 × 0.11 =
# 1.5972 + External roof Ext1 7.21 × 0.11 = 0.7931 → 19.0523. The 6 "Roof
# of Room" rooflights pierce the room-in-roof sloped ceiling, so their
# area deducts from the RR residual area, NOT the external flat roof.
LINE_30_ROOF_W_PER_K: Final[float] = 19.0523
# Worksheet (231) "Total electricity for the above, kWh/year" (Block 1).
# Decomposes as (230c) central heating pump 156 + (230d) oil boiler pump
# 200. (230c) = 41 (Main 1 circ pump, "2013 or later") + 115 (Main 2 circ

View file

@ -276,6 +276,11 @@ def test_section_3_roof_windows_case6_match_pdf() -> None:
_w001431_case6.LINE_31_TOTAL_EXTERNAL_AREA_M2,
"§3 (31) case6",
)
_pin(
ht.roof_w_per_k,
_w001431_case6.LINE_30_ROOF_W_PER_K,
"§3 (30) case6",
)
def test_section_4f_pumps_fans_case6_match_pdf() -> None: