From 0cef0445033af3af1b6eca65bc39e9469434b0c0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 15:54:44 +0000 Subject: [PATCH] feat(modelling): flat gate drops EWI on solid-wall insulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2d. A flat can take IWI (its own unit) but not EWI (whole-block coordination) — ADR-0019. _is_flat handles both ingestion representations: the Elmhurst name form ('Flat') and the API stringified RdSAP code ('2' = Flat per PROPERTY_TYPE_LOOKUP). Completes slice 2's eligibility surface. Co-Authored-By: Claude Opus 4.8 --- .../generators/solid_wall_recommendation.py | 25 +++++++++++-- .../modelling/test_elmhurst_cascade_pins.py | 37 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py index 00d12da0..f7af98fa 100644 --- a/domain/modelling/generators/solid_wall_recommendation.py +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -22,6 +22,7 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) +from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from domain.building_geometry import gross_heat_loss_wall_area from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation @@ -109,11 +110,26 @@ def _solid_wall_option( ) -def _allowed(measure_type: str, restrictions: PlanningRestrictions) -> bool: +def _is_flat(epc: EpcPropertyData) -> bool: + """Whether the dwelling is a flat. The Elmhurst path lodges the name + ("Flat"); the API path a stringified RdSAP code (`PROPERTY_TYPE_LOOKUP`, + where 2 = Flat) — handle both representations.""" + raw: str = (epc.property_type or "").strip() + if raw.lower() == "flat": + return True + if raw.isdigit(): + return PROPERTY_TYPE_LOOKUP.get(int(raw)) == "Flat" + return False + + +def _allowed( + measure_type: str, restrictions: PlanningRestrictions, is_flat: bool +) -> bool: """Whether a planning-gated Option survives (ADR-0019): EWI is removed by any - restriction; IWI only by a listed/heritage protection.""" + restriction or by the dwelling being a flat; IWI only by a listed/heritage + protection.""" if measure_type == _EXTERNAL_MEASURE_TYPE: - return not restrictions.blocks_external + return not (restrictions.blocks_external or is_flat) return not restrictions.blocks_internal @@ -141,10 +157,11 @@ def recommend_solid_wall( if not measure_types: return None + is_flat: bool = _is_flat(epc) allowed = tuple( measure_type for measure_type in measure_types - if _allowed(measure_type, restrictions) + if _allowed(measure_type, restrictions, is_flat) ) if not allowed: return None diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index cf142906..95a12f69 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -14,6 +14,7 @@ is a named generator/overlay/calculator gap to fix, never a tolerance to widen from __future__ import annotations +from dataclasses import replace from typing import Final from datatypes.epc.domain.epc_property_data import ( @@ -228,6 +229,42 @@ def test_listed_building_blocks_all_solid_wall_insulation() -> None: ) +def test_flat_drops_ewi_but_keeps_iwi() -> None: + # Arrange — a flat can take IWI to its own unit, but EWI needs whole-block + # coordination (ADR-0019). property_type "Flat" is the Elmhurst name form. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + flat: EpcPropertyData = replace(before, property_type="Flat") + + # Act + recommendation: Recommendation | None = recommend_solid_wall(flat, _AnyProduct()) + + # Assert + assert recommendation is not None + assert {option.measure_type for option in recommendation.options} == { + "internal_wall_insulation" + } + + +def test_flat_detected_from_api_property_type_code() -> None: + # Arrange — the API path lodges property_type as a stringified code + # ("2" = Flat per PROPERTY_TYPE_LOOKUP), not the name. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + flat: EpcPropertyData = replace(before, property_type="2") + + # Act + recommendation: Recommendation | None = recommend_solid_wall(flat, _AnyProduct()) + + # Assert — same gate fires regardless of representation. + assert recommendation is not None + assert {option.measure_type for option in recommendation.options} == { + "internal_wall_insulation" + } + + def test_cavity_wall_gets_no_solid_wall_recommendation() -> None: # Arrange — a cavity wall is handled by recommend_cavity_wall, never here. before: EpcPropertyData = parse_recommendation_summary(