mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Tighten the recommendation/plan vocabulary off generic str: MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType (also _GlazingTarget.measure_type, MeasureDependency.triggers -> frozenset[MeasureType], and the optimiser's chosen/required-type locals). Because MeasureType is a StrEnum the change is transparent to persistence (the `recommendation` varchar column), the optimiser group-by key, and every `== "solar_pv"`-style comparison — so pyright now enforces the enum at every construction site with no runtime behaviour change. The catalogue boundary stays str: ProductRepository.get(measure_type: str) and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so the fake product repos in tests need no edit. Test construction helpers coerce their str arg via MeasureType(...); direct constructions use members. Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3 xfail; pyright clean on production + tests (pre-existing moto + property- override-rowcount baselines untouched). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
7.1 KiB
Python
168 lines
7.1 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.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)
|