diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index a292bcce..04f39ba6 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2627,10 +2627,18 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]: # 1 = "To external air" — exposed floor (cantilever / passageway) # 2 = "To unheated space" — over garage / unheated basement / # crawlspace; cert 7536 Main lodges this -# 3 = "To unheated space" — variant lodged by cert 7536 Ext2 with -# the same top-level floors[] description -# as code 2; route to the same cascade -# signal until a fixture forces them apart +# 3 = "(other premises below)" — the lowest floor sits over non-domestic +# "other premises" (heated, but at different +# times), so it is "above a partially heated +# space" per RdSAP 10 §3.12 (PDF p.25) → the +# §5.14 constant U=0.7 W/m²K. The independent +# floors[].description resolves this: all 13 +# code-3 certs in the 2026 sample lodge +# "(other premises below)". `_api_build_sap +# _floor_dimensions` sets is_above_partially +# _heated_space on the floor=0 dimension; +# this string (!= "Ground floor", != "another +# dwelling below") is inert metadata. # 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), @@ -2669,7 +2677,7 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]: _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, Optional[str]] = { 1: "To external air", 2: "To unheated space", - 3: "To unheated space", + 3: "(other premises below)", 6: "(another dwelling below)", 7: "Ground floor", 8: "(another dwelling below)", @@ -2721,6 +2729,19 @@ def _api_roof_construction_str(value: Optional[int]) -> Optional[str]: _API_FLOOR_HEAT_LOSS_EXPOSED: Final[int] = 1 +# API `floor_heat_loss` integer that signals a floor above a partially +# heated space. The independent `floors[].description` field resolves the +# code: floor_heat_loss=3 lodges "(other premises below)" (13/13 certs in +# the 2026 sample). Per RdSAP 10 §3.12 (PDF p.25) a flat's floor is "above +# a partially heated space if there are non-domestic premises below +# (heated, but at different times)" — the "other premises" wording. That +# routes the cascade to the §5.14 (PDF p.47) constant U=0.7 W/m²K via +# `u_floor_above_partially_heated_space`, distinct from code 2's "To +# unheated space" (semi-exposed → Table 20) and code 6's "(another dwelling +# below)" (party floor, no heat loss). +_API_FLOOR_HEAT_LOSS_ABOVE_PARTIALLY_HEATED: Final[int] = 3 + + # GOV.UK API `built_form` integer → SAP10.2 sheltered_sides count per # RdSAP §S5. Detached has no neighbours shielding wind; terraced # variants pick up 1-3 sheltered sides via adjacent dwellings. Cross- @@ -3013,6 +3034,9 @@ def _api_build_sap_floor_dimensions( fixture convention. """ is_exposed = floor_heat_loss == _API_FLOOR_HEAT_LOSS_EXPOSED + is_above_partial = ( + floor_heat_loss == _API_FLOOR_HEAT_LOSS_ABOVE_PARTIALLY_HEATED + ) out: List[SapFloorDimension] = [] for fd in fds or []: raw_height = _measurement_value(fd.room_height) @@ -3026,6 +3050,7 @@ def _api_build_sap_floor_dimensions( floor_insulation=fd.floor_insulation, floor_construction=fd.floor_construction, is_exposed_floor=is_exposed and fd.floor == 0, + is_above_partially_heated_space=is_above_partial and fd.floor == 0, )) return out diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index c3ebb7c6..04d9ff64 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -932,6 +932,46 @@ class TestApiFloorTypeCode: # Act / Assert — no-heat-loss signal (not None, not "Ground floor"). assert _api_floor_type_str(8) == "(another dwelling below)" + def test_code_3_maps_to_other_premises_below(self) -> None: + # Arrange — code 3 ↔ "(other premises below)" (confirmed 9/9 on + # single-bp certs in the 2026 API sample). RdSAP 10 §3.12 (PDF p.25) + # classes a floor over non-domestic "other premises" (heated at + # different times) as "above a partially heated space" → §5.14 + # constant U=0.7. The string is != "Ground floor" / "(another + # dwelling below)", so it is inert metadata; the U-routing is driven + # by the `is_above_partially_heated_space` floor-dimension flag. + from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + + # Act / Assert + assert _api_floor_type_str(3) == "(other premises below)" + + def test_code_3_sets_above_partially_heated_space_on_lowest_floor(self) -> None: + # Arrange — the floor-dimension builder flags floor_heat_loss=3 → + # is_above_partially_heated_space on the lowest storey (floor==0) + # only, so the cascade routes that floor to U=0.7 (§5.14) and the + # heat-transmission step keeps its area even on a flat whose + # dwelling-level exposure defaults has_exposed_floor=False. + from datatypes.epc.domain.mapper import _api_build_sap_floor_dimensions # pyright: ignore[reportPrivateUsage] + from datatypes.epc.schema.rdsap_schema_21_0_1 import ( + SapFloorDimension as ApiSapFloorDimension, + ) + + def fd(floor: int) -> ApiSapFloorDimension: + return ApiSapFloorDimension( + floor=floor, + room_height=2.5, + total_floor_area=50.0, + party_wall_length=0.0, + heat_loss_perimeter=28.0, + ) + + # Act + dims = _api_build_sap_floor_dimensions([fd(0), fd(1)], floor_heat_loss=3) + + # Assert — lowest floor flagged, upper storey not. + assert dims[0].is_above_partially_heated_space is True + assert dims[1].is_above_partially_heated_space is False + class TestApiFloorConstructionCode: """`_api_floor_construction_str` maps the GOV.UK API integer diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 91783cbf..3e4c9a4e 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -972,16 +972,18 @@ 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 floor lodged as an *exposed* floor (API floor_heat_loss=1 → - # `is_exposed_floor`, "an exposed floor if there is an open space - # below" per RdSAP 10 §3.12, PDF p.25) carries heat loss even when - # the dwelling-level flat heuristic (`_dwelling_exposure`) defaults - # a mid-/top-floor flat to has_exposed_floor=False on the assumption - # its floor sits over another *heated* dwelling. The per-BP lodgement - # is authoritative: it overrides the suppression upward, mirroring - # how the "another dwelling below" party signal overrides it down. + # 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 + # floor_heat_loss=3, "(other premises below)" → `is_above_partial`) + # per RdSAP 10 §3.12 (PDF p.25) — carries heat loss even when the + # dwelling-level flat heuristic (`_dwelling_exposure`) defaults a + # mid-/top-floor flat to has_exposed_floor=False on the assumption its + # floor sits over another *heated* dwelling. The per-BP lodgement is + # authoritative: it overrides the suppression upward, mirroring how + # the "another dwelling below" party signal overrides it downward. part_has_exposed_floor = ( - exposure.has_exposed_floor or is_exposed_floor + exposure.has_exposed_floor or is_exposed_floor or is_above_partial ) 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/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 4bea5f15..99dbad76 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -370,9 +370,24 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="7536-3827-0600-0600-0276", actual_sap=68, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-6.1952, - expected_co2_resid_tonnes_per_yr=-0.1639, + expected_pe_resid_kwh_per_m2=-5.6414, + expected_co2_resid_tonnes_per_yr=-0.1492, notes=( + "FLOOR-CODE-3 SLICE (re-pinned): the prior 'residual is " + "irreducible register-rounding, DO NOT chase' conclusion below " + "was WRONG. Ext2 (bp3) lodges floor_heat_loss=3 = '(other " + "premises below)' — confirmed authoritative 9/9 on single-bp " + "certs (code 1↔'To external air', 2↔'To unheated space', " + "3↔'(other premises below)', 6↔'(another dwelling below)', " + "7↔Solid/Suspended). Per RdSAP 10 §3.12 (PDF p.25) that is " + "'above a partially heated space if there are non-domestic " + "premises below' → the §5.14 constant U=0.7 W/m²K, NOT the " + "ground-floor 1.12 the case-15/17 repro assumed (the cert's " + "lossy floors[] summary dropped bp3's description, so the prior " + "agent mis-read code 3 as 'ground'). Fix routes code 3 → " + "is_above_partially_heated_space: Ext2 floor U 1.12 → 0.70, " + "PE -6.1952 → -5.6414, CO2 -0.1639 → -0.1492 (both toward 0), " + "SAP integer 69 unchanged → resid +1. HISTORICAL NOTES BELOW. " "Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, " "Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and " "Slice 60 (dwelling-wide thermal bridging y from primary bp's " diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 2137ee9d..24fbe082 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -1033,6 +1033,41 @@ def test_exposed_floor_on_flat_carries_heat_loss_despite_unexposed_flag() -> Non assert result.floor_w_per_k == pytest.approx(21.6, abs=0.1) +def test_above_partially_heated_floor_on_flat_carries_07_loss_despite_unexposed_flag() -> None: + # Arrange — a mid-/top-floor flat whose lowest floor is lodged "above a + # partially heated space" (API floor_heat_loss=3, "(other premises + # below)") sits over non-domestic premises heated at different times. + # RdSAP 10 §3.12 + §5.14 (PDF p.25/47) give such a floor the constant + # U=0.7 W/m²K. As with the exposed-floor case, the dwelling-level flat + # heuristic defaults has_exposed_floor=False (assuming a heated dwelling + # below); the per-BP `is_above_partially_heated_space` lodgement is + # authoritative and overrides the suppression upward. + main = make_building_part( + construction_age_band="B", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_type="(other premises below)", + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=50.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=28.0, floor=0, + ), + ], + ) + main.sap_floor_dimensions[0].is_above_partially_heated_space = True + epc = make_minimal_sap10_epc( + total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[main], + ) + + # Act — dwelling-level exposure flags the floor as NOT exposed (flat). + result = heat_transmission_from_cert( + epc, exposure=DwellingExposure(has_exposed_floor=False, has_exposed_roof=True), + ) + + # Assert — §5.14 constant U=0.7 × 50 m² = 35.0 W/K, not the suppressed 0.0. + assert abs(result.floor_w_per_k - 35.0) <= 0.1 + + 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-