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.