From aab75cf902231f6c3c1123a3e135f6ffdb5c4d32 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 10:55:19 +0000 Subject: [PATCH] fix(walls): reconcile gov-API wall_construction enum with the calc code-space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov-EPC API `wall_construction` enum diverges from the calculator's internal WALL_* code-space (confirmed by the description-vs-code audit across the corpus): API 1-5 align (granite/sandstone/solid-brick/cavity/timber), but API 6=basement, 8=system built, 9=cob — whereas the calc constants are WALL_SYSTEM_BUILT=6, WALL_COB=7, WALL_PARK_HOME=8, WALL_CURTAIN=9. Codes 8 and 9 therefore fell OUT of u_wall's `known_types` and resolved only via the `walls[].description` fallback, with two failure modes: - System built (API 8): a cert lodging no description silently defaulted to cavity (1.5) instead of the system-built U (RdSAP 10 Table 6, e.g. band E as-built 1.7). Latent in the corpus (all 43 carry a description) but a silent mis-bill waiting to happen. - Cob (API 9): a LIVE bug — calc WALL_CURTAIN=9 (set by the Summary path's "CW" mapping, paired with a curtain_wall_age) intercepts code 9 in the `construction == WALL_CURTAIN` branch, billing the cob wall at the curtain default 2.0 regardless of description. Fix, split by where each can be disambiguated safely: - System built: `u_wall` gains `_GOV_API_WALL_CODE_TO_TYPE = {8: WALL_ SYSTEM_BUILT}`, resolving code 8 directly (calc WALL_PARK_HOME=8 is never dispatched, so no collision; gov 6=basement is left to the basement machinery — cannot remap 8→6). - Cob: translated at the API mapper (`_api_wall_construction_code`, 9 → WALL_COB=7) where the source is unambiguously the gov enum — the gov API has no curtain code, so an API 9 is always cob. Applied to main + alt walls across the from_rdsap_schema_* builders. The Summary path's "CW"→9 curtain mapping is untouched. Worksheet harness UNAFFECTED (47/47, 0 divergers — Summary path unchanged). API gauge 65.1% -> 65.3% within-0.5 (mean|err| 1.075 -> 1.059): the n=1 cob cert now computes cob instead of curtain. 3 AAA tests (u_wall system-built without description; mapper cob 9->7; aligned/system/basement pass-through). pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 51 ++++++++++++++----- .../domain/tests/test_from_rdsap_schema.py | 30 +++++++++++ domain/sap10_ml/rdsap_uvalues.py | 28 ++++++++++ domain/sap10_ml/tests/test_rdsap_uvalues.py | 23 +++++++++ 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 1512ea44..f87c04d5 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2,7 +2,7 @@ import re from dataclasses import replace from datetime import date from decimal import ROUND_HALF_UP, Decimal -from typing import Any, Dict, Final, List, Optional, Sequence, Union, cast +from typing import Any, Dict, Final, List, Optional, Sequence, TypeVar, Union, cast from datatypes.epc.schema.helpers import from_dict from datatypes.epc.domain.epc_property_data import ( @@ -539,7 +539,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -691,7 +691,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -830,7 +830,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -995,7 +995,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1177,7 +1177,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1402,7 +1402,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1467,7 +1467,7 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_1.wall_area, wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, - wall_construction=bp.sap_alternative_wall_1.wall_construction, + wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction), wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, @@ -1480,7 +1480,7 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_2.wall_area, wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, - wall_construction=bp.sap_alternative_wall_2.wall_construction, + wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction), wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, @@ -1693,7 +1693,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1758,7 +1758,7 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_1.wall_area, wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, - wall_construction=bp.sap_alternative_wall_1.wall_construction, + wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction), wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, @@ -1771,7 +1771,7 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_2.wall_area, wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, - wall_construction=bp.sap_alternative_wall_2.wall_construction, + wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction), wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, @@ -2294,6 +2294,33 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = { } +_WallConstructionCode = TypeVar("_WallConstructionCode") + + +def _api_wall_construction_code( + code: _WallConstructionCode, +) -> _WallConstructionCode: + """Translate the gov-EPC API `wall_construction` enum to the + calculator's WALL_* code-space where the two DIVERGE. + + The gov enum (confirmed by the description-vs-code audit) is + 1=granite, 2=sandstone, 3=solid brick, 4=cavity, 5=timber frame (all + ALIGN with the WALL_* constants), 6=basement, 8=system built, 9=cob. + Only code 9 needs remapping HERE: it collides with the calculator's + `WALL_CURTAIN`=9 (which the Summary path's "CW" mapping legitimately + uses, paired with a `curtain_wall_age`). The gov API has no curtain + code, so an API `wall_construction` of 9 is unambiguously cob → remap to + `WALL_COB`=7 at this boundary, before it can hit the curtain dispatch in + `u_wall`. Gov 8 (system built) is left as-is — `u_wall` resolves it and + calc `WALL_PARK_HOME`=8 is never dispatched; gov 6 (basement) is left to + the basement machinery. Non-int values pass through unchanged; the input + type is preserved so each call site's typed `wall_construction` field + stays satisfied.""" + if code == 9: + return cast(_WallConstructionCode, 7) + return code + + # Elmhurst wall-insulation-type codes mapped to the SAP10 integer enum # documented at domain.sap10_ml.rdsap_uvalues.WALL_INSULATION_FILLED_CAVITY. # Two-letter dual codes ("FE", "FI") encode a cavity-wall that received a diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index b882a4c0..793c64db 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -704,6 +704,36 @@ class TestFromRdSapSchema21_0_1: # --------------------------------------------------------------------------- +class TestApiWallConstructionCode: + """The gov-EPC API `wall_construction` enum diverges from the + calculator's WALL_* code-space at code 9: gov 9 = "Cob" but calc + `WALL_CURTAIN` = 9 (set by the Summary path's "CW" mapping). The gov + API has no curtain code, so an API code 9 must be remapped to + `WALL_COB` = 7 before it reaches `u_wall`'s curtain dispatch (which + would otherwise bill the wall at the curtain default 2.0 W/m²K).""" + + def test_gov_api_cob_code_9_remaps_to_wall_cob_7(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_wall_construction_code(9) + + # Assert — 9 (gov cob) → 7 (WALL_COB), dodging WALL_CURTAIN=9. + assert result == 7 + + def test_aligned_and_system_built_codes_pass_through_unchanged(self) -> None: + # Arrange — codes 1-5 already align; gov 8 (system built) is left + # for u_wall to resolve (calc WALL_PARK_HOME=8 is never dispatched); + # gov 6 (basement) is left to the basement machinery. + from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage] + + # Act / Assert + for code in (1, 2, 3, 4, 5, 6, 8): + assert _api_wall_construction_code(code) == code + assert _api_wall_construction_code(None) is None + + class TestApiResolveWallInsulationThickness: """`wall_insulation_thickness == "measured"` resolves to the separate `wall_insulation_thickness_measured` field (previously dropped by diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 1f37b222..ea1188db 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -459,6 +459,29 @@ _DEFAULT_WALL_BY_AGE: Final[dict[str, int]] = { "L": WALL_CAVITY, "M": WALL_CAVITY, } +# Gov-EPC API `wall_construction` codes that DIVERGE from the calculator's +# internal WALL_* code-space. The gov enum (confirmed by the description-vs- +# code audit across the corpus) is 1=granite, 2=sandstone, 3=solid brick, +# 4=cavity, 5=timber frame (all ALIGN with the WALL_* constants), but +# 6=basement, 8=system built, 9=cob — whereas the calc constants are +# WALL_SYSTEM_BUILT=6, WALL_COB=7, WALL_PARK_HOME=8, WALL_CURTAIN=9. +# +# Code 8 (system built) falls OUT of `known_types` and previously resolved +# only via the `walls[].description` fallback; a cert lodging no description +# silently defaulted to cavity. Translate it here so the int code alone +# resolves. `WALL_PARK_HOME` (calc 8) is never dispatched, so reusing API +# code 8 for system built is collision-free at this layer. +# +# Code 9 (cob) is NOT handled here: calc `WALL_CURTAIN`=9 (set by the +# Summary path's "CW" mapping, with a `curtain_wall_age`) intercepts it in +# the `construction == WALL_CURTAIN` branch above. The gov-API cob code is +# therefore translated to `WALL_COB` upstream in the API mapper (where the +# source is unambiguously the gov enum), so it never reaches this collision. +# Code 6 (basement) is left to the mapper's basement machinery. +_GOV_API_WALL_CODE_TO_TYPE: Final[dict[int, int]] = { + 8: WALL_SYSTEM_BUILT, # gov API "System built" +} + # Surveyor-text -> wall-construction code, evaluated in priority order so that # "sandstone" beats "stone", "solid brick" beats "brick", etc. Used only as a @@ -585,6 +608,11 @@ def u_wall( } if construction in known_types: wall_type = construction + elif construction in _GOV_API_WALL_CODE_TO_TYPE: + # A gov-API construction code (system built / cob) that diverges from + # the WALL_* code-space — resolve it directly, independent of the + # surveyor description (RdSAP 10 §5.7 Table 6 rows still apply). + wall_type = _GOV_API_WALL_CODE_TO_TYPE[construction] else: wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY) # RdSAP 10 §5.7 Table 13 + §5.8 (PDF p.41-42) — uninsulated solid diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 5783af45..1018ce30 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -451,6 +451,29 @@ def test_u_wall_falls_back_to_age_band_default_when_construction_unknown() -> No assert result == pytest.approx(0.60, abs=0.001) +def test_u_wall_gov_api_system_built_code_8_resolves_without_description() -> None: + # Arrange — the gov-EPC API `wall_construction` enum diverges from the + # calculator's internal WALL_* code-space: API 8 = "System built" (calc + # WALL_SYSTEM_BUILT = 6; calc 8 = park home). The 43-cert system-built + # cohort currently resolves only via the `walls[].description` fallback; + # with no description, code 8 silently defaulted to cavity (1.5) instead + # of the system-built U (band E as-built = 1.7). + + # Act — code 8, NO description. + result = u_wall( + country=Country.ENG, age_band="E", construction=8, + insulation_thickness_mm=0, description=None, + ) + reference = u_wall( + country=Country.ENG, age_band="E", construction=WALL_SYSTEM_BUILT, + insulation_thickness_mm=0, + ) + + # Assert — code 8 is system-built (1.7), not the cavity default (1.5). + assert abs(result - reference) <= 1e-9 + assert abs(result - 1.7) <= 1e-9 + + def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> None: # Arrange — no signal at all.