"""The solid-wall Recommendation Generator (IWI / EWI). Detects an uninsulated *solid* (non-cavity) main wall and emits one "Main wall" Recommendation carrying the constructable solid-wall insulation Options — External (EWI) and/or Internal (IWI) — as mutually-exclusive Measure Options the Optimiser chooses between (ADR-0019). A cavity wall is handled by `recommend_cavity_wall`, never here. Wall material is keyed on the RdSAP `wall_construction` code (codes 1-5 are consistent across the API and Elmhurst ingestion paths; the wall *description* is empty on the Elmhurst path, so it can't be the primary signal — it is a fallback for the ambiguous higher codes, handled in a later slice). The trigger is the as-built/uninsulated `wall_insulation_type`, mirroring the cavity generator. Detection + pricing only; impact is produced later by scoring (ADR-0016). """ from typing import Final, Optional 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.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from repositories.product.product_repository import ProductRepository _EXTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.EXTERNAL_WALL_INSULATION _INTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.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 # Park home (`PH`, the Elmhurst code-8 wall) — NOT system-built (ADR-0019: "do # not key system-built on 8"). A park home's wall is a proprietary panel system # our EWI/IWI model doesn't represent, so it is never solid-wall-suitable. _WALL_PARK_HOME: Final[int] = 8 # `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. _WALL_INSULATION_EXTERNAL: Final[int] = 1 _WALL_INSULATION_INTERNAL: Final[int] = 3 # Recommended solid-wall insulation depth (mm); the calculator's λ default # (0.04 W/m·K) matches Elmhurst's lodged thermal conductivity. _SOLID_WALL_INSULATION_MM: Final[int] = 100 # Which solid-wall Options each construction can take (ADR-0019). Solid brick # 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[MeasureType, ...]]] = { _WALL_SOLID_BRICK: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE), _WALL_SYSTEM_BUILT: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE), _WALL_TIMBER_FRAME: (_INTERNAL_MEASURE_TYPE,), } _INSULATION_TYPE: Final[dict[str, int]] = { _EXTERNAL_MEASURE_TYPE: _WALL_INSULATION_EXTERNAL, _INTERNAL_MEASURE_TYPE: _WALL_INSULATION_INTERNAL, } _DESCRIPTION: Final[dict[str, str]] = { _EXTERNAL_MEASURE_TYPE: "External wall insulation (insulate the wall externally)", _INTERNAL_MEASURE_TYPE: "Internal wall insulation (insulate the wall internally)", } def _solid_wall_option( epc: EpcPropertyData, products: ProductRepository, measure_type: MeasureType ) -> MeasureOption: """Build one solid-wall Measure Option: its insulation overlay (100 mm at the External/Internal `wall_insulation_type`) priced at the heat-loss wall area.""" product = products.get(measure_type) wall_area: float = gross_heat_loss_wall_area(epc, BuildingPartIdentifier.MAIN) cost = Cost( total=wall_area * product.unit_cost_per_m2, contingency_rate=product.contingency_rate, ) return MeasureOption( measure_type=measure_type, description=_DESCRIPTION[measure_type], overlay=EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay( wall_insulation_type=_INSULATION_TYPE[measure_type], wall_insulation_thickness=_SOLID_WALL_INSULATION_MM, ) } ), cost=cost, material_id=product.id, ) 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 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 or is_flat) return not restrictions.blocks_internal def recommend_solid_wall( epc: EpcPropertyData, products: ProductRepository, restrictions: PlanningRestrictions = PlanningRestrictions(), ) -> Optional[Recommendation]: """Return a solid-wall insulation Recommendation for an uninsulated, suitable main wall — its constructable EWI/IWI Options, minus any the Property's planning protections forbid — else None.""" main = next( part for part in epc.sap_building_parts if part.identifier is BuildingPartIdentifier.MAIN ) 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 if construction == _WALL_PARK_HOME: return None # park home (code 8) — proprietary panel, never EWI/IWI measure_types = _CONSTRUCTABLE_OPTIONS.get(construction) 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, is_flat) ) if not allowed: return None options = tuple( _solid_wall_option(epc, products, measure_type) for measure_type in allowed ) return Recommendation(surface="Main wall", options=options)