From 9b0c590bf8f7a1ab4e45adb5410746fe62368ec2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 13:16:44 +0000 Subject: [PATCH] =?UTF-8?q?fix(heat-transmission):=20bill=20a=20ground-flo?= =?UTF-8?q?or=20flat's=20ground=20floor=20(RdSAP=2010=20=C2=A73.12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat floor-exposure heuristic keys on dwelling_type: a flat defaults to has_exposed_floor=False (assuming a heated dwelling below). The Elmhurst Summary path lodges a ground-floor flat's vertical position as a "Ground floor" floor_type rather than the API floor_heat_loss=1 exposed code, and the mapper can label such a flat "Top-floor flat" — so the cascade dropped the ground floor entirely (a ground floor is in contact with the ground and carries heat loss). Treat a "ground floor" floor_type as a heat-loss floor, overriding the dwelling-level suppression upward — mirroring the existing "another dwelling below" party override downward. Worksheet-validated to 1e-4 on simulated case 45 (a ground-floor flat the mapper labelled "Top-floor flat"): floor (28a) 0 -> 25.38 W/K, fabric (33) 75.63 -> 101.0104, HTC (39) 112.93 -> 145.3579, all matching the P960 exactly; SAP 67.81 -> 62.52. RdSAP-21.0.1 corpus within-0.5 69.5% -> 69.7% (MAE 0.859 -> 0.854). Floors ratcheted. Pinned in test_heat_transmission (ground-floor billed + party-floor suppressed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../worksheet/heat_transmission.py | 11 ++++ .../worksheet/test_heat_transmission.py | 64 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 10 ++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index d12b7da9..8140cec2 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -1105,6 +1105,16 @@ def heat_transmission_from_cert( # 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() + # A part whose floor_type is a GROUND floor sits in contact with the + # ground (RdSAP 10 §3.12) and is therefore a heat-loss floor, even when + # the dwelling-level flat heuristic (`_dwelling_exposure`) defaults a + # flat to has_exposed_floor=False. The Elmhurst Summary path lodges a + # ground-floor flat's position as a "Ground floor" floor_type (not the + # API floor_heat_loss=1 exposed code), so without this signal the + # cascade dropped its ground floor entirely — simulated case 45 (a + # ground-floor flat the mapper labelled "Top-floor flat"): worksheet + # (28a) = 47.0 × 0.54 = 25.38 W/K billed as 0, over-rating by +7 SAP. + part_floor_is_ground = "ground floor" in (part.floor_type or "").lower() # A floor lodged as a heat-loss floor — *exposed* (API # floor_heat_loss=1 → `is_exposed_floor`, "an exposed floor if there # is an open space below") or *above a partially heated space* (API @@ -1117,6 +1127,7 @@ def heat_transmission_from_cert( # the "another dwelling below" party signal overrides it downward. part_has_exposed_floor = ( exposure.has_exposed_floor or is_exposed_floor or is_above_partial + or part_floor_is_ground ) and not part_floor_is_party floor_area_total = _round_half_up( geom["ground_floor_area_m2"] if part_has_exposed_floor else 0.0, diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index d841746e..0d53867b 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -157,6 +157,70 @@ def test_mixed_flat_pitched_roof_does_not_contaminate_pitched_u_value() -> None: assert abs(result.roof_w_per_k - 44.6) <= 2.0 +def test_ground_floor_flat_bills_floor_despite_flat_dwelling_type() -> None: + # Arrange — a ground-floor flat whose dwelling_type the mapper labelled + # "Top-floor flat" (so the dwelling-level exposure heuristic + # `_dwelling_exposure` suppresses the floor on the assumption a heated + # dwelling sits below), but whose building part lodges a "Ground floor" + # floor_type. A ground floor is in contact with the ground (RdSAP 10 + # §3.12) -> heat-loss floor. The Elmhurst Summary path lodges this as a + # "Ground floor" floor_type (not the API floor_heat_loss=1 exposed code), + # so without the per-part ground signal the cascade dropped the floor. + # Worksheet-validated by simulated case 45: (28a) = 47.0 × U=0.54 = 25.38 + # W/K, billed as 0 before this fix (+7 SAP). + ground = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="C", + floor_type="Ground floor", + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=47.0, room_height_m=2.4, + heat_loss_perimeter_m=15.8, party_wall_length_m=0.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=47.0, country_code="ENG", + dwelling_type="Top-floor flat", property_type="Flat", + sap_building_parts=[ground], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — the ground floor carries heat loss (≈ 47 × 0.54), not 0. + assert result.floor_w_per_k > 20.0 + + +def test_top_floor_flat_with_party_floor_stays_suppressed() -> None: + # Arrange — the contrast: a flat lodging "(another dwelling below)" sits + # over a heated dwelling, so its floor is a party floor with no heat loss + # (RdSAP 10 §3). The ground-floor override must NOT fire — proving the + # discriminator is the floor_type, not the flat label. + party = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="C", + floor_type="To another dwelling below", + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=47.0, room_height_m=2.4, + heat_loss_perimeter_m=15.8, party_wall_length_m=0.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=47.0, country_code="ENG", + dwelling_type="Top-floor flat", property_type="Flat", + sap_building_parts=[party], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — party floor, no heat loss. + assert result.floor_w_per_k == 0.0 + + 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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 9aed0f34..7b510ab3 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -119,7 +119,15 @@ _CORPUS = Path( # 100010129331 (roof 110.5 -> 31.3 W/K, +13.1 -> -0.05 SAP). within-0.5 # 68.8% -> 69.5% (MAE 0.888 -> 0.859; PE 13.9 -> 13.6); 3-part cohort 56% -> # 61%. Pinned in test_heat_transmission (by_kind split + no-contamination). -_MIN_WITHIN_HALF_SAP = 0.69 +# GROUND-FLOOR FLAT FLOOR EXPOSURE (RdSAP 10 §3.12): a ground-floor flat whose +# dwelling_type the mapper labelled "Top-floor flat" had its ground floor (in +# contact with the ground -> heat loss) dropped, because the flat exposure +# heuristic keys on dwelling_type and the Summary path lodges the position as a +# "Ground floor" floor_type (not the API floor_heat_loss=1 code). Treating a +# "ground floor" floor_type as exposed (worksheet-validated to 1e-4 on simulated +# case 45: floor (28a) 0 -> 25.38 W/K, fabric (33) 75.6 -> 101.01) -> 69.5% -> +# 69.7% (MAE 0.859 -> 0.854). Pinned in test_heat_transmission. +_MIN_WITHIN_HALF_SAP = 0.695 _MAX_SAP_MAE = 0.86 _MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current