From 3aed8f858a98f6962b52735ea850cfe0365103e3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 18:05:33 +0000 Subject: [PATCH] fix(cascade): suppress floor heat loss for "another dwelling below" (code 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floor lodged API floor_heat_loss=6 ("another dwelling below") sits over another heated dwelling, so it is a party floor with no heat loss (RdSAP 10 §3). The mapper mapped code 6 → None and the heat-transmission step drove floor exposure solely from the dwelling-level `has_exposed_floor` flag — which is keyed only on the dwelling_type label and defaults a "Ground-floor flat" to an exposed floor. So a ground-floor flat above a basement dwelling kept its full ground-floor heat-loss area. Map code 6 → "(another dwelling below)" (still != "Ground floor", so the §5 (12) suspended-timber rule stays inert) and have the cascade suppress that BP's floor when its floor_type carries the signal, mirroring the roof's existing "another dwelling above" per-BP party override. Cert 2115-4121-4711-9361-3686 (ground-floor flat, floor_heat_loss=6): floor_w_per_k 47.85 → 0; SAP -23.44 → -4.41. Cert 0350-…-6435 -12.38 → -0.55; 0926-…-9024 -2.35 → -0.82. Eval mean |err| 1.982 → 1.944. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 27 ++++++++------ .../domain/tests/test_from_rdsap_schema.py | 28 +++++++++++++++ .../worksheet/heat_transmission.py | 12 ++++++- domain/sap10_ml/tests/_fixtures.py | 2 ++ .../worksheet/test_heat_transmission.py | 36 +++++++++++++++++++ 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 8a65cd9e..ea1e2d29 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2631,16 +2631,21 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]: # the same top-level floors[] description # as code 2; route to the same cascade # signal until a fixture forces them apart -# 6 = "(another dwelling below)" — top-floor flat over a party floor; -# cert 9501 lodges this. The cascade's -# floor-as-party-floor dispatch already -# handles this via `property_type=Flat` + -# cert.floors[].description, so the -# floor_type string from this helper is -# not consumed for the (12) spec rule -# in that path — explicit None preserves -# the cert 9501 cascade match without -# silently letting unknown codes through. +# 6 = "(another dwelling below)" — the floor sits over another heated +# dwelling (e.g. an upper-floor flat, or a +# ground-floor flat above a basement flat), +# so it is a party floor with no heat loss +# (RdSAP 10 §3). The heat-transmission step +# reads this string to suppress the BP's +# floor area, mirroring the roof's "another +# dwelling above" party override — the +# dwelling-level exposure heuristic (keyed +# only on the dwelling_type label) defaults +# has_exposed_floor=True for a ground-floor +# flat, so the per-BP lodgement is needed to +# override it. It is != "Ground floor", so +# the §5 (12) suspended-timber rule stays +# inert (short-circuits exactly as None did). # 7 = "Ground floor" — typical ground-floor heat loss # # Codes 4/5/8+ are not yet observed in any fixture; the strict-raise @@ -2650,7 +2655,7 @@ _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, Optional[str]] = { 1: "To external air", 2: "To unheated space", 3: "To unheated space", - 6: None, + 6: "(another dwelling below)", 7: "Ground floor", } diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index c9fe69b6..f20a5615 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -887,6 +887,34 @@ class TestElmhurstGlazingTypeWrappedGap: assert code == 2 +class TestApiFloorTypeCode: + """`_api_floor_type_str` maps the GOV.UK API integer floor_heat_loss + code to the floor-position string the cascade reads. Code 6 ("another + dwelling below") must surface "(another dwelling below)" so the + heat-transmission step can suppress that BP's floor as a party floor + (RdSAP 10 §3) — it previously mapped to None and the floor leaked + heat-loss area. Cert 2115-4121-4711-9361-3686 (ground-floor flat over + another dwelling) under-rated ~23 SAP from the over-counted floor.""" + + def test_code_6_maps_to_another_dwelling_below(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_type_str(6) + + # Assert — a party-floor signal the cascade consumes (not None). + assert result == "(another dwelling below)" + + def test_code_7_still_maps_to_ground_floor(self) -> None: + # Arrange — regression guard: the ground-floor signal the §5 (12) + # suspended-timber rule keys on is unchanged. + from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + + # Act / Assert + assert _api_floor_type_str(7) == "Ground floor" + + class TestApiFloorConstructionCode: """`_api_floor_construction_str` maps the GOV.UK API integer floor_construction code to the description string the cascade's diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index c8c12c74..5729fd9a 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -937,8 +937,18 @@ def heat_transmission_from_cert( 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)) + # Per-BP floor exposure: a floor lodged "(another dwelling below)" + # (API floor_heat_loss=6) sits over another heated dwelling, so it + # is a party floor with no heat loss (RdSAP 10 §3) — suppress that + # BP's floor even when the dwelling-level `has_exposed_floor` flag + # is True. The flag is keyed only on the dwelling_type label, which + # defaults a "Ground-floor flat" to an exposed floor; the per-BP + # lodgement is authoritative. Mirrors the roof's "another dwelling + # above" override above. Cert 2115-4121-4711-9361-3686. + part_floor_is_party = "another dwelling below" in (part.floor_type or "").lower() + part_has_exposed_floor = exposure.has_exposed_floor and not part_floor_is_party floor_area_total = _round_half_up( - geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0, + geom["ground_floor_area_m2"] if part_has_exposed_floor else 0.0, _AREA_ROUND_DP, ) diff --git a/domain/sap10_ml/tests/_fixtures.py b/domain/sap10_ml/tests/_fixtures.py index 7a8da2a0..040466b5 100644 --- a/domain/sap10_ml/tests/_fixtures.py +++ b/domain/sap10_ml/tests/_fixtures.py @@ -155,6 +155,7 @@ def make_building_part( roof_construction: Optional[int] = 4, floor_dimensions: Optional[list[SapFloorDimension]] = None, sap_room_in_roof: Optional[SapRoomInRoof] = None, + floor_type: Optional[str] = None, ) -> SapBuildingPart: """Build a SapBuildingPart with sensible SAP10 defaults.""" return SapBuildingPart( @@ -169,6 +170,7 @@ def make_building_part( if floor_dimensions is not None else [make_floor_dimension()], sap_room_in_roof=sap_room_in_roof, + floor_type=floor_type, ) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index ad7ad30d..bb55b94a 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -921,6 +921,42 @@ def test_ground_floor_flat_exposure_keeps_floor_drops_roof() -> None: assert ground.roof_w_per_k == 0.0 +def test_floor_over_another_dwelling_below_zeroes_floor_despite_exposed_flag() -> None: + # Arrange — a "Ground-floor flat" lodged with floor_heat_loss=6 + # ("another dwelling below") sits over a heated dwelling (e.g. a + # basement flat), so its floor is a party floor (U=0, no heat loss) + # even though the dwelling-level exposure heuristic — keyed only on + # the "Ground-floor flat" label — defaults has_exposed_floor=True. + # The per-BP `floor_type` lodgement is authoritative and must + # suppress that BP's floor, mirroring the roof's "another dwelling + # above" party override. RdSAP 10 §3 — party floors between dwellings + # are not heat-loss elements. Cert 2115-4121-4711-9361-3686. + main = make_building_part( + construction_age_band="G", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_type="(another dwelling below)", + 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, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=60.0, country_code="ENG", sap_building_parts=[main], + ) + + # Act — dwelling-level exposure still flags the floor as exposed. + result = heat_transmission_from_cert( + epc, exposure=DwellingExposure(has_exposed_floor=True, has_exposed_roof=False), + ) + + # Assert — the per-BP "another dwelling below" override wins → no floor loss. + assert result.floor_w_per_k == 0.0 + assert result.walls_w_per_k > 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-