From 7136edf2fbf2b93a2c99123af472d5f578c2dcf0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 09:20:57 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.22:=20per-BP=20roof=20exposure=20?= =?UTF-8?q?=E2=80=94=20closes=20cert=200036=20Ext1=20flat=20roof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For multi-BP dwellings the dwelling-level `exposure.has_exposed_roof` flag (derived from `dwelling_type` via `_dwelling_exposure`) zeroed out ALL BPs' roof contributions uniformly. That's wrong when a flat has an extension with its own external roof — e.g. ground-floor flat with a single-storey extension whose flat roof is exposed. Replace the global suppression with a per-BP signal: - Per-BP `roof_construction_type` containing "another dwelling above" → that BP's roof is party → suppress. - Otherwise BP 0 (Main) falls back to the dwelling-level flag (covers flat lodgements that don't explicitly mark the Main roof type). - Extensions (i > 0) expose their roof by default unless their own roof_construction_type lodges as party. Cohort cert 0036-6325-1100-0063-1226 (ground-floor flat, age D): - Main lodges roof_construction_type = "Another dwelling above" → contributes 0 W/K (matches worksheet line (30) "External roof Main 57.93 m² × U=0 = 0.0"). - Ext1 lodges roof_construction_type = "Flat" → contributes 1.09 m² × U=2.30 = 2.507 W/K (matches worksheet "External roof Ext1 1.09 m² × U=2.30 = 2.507", spec line (30)). - Cascade SAP closes from +0.2987 → -6e-6 vs worksheet 62.7471. Houses + bungalows are unaffected: dwelling-level flag stays True and the per-BP guard only activates on explicit party-roof lodgement. Single-BP flat tests stay correct: the per-BP guard is a no-op when no roof_construction_type is lodged (i==0 → falls back to dwelling- level flag). Spec citation: - RdSAP 10 §3 / §5.11 — heat-loss surfaces and party-roof treatment. SAP 10.2 spec line (30) sums external roofs only; party roofs sit in the (32) party-element channel with U=0. Cohort-2 distribution (38 certs, Summary path) shifts: exact (<1e-4): 19 → **20** (+1: 0036) 0.07..0.5: 2 → **1** (-1: 0036 → exact) Pyright net-zero (heat_transmission.py 13→13, test file 71→71). Test counts: 702 → 703 pass (+1 new test), 10 expected fails unchanged. Co-Authored-By: Claude Opus 4.7 --- .../worksheet/heat_transmission.py | 20 ++++++- .../worksheet/tests/test_heat_transmission.py | 58 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index a658e668..a397fea1 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -686,8 +686,26 @@ def heat_transmission_from_cert( # floor areas on each level. For a pitched roof with a sloping # ceiling, divide that area by cos(30°) — the worksheet enters # the inclined surface area, not the horizontal projection. - top_floor_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0 roof_type = (part.roof_construction_type or "").lower() + # Per-BP roof exposure: an extension on a flat can have its own + # external roof even when the dwelling-level position says the + # primary building's roof is party (cohort cert 0036: ground- + # floor flat with single-storey Ext1 flat roof, worksheet (30) + # = 1.09 m² × U=2.30 = 2.51 W/K). The per-BP signal is the + # explicit `roof_construction_type` lodgement of "another + # dwelling above" — when present, suppress that BP's roof; when + # absent, the dwelling-level `exposure.has_exposed_roof` flag + # applies to the primary BP (i==0) and extensions (i>0) expose + # by default. Houses + bungalows pass through unchanged because + # their dwelling-level flag stays True. + part_roof_is_party = "another dwelling above" in roof_type + if part_roof_is_party: + part_has_exposed_roof = False + elif i == 0: + part_has_exposed_roof = exposure.has_exposed_roof + else: + part_has_exposed_roof = True + top_floor_area = geom["top_floor_area_m2"] if part_has_exposed_roof else 0.0 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) diff --git a/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py b/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py index b3d43bf1..dbdeca5d 100644 --- a/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py +++ b/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py @@ -762,6 +762,64 @@ def test_ground_floor_flat_exposure_keeps_floor_drops_roof() -> None: assert ground.roof_w_per_k == 0.0 +def test_ground_floor_flat_extension_with_flat_roof_exposes_extension_roof_only() -> None: + """Per-BP roof exposure: an extension on a ground-floor flat can have + its own external (e.g. single-storey) roof even though the dwelling- + level position says the main building's roof is party. Cohort cert + 0036-6325-1100-0063-1226: ground-floor flat, Main lodges roof type + "Another dwelling above" (party); Ext1 lodges roof type "Flat" with + its own external surface. Worksheet (30) sums Ext1's 1.09 m² × U=2.30 + = 2.507 W/K; Main contributes 0 W/K. Without the per-BP signal the + dwelling-level `has_exposed_roof=False` zeroes both → -0.30 SAP + over-prediction. + """ + # Arrange + main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="D", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=60.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0, + ), + ], + ) + main.roof_construction_type = "Another dwelling above" + ext1 = make_building_part( + identifier=BuildingPartIdentifier.EXTENSION_1, + construction_age_band="D", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=1.09, room_height_m=2.0, + party_wall_length_m=0.0, heat_loss_perimeter_m=3.0, floor=0, + ), + ], + ) + ext1.roof_construction_type = "Flat" + epc = make_minimal_sap10_epc( + total_floor_area_m2=61.09, + country_code="ENG", + sap_building_parts=[main, ext1], + ) + + # Act + result = heat_transmission_from_cert( + epc, + exposure=DwellingExposure(has_exposed_floor=True, has_exposed_roof=False), + ) + + # Assert — only Ext1's 1.09 m² flat roof contributes; Main's roof is + # party. Age D flat-roof default per Table 18 col (3) = 2.30 W/m²K. + expected_ext1_roof = 1.09 * 2.30 + assert abs(result.roof_w_per_k - expected_ext1_roof) <= 0.01, ( + f"got {result.roof_w_per_k:.4f}, want {expected_ext1_roof:.4f}" + ) + + # ============================================================================ # New §3 worksheet-line-mapped tests: alternative walls, effective window U, # and the (31)/(33) line-ref fields. Reference: SAP10.2 §3.2, RdSAP10 §1.4.2.