mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
161 lines
6.6 KiB
Python
161 lines
6.6 KiB
Python
"""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.recommendation import Cost, MeasureOption, Recommendation
|
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
|
from repositories.product.product_repository import ProductRepository
|
|
|
|
_EXTERNAL_MEASURE_TYPE: Final[str] = "external_wall_insulation"
|
|
_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.
|
|
_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[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,),
|
|
}
|
|
|
|
_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: str
|
|
) -> 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
|
|
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)
|