S0380.219: map API floor_construction code 3 → "Suspended, not timber"

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 14:02:43 +00:00
parent d3def1e254
commit c89bec42cb
2 changed files with 48 additions and 0 deletions

View file

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

View file

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