diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index d21b2eb1..dff61a08 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -341,6 +341,13 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]: def _part_geometry(part: SapBuildingPart) -> dict[str, float]: if not part.sap_floor_dimensions: + # A part with no floor dimensions has no derivable RR shell or + # cantilever geometry, but the early return must still expose the + # SAME keys as the full return below: the §3.9 RR block reads + # geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] / + # ["cantilever_floor_area_m2"] for every part, so omitting them + # here raised KeyError on multi-part certs whose first bp lodges + # no sap_floor_dimensions (5 certs in a 2026 API sample). return { "ground_floor_area_m2": 0.0, "top_floor_area_m2": 0.0, @@ -348,6 +355,9 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: "party_wall_area_m2": 0.0, "rr_floor_area_m2": 0.0, "rr_simplified_a_rr_m2": 0.0, + "rr_common_wall_area_m2": 0.0, + "rr_gable_area_m2": 0.0, + "cantilever_floor_area_m2": 0.0, } fds = list(part.sap_floor_dimensions) ground = next((fd for fd in fds if fd.floor == 0), fds[0]) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 76edac33..600bf7d9 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -36,11 +36,36 @@ from domain.sap10_calculator.worksheet.heat_transmission import ( heat_transmission_from_cert, ) from domain.sap10_calculator.worksheet.heat_transmission import ( + _part_geometry, # pyright: ignore[reportPrivateUsage] _round_half_up, # pyright: ignore[reportPrivateUsage] _window_bp_index, # pyright: ignore[reportPrivateUsage] ) +def test_part_geometry_floorless_part_honours_full_key_contract() -> None: + # Arrange — a building part lodged with NO sap_floor_dimensions (e.g. + # a party-wall-only or RR-only extension; observed on 5 certs in a + # 2026 API sample). `_part_geometry`'s early return must expose the + # same dict keys as its full return: the §3.9 RR contribution block + # reads geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] for + # EVERY part, so a missing key raises KeyError and blocks the cert. + floorless = make_building_part(floor_dimensions=[]) + with_floors = make_building_part( + floor_dimensions=[make_floor_dimension(total_floor_area_m2=50.0)] + ) + + # Act + early = _part_geometry(floorless) + full = _part_geometry(with_floors) + + # Assert — identical key contract; the RR/cantilever geometry is 0.0 + # for a floorless part (no floor area ⇒ no RR shell or cantilever). + assert set(early.keys()) == set(full.keys()) + assert early["rr_common_wall_area_m2"] == 0.0 + assert early["rr_gable_area_m2"] == 0.0 + assert early["cantilever_floor_area_m2"] == 0.0 + + def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() -> None: # Arrange — 346 corpus certs lodge roof_insulation_thickness="NI" # with descriptions like "Pitched, insulated (assumed)". The