mapper: disambiguate SY system-built from B basement wall (both share code 6)

RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT — a
WALL TYPE — but the gov-EPC basement heuristic hijacked it: Elmhurst
lodges both "SY System build" and "B Basement wall" as code 6, and the
API lodges basements as code 6 too, so a system-built wall was
mis-flagged `main_wall_is_basement` → wrong RdSAP §5.17 / Table 23
u_basement_wall/u_basement_floor overrides, and downstream the solid-wall
Recommendation Generator couldn't offer EWI/IWI on system-built walls.

System-built stays the wall type on its canonical code 6; the basement
signal moves OFF code 6 to a dedicated `is_basement` (SapAlternativeWall)
/ `wall_is_basement` (SapBuildingPart) Optional[bool] flag:
- Elmhurst: `_elmhurst_wall_is_basement` sets it from the distinct
  "SY"/"B" labels (False for SY, True for B, None otherwise).
- gov-EPC API: per-wall code 6 can't be told apart at lodging time, so
  `from_api_response` post-processes via `_clear_basement_flag_when_
  system_built` — when the cert addendum marks the dwelling system-built,
  the code-6 basement heuristic is cleared. A genuine basement (no
  addendum signal) keeps the code-6 fallback.
- `main_wall_is_basement` / `is_basement_wall` honour the flag when set,
  else fall back to the code-6 heuristic — so untouched API basements and
  the cert 000565 "B" cohort are unchanged.

`EpcPropertyData.system_build` is a derived property over the wall type:
the MAIN wall is system-built iff `wall_construction == 6` and it is not
flagged basement. System-built lives on `wall_construction`; the basement
attribute is separate.

Acceptance: a system-built main wall (Elmhurst SY, or API addendum
system_build) → wall_construction == 6, main_wall_is_basement is False,
system_build is True; a genuine basement main wall → main_wall_is_basement
is True, system_build is False. Full §4 suite green (2404 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 19:05:18 +00:00
parent 9a483b8711
commit bd25a3c774
4 changed files with 312 additions and 19 deletions

View file

@ -4359,3 +4359,24 @@ def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> Non
# Assert
assert code == 1
def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None:
# Arrange — "SY System build" and "B Basement wall" both map to SAP10
# wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit
# basement flag separates them: only "B" is a basement wall (drives
# RdSAP §5.17 u_basement_wall); "SY" is False so it routes through the
# normal system-built U-value table; any other code → None (the
# gov-EPC API code-6 heuristic still applies).
from datatypes.epc.domain.mapper import _elmhurst_wall_construction_int # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import _elmhurst_wall_is_basement # pyright: ignore[reportPrivateUsage]
# Act / Assert — system-built keeps code 6 but is NOT basement.
assert _elmhurst_wall_construction_int("SY System build") == 6
assert _elmhurst_wall_is_basement("SY System build") is False
# Genuine basement: code 6 AND flagged basement.
assert _elmhurst_wall_construction_int("B Basement wall") == 6
assert _elmhurst_wall_is_basement("B Basement wall") is True
# Other constructions defer to the API code-6 heuristic.
assert _elmhurst_wall_is_basement("CA Cavity") is None
assert _elmhurst_wall_is_basement("") is None

View file

@ -435,13 +435,24 @@ class SapAlternativeWall:
# Mirrors `SapBuildingPart.wall_thickness_mm` per the
# [[feedback-no-misleading-insulation-type]] convention.
wall_thickness_mm: Optional[int] = None
# Explicit basement determination. RdSAP10 `wall_construction == 6` is
# canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement
# heuristic hijacked it because Elmhurst lodges both "SY System build"
# and "B Basement wall" as code 6. When the source can tell them apart
# (the Elmhurst mapper, from the distinct "SY"/"B" codes) it sets this
# flag; None falls back to the gov-EPC API code-6 heuristic so the API
# path (basement lodged as integer 6, no flag) is unchanged.
is_basement: Optional[bool] = None
@property
def is_basement_wall(self) -> bool:
"""True iff this alt sub-area is the dwelling's basement wall —
identified by RdSAP10 wall_construction code = 6 (see module
constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23
applies a special U-value lookup to basement walls."""
"""True iff this alt sub-area is the dwelling's basement wall.
Honours the explicit `is_basement` flag when set; otherwise falls
back to the gov-EPC API basement sentinel `wall_construction == 6`
(`BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 applies
a special U-value lookup to basement walls."""
if self.is_basement is not None:
return self.is_basement
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
@ -514,12 +525,23 @@ class SapBuildingPart:
# The dwelling-wide `construction_age_band` does NOT govern curtain
# walls; this field decouples them per spec.
curtain_wall_age: Optional[str] = None
# Explicit basement determination for the primary wall. See
# `SapAlternativeWall.is_basement` — RdSAP10 code 6 is canonically
# SYSTEM-BUILT, so the Elmhurst mapper sets this flag from the distinct
# "SY"/"B" codes (False for system-built, True for basement); None
# preserves the gov-EPC API code-6 heuristic.
wall_is_basement: Optional[bool] = None
@property
def main_wall_is_basement(self) -> bool:
"""True iff this part's primary wall (not an alt sub-area) is the
basement wall happens when the whole part sits below grade.
Empirically 54 of 67k parts in the 2026 sweep; rare but real."""
Empirically 54 of 67k parts in the 2026 sweep; rare but real.
Honours the explicit `wall_is_basement` flag when set (so a
SYSTEM-BUILT wall, which shares code 6, is not mis-flagged);
otherwise falls back to the gov-EPC API code-6 heuristic."""
if self.wall_is_basement is not None:
return self.wall_is_basement
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
@property
@ -722,3 +744,25 @@ class EpcPropertyData:
solar_hw_collector_orientation: Optional[str] = None
solar_hw_collector_pitch_deg: Optional[int] = None
solar_hw_overshading: Optional[str] = None
@property
def system_build(self) -> Optional[bool]:
"""Whether the dwelling's MAIN wall is system-built.
System-built is a WALL TYPE: RdSAP10 `WALL_SYSTEM_BUILT == 6` on
the main wall (the U-value cascade table is keyed on that code).
It happens to share the integer with basement walls so a code-6
main wall is system-built only when it is NOT flagged as a
basement (`main_wall_is_basement`, the dedicated basement signal
the mapper sets from the distinct "SY"/"B" labels or the cert
addendum). Reading the wall type keeps the two concerns separate:
`wall_construction` carries the construction, the basement flag
carries the below-grade attribute. Returns None when there is no
MAIN building part (unknown)."""
for part in self.sap_building_parts:
if part.identifier is BuildingPartIdentifier.MAIN:
return (
part.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
and not part.main_wall_is_basement
)
return None

View file

@ -1,10 +1,12 @@
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 datatypes.epc.schema.helpers import from_dict
from datatypes.epc.domain.epc_property_data import (
BASEMENT_WALL_CONSTRUCTION_CODE,
Addendum,
BuildingPartIdentifier,
EnergyElement,
@ -1916,14 +1918,18 @@ class EpcPropertyDataMapper:
if schema == "RdSAP-Schema-21.0.1":
from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1
return EpcPropertyDataMapper.from_rdsap_schema_21_0_1(
from_dict(RdSapSchema21_0_1, data)
return _clear_basement_flag_when_system_built(
EpcPropertyDataMapper.from_rdsap_schema_21_0_1(
from_dict(RdSapSchema21_0_1, data)
)
)
if schema == "RdSAP-Schema-21.0.0":
from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0
return EpcPropertyDataMapper.from_rdsap_schema_21_0_0(
from_dict(RdSapSchema21_0_0, data)
return _clear_basement_flag_when_system_built(
EpcPropertyDataMapper.from_rdsap_schema_21_0_0(
from_dict(RdSapSchema21_0_0, data)
)
)
raise ValueError(f"Unsupported EPC schema: {schema!r}")
@ -1933,6 +1939,68 @@ class EpcPropertyDataMapper:
# ---------------------------------------------------------------------------
def _clear_basement_flag_when_system_built(
epc: EpcPropertyData,
) -> EpcPropertyData:
"""When the dwelling is system-built, a `wall_construction == 6` wall
is WALL_SYSTEM_BUILT, not a basement so the gov-EPC API code-6
basement heuristic must not fire for it. The API path can't tell the
two apart at the per-wall level (both lodge code 6), so once the
cert-level `system_build` flag is known we clear the basement signal
on every code-6 wall that hasn't been explicitly determined
(`wall_is_basement` / `is_basement` still None). No-op unless the
dwelling is system-built, so genuine basements (system_build absent /
False) keep the code-6 heuristic. Returns the same object when
nothing changes.
The Elmhurst path sets the per-wall flag directly from the distinct
"SY"/"B" labels, so it never reaches here (it routes through
`from_elmhurst_site_notes`, not `from_api_response`).
Keyed on the RAW cert `addendum.system_build` signal rather than the
derived `epc.system_build` property the property reads the wall
type AFTER this clears the basement flag, so using it here would be
circular."""
if epc.addendum is None or epc.addendum.system_build is not True:
return epc
def _clear_alt(alt: Optional[SapAlternativeWall]) -> Optional[SapAlternativeWall]:
if (
alt is not None
and alt.is_basement is None
and alt.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
):
return replace(alt, is_basement=False)
return alt
new_parts: List[SapBuildingPart] = []
changed = False
for part in epc.sap_building_parts:
new_alt_1 = _clear_alt(part.sap_alternative_wall_1)
new_alt_2 = _clear_alt(part.sap_alternative_wall_2)
clear_main = (
part.wall_is_basement is None
and part.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
)
if clear_main or new_alt_1 is not part.sap_alternative_wall_1 or (
new_alt_2 is not part.sap_alternative_wall_2
):
changed = True
new_parts.append(
replace(
part,
wall_is_basement=False if clear_main else part.wall_is_basement,
sap_alternative_wall_1=new_alt_1,
sap_alternative_wall_2=new_alt_2,
)
)
else:
new_parts.append(part)
if not changed:
return epc
return replace(epc, sap_building_parts=new_parts)
def _measurement_value(field: Any) -> float:
"""SAP measurements arrive as a `Measurement` (with `.value`), a raw dict
{'value': N, 'quantity': '...'} when `from_dict` didn't coerce, or a plain
@ -2172,16 +2240,16 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = {
"CA": 4, # Cavity
"TF": 5, # Timber frame
"TI": 5, # Timber frame (Elmhurst's alt-wall code; same SAP10 mapping)
"SY": 6, # System build
"B": 6, # Basement wall (cert 000565 Ext3+Ext4) — routes to the
# `BASEMENT_WALL_CONSTRUCTION_CODE=6` canonical signal so
# the cascade's `part.main_wall_is_basement` triggers the
# RdSAP 10 §5.17 / Table 23 `u_basement_wall` override
# (heat_transmission.py:640). Collides numerically with
# "SY" System build — the cascade's basement check
# precedes `u_wall(construction=6)` so SY would be
# silently mis-routed to u_basement_wall today; no cohort
# fixture exercises SY yet so the conflict is dormant.
"SY": 6, # System build — canonical RdSAP10 WALL_SYSTEM_BUILT=6.
"B": 6, # Basement wall (cert 000565 Ext3+Ext4). Numerically
# collides with "SY" System build on code 6, so the
# basement vs system-built distinction is carried by the
# explicit `is_basement` / `wall_is_basement` flag (set
# via `_elmhurst_wall_is_basement`) rather than the code:
# only "B" triggers the cascade's `main_wall_is_basement`
# → RdSAP 10 §5.17 / Table 23 `u_basement_wall` override.
# "SY" sets the flag False so it routes through the normal
# `u_wall(construction=6)` system-built table instead.
"CO": 7, # Cob
"PH": 8, # Park home
"CW": 9, # Curtain wall
@ -2263,6 +2331,32 @@ def _elmhurst_wall_construction_int(coded: str) -> Optional[int]:
return _ELMHURST_WALL_CODE_TO_SAP10[code]
# Elmhurst wall codes that both resolve to SAP10 wall_construction=6 but
# carry opposite basement meaning: "B" Basement wall vs "SY" System build
# (see `_ELMHURST_WALL_CODE_TO_SAP10`). RdSAP10 code 6 is canonically
# WALL_SYSTEM_BUILT; the explicit basement flag lets the cascade route a
# genuine basement to RdSAP §5.17 `u_basement_wall` without mis-flagging
# a system-built wall.
_ELMHURST_BASEMENT_WALL_CODE: Final[str] = "B"
_ELMHURST_SYSTEM_BUILT_WALL_CODE: Final[str] = "SY"
def _elmhurst_wall_is_basement(coded: str) -> Optional[bool]:
"""Disambiguate the SAP10 code-6 collision from the Elmhurst wall_type
string. Returns True for "B Basement wall", False for "SY System
build", and None for every other code (so the SapBuildingPart /
SapAlternativeWall properties fall back to the gov-EPC API code-6
heuristic unchanged for the API path). Empty lodging None."""
code = _leading_code(coded)
if not code:
return None
if code == _ELMHURST_BASEMENT_WALL_CODE:
return True
if code == _ELMHURST_SYSTEM_BUILT_WALL_CODE:
return False
return None
# Elmhurst Party Wall Type codes — distinct category-set from the Wall
# Type field; the codes describe construction class for `u_party_wall`
# (Table 4 / RdSAP §S.3.2) rather than a specific SAP10 wall-type. Maps
@ -3385,6 +3479,7 @@ def _map_elmhurst_building_part(
identifier=identifier,
construction_age_band=age_code,
wall_construction=_elmhurst_wall_construction_int(walls.wall_type),
wall_is_basement=_elmhurst_wall_is_basement(walls.wall_type),
wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation),
wall_thickness_measured=not walls.thickness_unknown,
party_wall_construction=_elmhurst_party_wall_construction_int(walls.party_wall_type),
@ -3463,6 +3558,7 @@ def _map_elmhurst_alternative_wall(
wall_thickness_measured="Y" if not a.thickness_unknown else "N",
wall_insulation_thickness=None,
wall_thickness_mm=measured_thickness_mm,
is_basement=_elmhurst_wall_is_basement(a.wall_type),
)

View file

@ -1222,6 +1222,138 @@ def test_sap_building_part_has_basement_detects_main_wall_and_alt_wall_codes() -
assert alt_is_basement.main_wall_is_basement is False # main is still wc=4
def test_explicit_wall_is_basement_flag_disambiguates_system_built_from_basement() -> None:
"""RdSAP10 `wall_construction == 6` is canonically SYSTEM-BUILT
(`WALL_SYSTEM_BUILT`), but the gov-EPC basement heuristic hijacked it
(Elmhurst lodges both "SY System build" and "B Basement wall" as
code 6). The explicit `wall_is_basement` flag set by the Elmhurst
mapper from the distinct "SY"/"B" codes disambiguates:
- flag True basement (drives §5.17 u_basement_wall)
- flag False system-built (drives the u_wall code-6 table)
- flag None fall back to the gov-EPC API code-6 heuristic
so the API path (which lodges basement as integer 6 with no flag) is
unchanged."""
from dataclasses import replace
# Arrange — three parts, all wall_construction=6, differing only in flag.
plain = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=6, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
system_built = replace(plain, wall_is_basement=False)
basement = replace(plain, wall_is_basement=True)
api_code_6 = replace(plain, wall_is_basement=None)
# Act / Assert
assert system_built.main_wall_is_basement is False
assert basement.main_wall_is_basement is True
assert api_code_6.main_wall_is_basement is True # API heuristic preserved
# Alt-wall mirror — same Optional disambiguation on SapAlternativeWall.
alt_system_built = SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N", wall_construction=6,
wall_insulation_type=4, wall_thickness_measured="Y", is_basement=False,
)
alt_basement = SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N", wall_construction=6,
wall_insulation_type=4, wall_thickness_measured="Y", is_basement=True,
)
alt_api_code_6 = SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N", wall_construction=6,
wall_insulation_type=4, wall_thickness_measured="Y",
)
assert alt_system_built.is_basement_wall is False
assert alt_basement.is_basement_wall is True
assert alt_api_code_6.is_basement_wall is True
def test_system_build_property_derives_from_main_wall_construction_type() -> None:
# Arrange — system-built is a WALL TYPE: RdSAP10 WALL_SYSTEM_BUILT=6
# on the MAIN wall. It shares the integer with basement, so a code-6
# main wall is system-built only when it is NOT flagged basement. The
# `system_build` property reads the wall type (wall_construction) + the
# dedicated basement flag — it does not need a separate dwelling-level
# field.
from dataclasses import replace
base_main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=6, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
system_built = make_minimal_sap10_epc(
sap_building_parts=[replace(base_main, wall_is_basement=False)],
)
basement = make_minimal_sap10_epc(
sap_building_parts=[replace(base_main, wall_is_basement=True)],
)
cavity = make_minimal_sap10_epc(
sap_building_parts=[replace(base_main, wall_construction=4)],
)
no_main = make_minimal_sap10_epc(sap_building_parts=[])
# Act / Assert — code 6 + not basement → system-built; code 6 + basement
# → not system-built; a non-6 wall type → not system-built; no main → None.
assert system_built.system_build is True
assert basement.system_build is False
assert cavity.system_build is False
assert no_main.system_build is None
def test_system_built_addendum_clears_basement_on_code_6_walls_api_path() -> None:
# Arrange — gov-EPC API system-built cert: the per-wall code 6 can't be
# told from a basement at lodging time, so once the cert addendum marks
# the dwelling system-built, `from_api_response` clears the code-6
# basement heuristic. A genuine basement (no addendum signal) keeps it.
from dataclasses import replace
from datatypes.epc.domain.epc_property_data import Addendum
from datatypes.epc.domain.mapper import _clear_basement_flag_when_system_built # pyright: ignore[reportPrivateUsage]
code_6_main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=6, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
system_built_cert = replace(
make_minimal_sap10_epc(sap_building_parts=[code_6_main]),
addendum=Addendum(system_build=True),
)
genuine_basement_cert = make_minimal_sap10_epc(sap_building_parts=[code_6_main])
# Act
cleared = _clear_basement_flag_when_system_built(system_built_cert)
untouched = _clear_basement_flag_when_system_built(genuine_basement_cert)
# Assert — system-built cert: code-6 main wall is no longer basement,
# and the wall-type-derived system_build reads True. Genuine basement
# (no addendum) is unchanged → still basement.
assert cleared.sap_building_parts[0].main_wall_is_basement is False
assert cleared.system_build is True
assert untouched.sap_building_parts[0].main_wall_is_basement is True
assert untouched.system_build is False
def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None:
"""RdSAP §5.17 / Table 23 governs basement-wall U-values: 0.7 for age
A-F, 0.6 for G-H, 0.45 for I, 0.35 for J, ..., 0.26 for M. The