fix(floor): floor_heat_loss=3 → above partially heated space, U=0.7 (RdSAP §3.12)

The API `floor_heat_loss` code is authoritative — confirmed by joining each
single-BP cert's code to its independent `floors[].description` (which the
gov register publishes alongside the code):

  code 1 ↔ "To external air"        (exposed,        9/9)
  code 2 ↔ "To unheated space"      (semi-exposed,   6/6)
  code 3 ↔ "(other premises below)" (partially htd,  9/9)
  code 6 ↔ "(another dwelling below)" (party,      176/176)
  code 7 ↔ "Solid"/"Suspended …"    (ground,     all)

Code 3 was mis-mapped to "To unheated space" (semi-exposed) and, on
mid-/top-floor flats, had its floor area zeroed entirely by the
dwelling-level exposure heuristic. RdSAP 10 §3.12 (PDF p.25) classes a
flat's floor over non-domestic "other premises … heated, but at different
times" as "above a partially heated space" → the §5.14 (PDF p.47) constant
U=0.7 W/m²K — distinct from semi-exposed (Table 20) and party (no loss).

Fix: the mapper sets `is_above_partially_heated_space` on the floor=0
dimension for code 3 (string → "(other premises below)" for fidelity), and
the heat-transmission step lets that per-BP lodgement override the flat
suppression upward (mirroring the existing exposed / "another dwelling
below" overrides). The cascade already routes is_above_partial → U=0.7.

Re-pins golden cert 7536-3827: its Ext2 (bp3) lodges code 3, but the cert's
lossy `floors[]` summary dropped that description, so a prior agent guessed
"code 3 = ground" (U=1.12) and concluded the residual was an irreducible
"register-rounding" artifact. It was this bug: Ext2 floor U 1.12 → 0.70,
PE -6.1952 → -5.6414, CO2 -0.1639 → -0.1492 (both toward 0), SAP unchanged.

Eval: 909 computed, 45.1% → 45.3% within 0.5, mean|err| 1.702 → 1.659,
<1.0 59.5% → 60.2%. 13 code-3 certs improve (0380 +3.71 → -0.63, 0350
+7.82 → +0.83, 2610 +7.47 → -1.29); the few that overshoot were already
failing and carry independent fabric bugs (9763's walls = 8 W/K for 60 m²).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-07 22:25:04 +00:00
parent 75ef250ec8
commit 8741fbdfac
5 changed files with 133 additions and 16 deletions

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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 "

View file

@ -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-