feat(modelling): flat gate drops EWI on solid-wall insulation

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 15:54:44 +00:00
parent 51ea4993a0
commit 0cef044503
2 changed files with 58 additions and 4 deletions

View file

@ -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

View file

@ -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(