From c89bec42cb9485a665b4290debe8c57349331d69 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 14:02:43 +0000 Subject: [PATCH] =?UTF-8?q?S0380.219:=20map=20API=20floor=5Fconstruction?= =?UTF-8?q?=20code=203=20=E2=86=92=20"Suspended,=20not=20timber"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A random 1000-cert Jan–May 2026 EPB-register sample surfaced 53 certs lodging sap_floor_dimensions.floor_construction=3, which raised UnmappedApiCode and blocked the whole cert from computing (~44 of the sample's mapper raises). RdSAP 10 field 3-1 "Floor construction" enumerates the lowest-floor construction as solid / suspended timber / suspended, not timber, and the spec's "Suspended not timber (structural infiltration 0)" makes the split load-bearing. Map code 3 to the canonical "Suspended, not timber" string (the same value the site-notes mapper already emits — cross-mapper parity): - u_floor takes the suspended BS EN ISO 13370 branch via the "Suspended" prefix (_floor_is_suspended_from_description), and - _has_suspended_timber_floor_per_spec's exact-match `!= "Suspended timber"` gate correctly does NOT fire, so the §5 (12) 0.1/0.2 floor-infiltration adjustment is skipped (structural infiltration 0) — exactly the spec rule for not-timber suspended. Validated: all 5 sampled code-3 certs now compute (e.g. 0340-2877-5570-2606-5965 floor_construction_type="Suspended, not timber", SAP cont 60.12 vs lodged 60). Confirmed against the cert's own global floor descriptions ("Suspended, …", floor_heat_loss=7). Code semantics established from the RdSAP 10 spec + the lodged certs' human-readable floor descriptions (the EPB /api/codes endpoint carries no floor_construction enum). §4 suite + schema-mapper tests green (the pre-existing test_total_floor_area failure is unrelated). mapper.py pyright unchanged at 32; new test suppresses reportPrivateUsage to keep net-zero new errors. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 13 +++++++ .../domain/tests/test_from_rdsap_schema.py | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 4f6abc04..c79bb76b 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2373,9 +2373,22 @@ def _api_party_wall_construction_int(value: Union[int, str, None]) -> Optional[i # distinguishes "Suspended" vs everything-else (the solid-branch is # the default fall-through), so the additional code maps to the same # "Solid" string as code 1. +# Code 3 = "suspended, not timber" (e.g. beam-and-block / suspended +# concrete). RdSAP 10 field 3-1 "Floor construction" enumerates the +# lowest-floor construction as solid / suspended timber / suspended, +# not timber. The timber/not-timber split is load-bearing: the spec's +# "Suspended not timber (structural infiltration 0)" means only +# "Suspended timber" triggers the §5 (12) 0.1/0.2 floor-infiltration +# adjustment (see `_has_suspended_timber_floor_per_spec`), while a +# not-timber suspended floor is infiltration 0. Mapping to the canonical +# "Suspended, not timber" string (also used by the site-notes mapper) +# takes the suspended U-value branch via the "Suspended" prefix yet +# correctly fails the exact-match timber gate. Observed on 53/1000 of a +# random 2026 API sample (was raising UnmappedApiCode, blocking the cert). _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, str] = { 1: "Solid", 2: "Suspended timber", + 3: "Suspended, not timber", 4: "Solid", } diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 58b9ed1a..c3d59891 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -795,3 +795,38 @@ class TestElmhurstGlazingTypeWrappedGap: # Assert assert code == 2 + + +class TestApiFloorConstructionCode: + """`_api_floor_construction_str` maps the GOV.UK API integer + floor_construction code to the description string the cascade's + `u_floor` + the §5 (12) infiltration rule read. RdSAP 10 field 3-1 + "Floor construction" enumerates the lowest-floor construction as one + of: solid / suspended timber / suspended, not timber. The spec's + "Suspended not timber (structural infiltration 0)" makes the + timber/not-timber split load-bearing: only "Suspended timber" + triggers the §5 (12) 0.1/0.2 floor-infiltration adjustment; + "suspended, not timber" is structural-infiltration 0.""" + + def test_code_3_maps_to_suspended_not_timber(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_construction_str(3) + + # Assert — suspended U-value branch fires (starts "Suspended"), + # but the exact-match "Suspended timber" (12) rule does NOT — + # per RdSAP 10 "suspended not timber (structural infiltration 0)". + # Same canonical string the site-notes mapper already uses. + assert result == "Suspended, not timber" + + def test_code_2_still_maps_to_suspended_timber(self) -> None: + # Arrange — regression guard: the timber code is unchanged. + from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_construction_str(2) + + # Assert + assert result == "Suspended timber"