From a42e03529c2fa7dad628a86eda4e5bc144db5159 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 15:19:37 +0000 Subject: [PATCH] =?UTF-8?q?S0380.203:=20RdSAP=2010=20=C2=A73.7=20=E2=80=94?= =?UTF-8?q?=20"Roof=20of=20Room"=20rooflights=20deduct=20from=20the=20RR?= =?UTF-8?q?=20residual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../worksheet/heat_transmission.py | 69 ++++++++++++++++++- .../rdsap/test_golden_fixtures.py | 48 +++++++++---- .../_elmhurst_worksheet_001431_case6.py | 7 ++ .../worksheet/test_section_cascade_pins.py | 5 ++ 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 1c0a6f0c..ea1107fc 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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( diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index d656440d..c1507af0 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -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 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index 56b91e60..c9f1d820 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -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 diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index e00194c6..a6952cb4 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -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: