feat(modelling): system-built walls take EWI+IWI (blocked on basement-code fix)

System-built (precast/no-fines concrete) takes both solid-wall Options like
solid brick (ADR-0019), keyed on `wall_construction == 6` (WALL_SYSTEM_BUILT,
Elmhurst `SY`). A basement-suitability guard (`main_wall_is_basement`) is added
since a below-ground basement wall is never EWI/IWI-suitable.

This is currently inert: `B Basement wall` also maps to 6 (mapper.py:2100) and
`main_wall_is_basement` is derived as `wall_construction == 6`, so every code-6
wall reads as basement and is guarded out — the live cohort is unchanged. The
system-built EWI/IWI cascade pin is committed as a strict-xfail tripwire that
flips green the moment the calculator disambiguates system-built from basement
(MAIN wall_construction==6 with main_wall_is_basement False). `wall_construction
== 8` is Park home, not system-built — not keyed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 18:26:08 +00:00
parent 3f5b60051c
commit ea4534f3af
7 changed files with 56 additions and 3 deletions

View file

@ -4,7 +4,9 @@ The solid-wall Recommendation Generator must decide, per Property, which wall-in
## Decision
**By construction** (keyed on the `wall_construction` code, which is consistent across the API and Elmhurst paths for codes 1-5; the wall *description* is empty on the Elmhurst ingestion path so it can't be the primary signal — it's a fallback for the ambiguous higher codes (system-built 6-vs-8, cob 7) and for refining the as-built trigger on the API path):
**By construction** (keyed on the `wall_construction` code, which is consistent across the API and Elmhurst paths for codes 1-5; the wall *description* is empty on the Elmhurst ingestion path so it can't be the primary signal — it's a fallback for the ambiguous codes (cob 7) and for refining the as-built trigger on the API path).
**System-built** is keyed on `wall_construction == 6` (`WALL_SYSTEM_BUILT`; the Elmhurst `SY System build` label). This code is currently *overloaded*: `B Basement wall` also maps to 6 (`BASEMENT_WALL_CONSTRUCTION_CODE`, `mapper.py:2100`), so the generator additionally guards on `main_wall_is_basement` — a basement wall is never solid-wall-insulation-suitable and is excluded regardless of construction. Because `main_wall_is_basement` is presently derived as `wall_construction == 6`, *every* code-6 wall is treated as basement today, so the system-built branch is inert until the calculator disambiguates system-built from basement (target: MAIN `wall_construction == 6` with `main_wall_is_basement` False). The strict-xfail pin `test_system_built_generator_offers_ewi_and_iwi_each_pinning_its_after` is the tripwire for that fix. Note `wall_construction == 8` is **Park home** (`PH`) on the Elmhurst path, *not* system-built — do not key system-built on 8.
| Construction | Cavity fill | IWI | EWI |
|---|---|---|---|

View file

@ -34,6 +34,12 @@ _INTERNAL_MEASURE_TYPE: Final[str] = "internal_wall_insulation"
# RdSAP `wall_construction` codes (consistent across paths for 1-5).
_WALL_SOLID_BRICK: Final[int] = 3
_WALL_TIMBER_FRAME: Final[int] = 5
# System-built (precast/no-fines concrete): `WALL_SYSTEM_BUILT` in
# rdsap_uvalues. NB this is the Elmhurst code (`SY`); the *basement-wall* signal
# also lodges as 6 today (`BASEMENT_WALL_CONSTRUCTION_CODE`), so system-built is
# disambiguated from basement by `main_wall_is_basement` below — a basement wall
# is never solid-wall-insulation-suitable regardless.
_WALL_SYSTEM_BUILT: Final[int] = 6
# `wall_insulation_type`: 4 = as-built / assumed (uninsulated) — the trigger.
_WALL_AS_BUILT: Final[int] = 4
# `wall_insulation_type` the overlay lodges: 1 = external, 3 = internal.
@ -44,10 +50,11 @@ _WALL_INSULATION_INTERNAL: Final[int] = 3
_SOLID_WALL_INSULATION_MM: Final[int] = 100
# Which solid-wall Options each construction can take (ADR-0019). Solid brick
# takes both; timber-frame takes IWI only (EWI not constructable). System-built
# and the breathable cob/stone exclusions land in a later slice.
# and system-built take both; timber-frame takes IWI only (EWI not
# constructable). The breathable cob/stone exclusions take neither (never keyed).
_CONSTRUCTABLE_OPTIONS: Final[dict[int, tuple[str, ...]]] = {
_WALL_SOLID_BRICK: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE),
_WALL_SYSTEM_BUILT: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE),
_WALL_TIMBER_FRAME: (_INTERNAL_MEASURE_TYPE,),
}
@ -129,6 +136,9 @@ def recommend_solid_wall(
if main.wall_insulation_type != _WALL_AS_BUILT:
return None
if main.main_wall_is_basement:
return None # a (below-ground) basement wall is never EWI/IWI-suitable
construction: object = main.wall_construction
if not isinstance(construction, int):
return None # a free-text site-notes construction is not a code we key on

View file

@ -17,6 +17,8 @@ from __future__ import annotations
from dataclasses import replace
from typing import Final
import pytest
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
@ -173,6 +175,45 @@ def test_solid_brick_generator_offers_ewi_and_iwi_each_pinning_its_after() -> No
)
@pytest.mark.xfail(
strict=True,
reason="Blocked on the calculator-side wall_construction=6 collision: "
"Elmhurst 'SY System build' and 'B Basement wall' both map to 6 "
"(mapper.py:2100), so `main_wall_is_basement` is wrongly True for "
"system-built and the generator's basement guard suppresses it. Flips "
"green once system-built is disambiguated from basement (MAIN "
"wall_construction==6 with main_wall_is_basement False).",
)
def test_system_built_generator_offers_ewi_and_iwi_each_pinning_its_after() -> None:
# Arrange — system-built (precast concrete) takes both Options like solid
# brick (ADR-0019): one uninsulated before, two re-lodged afters.
before: EpcPropertyData = parse_recommendation_summary(
"system_built_ewi_001431_before.pdf"
)
ewi_after: EpcPropertyData = parse_recommendation_summary(
"system_built_ewi_001431_after.pdf"
)
iwi_after: EpcPropertyData = parse_recommendation_summary(
"system_built_iwi_001431_after.pdf"
)
# Act
recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct())
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — both Options offered, each reproducing its own re-lodged after.
assert set(options) == {"external_wall_insulation", "internal_wall_insulation"}
_assert_overlay_reproduces_after(
before, ewi_after, options["external_wall_insulation"].overlay
)
_assert_overlay_reproduces_after(
before, iwi_after, options["internal_wall_insulation"].overlay
)
def test_timber_frame_generator_offers_iwi_only_pinning_its_after() -> None:
# Arrange — timber frame takes IWI but EWI is not constructable (ADR-0019).
before: EpcPropertyData = parse_recommendation_summary(