diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py index dff55b669..32cee3437 100644 --- a/domain/modelling/generators/solid_wall_recommendation.py +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -26,6 +26,7 @@ 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.generators.wall_u_value_gate import wall_insulation_lowers_u from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from repositories.product.product_repository import ProductRepository @@ -158,6 +159,15 @@ def recommend_solid_wall( measure_type for measure_type in measure_types if _allowed(measure_type, restrictions, is_flat) + # Wall U-Value Gate (ADR-0051): withhold an Option that cannot lower + # the wall's derived U — e.g. an at-regs modern age band, where the + # as-built and insulated cascades resolve to the same value. + and wall_insulation_lowers_u( + main, + epc.country_code, + insulation_type=_INSULATION_TYPE[measure_type], + insulation_thickness_mm=_SOLID_WALL_INSULATION_MM, + ) ) if not allowed: return None diff --git a/domain/modelling/generators/wall_u_value_gate.py b/domain/modelling/generators/wall_u_value_gate.py new file mode 100644 index 000000000..fbbe3248c --- /dev/null +++ b/domain/modelling/generators/wall_u_value_gate.py @@ -0,0 +1,80 @@ +"""The Wall U-Value Gate (ADR-0051). + +A wall insulation Option is offered only when it would actually lower the main +wall's derived U-value: on a modern construction age band RdSAP's Table 6-10 +cascade assigns an as-built wall its at-regs U-value, and the post-measure +state resolves to the *same* value — the measure costs thousands for +0.00 SAP +(property 711795, PRD #1435 WS2: timber frame at age band L, 0.280 → 0.280). + +The comparison uses a deliberately thin `u_wall` parameter mapping (the +`envelope.py` style — construction, age band, insulation type/thickness, +documentary wall thickness), omitting the calculator's rich extras (surveyor +description, dry-lining, lodged `wall_u_value`). The omissions bias the gate +toward OFFERING — both sides are computed identically, so a genuine no-op still +compares equal — never toward suppressing a real measure. +""" + +from typing import Any, Final, Optional + +from datatypes.epc.domain.epc_property_data import SapBuildingPart +from domain.sap10_ml.rdsap_uvalues import WALL_UNKNOWN, Country, u_wall + +# Improvements below this are float noise, not a physical change. NOT a +# materiality threshold — how much improvement is worth the cost stays with the +# Optimiser (ADR-0016/0024). +_NOISE_FLOOR_W_PER_M2K: Final[float] = 0.01 + +# RdSAP `wall_insulation_type` 4 = as-built / assumed (no added insulation). +_WALL_INSULATION_NONE: Final[int] = 4 + + +def _int_or_none(value: Any) -> Optional[int]: + return value if isinstance(value, int) else None + + +def _parse_thickness_mm(value: Any) -> Optional[int]: + if value is None or isinstance(value, int): + return value + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None # "NI" / free text — thickness not known + + +def wall_insulation_lowers_u( + main: SapBuildingPart, + country_code: Optional[str], + *, + insulation_type: int, + insulation_thickness_mm: Optional[int] = None, +) -> bool: + """Whether insulating the main wall to ``insulation_type`` (+ optional + thickness) lowers its derived U-value by more than float noise — the Wall + U-Value Gate (ADR-0051).""" + country: Country = Country.from_code(country_code) + age_band = main.construction_age_band + construction: Optional[int] = _int_or_none(main.wall_construction) + if construction == WALL_UNKNOWN: + construction = None + wall_thickness_mm = main.wall_thickness_mm + current_type: Optional[int] = _int_or_none(main.wall_insulation_type) + current_u: float = u_wall( + country, + age_band, + construction, + _parse_thickness_mm(main.wall_insulation_thickness), + insulation_present=( + current_type is not None and current_type != _WALL_INSULATION_NONE + ), + wall_insulation_type=current_type, + wall_thickness_mm=wall_thickness_mm, + ) + post_u: float = u_wall( + country, + age_band, + construction, + insulation_thickness_mm, + insulation_present=True, + wall_insulation_type=insulation_type, + wall_thickness_mm=wall_thickness_mm, + ) + return current_u - post_u > _NOISE_FLOOR_W_PER_M2K