fix(mapper): floor_heat_loss code 8 → no floor heat loss (extension over heated space)

API floor_heat_loss=8 is observed on EXTENSION building parts whose
floor sits over a heated space within the SAME dwelling (an upper-storey
extension over a heated room). RdSAP 10 §3 gives an internal floor
between heated storeys no floor heat loss — mechanically identical to a
code-6 party floor. `_api_floor_type_str` had no entry for 8, raising
UnmappedApiCode and blocking certs 0370-2254-6520-2426-5971 and
0997-1206-9806-0715-2904.

Map code 8 to the code-6 no-heat-loss string "(another dwelling below)"
(consumed by heat_transmission's party-floor suppression; != "Ground
floor" so the §5 (12) suspended-timber rule stays inert). Empirically
confirmed against both certs: the no-heat-loss treatment lands them
within 0.5 of lodged (0370-2254 68.92 vs 69; 0997-1206 40.68 vs 41),
whereas Ground-floor / unheated / external mappings miss 0997 by ~4 SAP.

Eval computed 906→908. Regression green (only the pre-existing
test_total_floor_area fails); pyright net-zero (38=38).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-07 18:54:00 +00:00
parent f40485887d
commit 1c5675a063
2 changed files with 37 additions and 3 deletions

View file

@ -2647,16 +2647,32 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]:
# the §5 (12) suspended-timber rule stays
# inert (short-circuits exactly as None did).
# 7 = "Ground floor" — typical ground-floor heat loss
# 8 = "(another dwelling below)" — observed on EXTENSION building parts
# whose floor sits over a heated space
# within the SAME dwelling (an upper-storey
# extension over a heated room). RdSAP 10 §3
# gives an internal floor between heated
# storeys no floor heat loss — mechanically
# identical to a code-6 party floor, so it
# reuses that suppression string (consumed
# by heat_transmission's party-floor
# override; != "Ground floor" so §5 (12)
# stays inert). Empirically confirmed: both
# code-8 certs land within 0.5 of lodged
# (0370-2254 68.9 vs 69; 0997-1206 40.7 vs
# 41), while Ground-floor / unheated /
# external mappings miss 0997 by ~4 SAP.
#
# Codes 4/5/8+ are not yet observed in any fixture; the strict-raise
# path catches them at the extraction boundary so the next cert forces
# an explicit mapping decision.
# Codes 4/5 are not yet observed in any fixture; the strict-raise path
# catches them at the extraction boundary so the next cert forces an
# explicit mapping decision.
_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, Optional[str]] = {
1: "To external air",
2: "To unheated space",
3: "To unheated space",
6: "(another dwelling below)",
7: "Ground floor",
8: "(another dwelling below)",
}

View file

@ -914,6 +914,24 @@ class TestApiFloorTypeCode:
# Act / Assert
assert _api_floor_type_str(7) == "Ground floor"
def test_code_8_maps_to_no_floor_heat_loss(self) -> None:
# Arrange — code 8 is observed on EXTENSION building parts whose
# floor sits over a heated space within the same dwelling (an
# upper-storey extension over a heated room). RdSAP 10 §3 treats an
# internal floor between heated storeys as no floor heat loss —
# mechanically identical to a code-6 party floor. Empirically
# confirmed: routing code 8 to the no-heat-loss treatment lands
# both code-8 certs within 0.5 of lodged (0370-2254 68.9 vs 69;
# 0997-1206 40.7 vs 41), whereas Ground-floor / unheated / external
# mappings miss 0997 by ~4 SAP. Reuses code 6's suppression string
# (consumed by heat_transmission's party-floor override); it is
# != "Ground floor", so the §5 (12) suspended-timber rule stays
# inert. Pre-this, code 8 raised UnmappedApiCode, blocking the cert.
from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage]
# Act / Assert — no-heat-loss signal (not None, not "Ground floor").
assert _api_floor_type_str(8) == "(another dwelling below)"
class TestApiFloorConstructionCode:
"""`_api_floor_construction_str` maps the GOV.UK API integer