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"