diff --git a/docs/adr/0019-wall-insulation-eligibility.md b/docs/adr/0019-wall-insulation-eligibility.md index 1b46f165..937ce79f 100644 --- a/docs/adr/0019-wall-insulation-eligibility.md +++ b/docs/adr/0019-wall-insulation-eligibility.md @@ -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 | |---|---|---|---| diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py index 9da36bb1..9d4a67e0 100644 --- a/domain/modelling/generators/solid_wall_recommendation.py +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -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 diff --git a/tests/domain/modelling/fixtures/system_built_ewi_001431_after.pdf b/tests/domain/modelling/fixtures/system_built_ewi_001431_after.pdf new file mode 100644 index 00000000..1b76e032 Binary files /dev/null and b/tests/domain/modelling/fixtures/system_built_ewi_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/system_built_ewi_001431_before.pdf b/tests/domain/modelling/fixtures/system_built_ewi_001431_before.pdf new file mode 100644 index 00000000..0e4830b1 Binary files /dev/null and b/tests/domain/modelling/fixtures/system_built_ewi_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/system_built_iwi_001431_after.pdf b/tests/domain/modelling/fixtures/system_built_iwi_001431_after.pdf new file mode 100644 index 00000000..c943c608 Binary files /dev/null and b/tests/domain/modelling/fixtures/system_built_iwi_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/system_built_iwi_001431_before.pdf b/tests/domain/modelling/fixtures/system_built_iwi_001431_before.pdf new file mode 100644 index 00000000..0e4830b1 Binary files /dev/null and b/tests/domain/modelling/fixtures/system_built_iwi_001431_before.pdf differ diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 731e352d..97fccc84 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -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(