mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
a97ff60b01
commit
aab75cf902
4 changed files with 120 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue