fix(walls): reconcile gov-API wall_construction enum with the calc code-space

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 10:55:19 +00:00
parent a97ff60b01
commit aab75cf902
4 changed files with 120 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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