diff --git a/domain/modelling/generators/floor_recommendation.py b/domain/modelling/generators/floor_recommendation.py index 9e3815ba5..386c2fd76 100644 --- a/domain/modelling/generators/floor_recommendation.py +++ b/domain/modelling/generators/floor_recommendation.py @@ -15,6 +15,7 @@ from datatypes.epc.domain.epc_property_data import ( SapBuildingPart, ) from domain.building_geometry import ground_floor_area +from domain.modelling.generators.floor_u_value_gate import floor_insulation_lowers_u from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation @@ -69,6 +70,16 @@ def recommend_floor_insulation( if measure_type is None: return None + # Floor U-Value Gate (ADR-0051, floor arm): withhold the Option when it + # cannot lower the floor's derived U — e.g. an at-regs modern age band, + # where the as-built and insulated cascades resolve to the same value. + if not floor_insulation_lowers_u( + main, + epc.country_code, + insulation_thickness_mm=_RECOMMENDED_FLOOR_THICKNESS_MM, + ): + return None + product = products.get(measure_type) area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN) cost = Cost( diff --git a/domain/modelling/generators/floor_u_value_gate.py b/domain/modelling/generators/floor_u_value_gate.py new file mode 100644 index 000000000..f7d2a5fb2 --- /dev/null +++ b/domain/modelling/generators/floor_u_value_gate.py @@ -0,0 +1,76 @@ +"""The Floor U-Value Gate — the ground-floor arm of ADR-0051. + +A floor insulation Option is offered only when it would actually lower the +ground floor's derived U-value: on a modern construction age band RdSAP's +BS EN ISO 13370 / Table 19 cascade assigns an as-built floor its at-regs +U-value and the post-measure state resolves to the same value — the measure +costs thousands for +0.00 SAP (portfolio 796: solid_floor_insulation offered +at £3-4k on age-band-L dwellings 709782/709790). + +Same thin-mapping philosophy as the Wall U-Value Gate (`wall_u_value_gate`): +the calculator's rich extras (surveyor description, lodged `floor_u_value`) +are deliberately omitted — both sides are computed identically, so a genuine +no-op still compares equal, and omissions only ever bias toward OFFERING. +""" + +from typing import Any, Final, Optional + +from datatypes.epc.domain.epc_property_data import SapBuildingPart +from domain.sap10_ml.rdsap_uvalues import Country, u_floor + +# Improvements below this are float noise, not a physical change. NOT a +# materiality threshold — economics stay with the Optimiser (ADR-0016/0024). +_NOISE_FLOOR_W_PER_M2K: Final[float] = 0.01 + + +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 floor_insulation_lowers_u( + main: SapBuildingPart, + country_code: Optional[str], + *, + insulation_thickness_mm: int, +) -> bool: + """Whether insulating the main part's ground floor to + ``insulation_thickness_mm`` lowers its derived U-value by more than float + noise — the Floor U-Value Gate (ADR-0051).""" + country: Country = Country.from_code(country_code) + age_band = main.construction_age_band + ground = next( + (fd for fd in main.sap_floor_dimensions if fd.floor == 0), + main.sap_floor_dimensions[0] if main.sap_floor_dimensions else None, + ) + construction: Optional[int] = ( + _int_or_none(ground.floor_construction) if ground is not None else None + ) + area = ground.total_floor_area_m2 if ground is not None else None + perimeter = ground.heat_loss_perimeter_m if ground is not None else None + current_u: float = u_floor( + country, + age_band, + construction, + _parse_thickness_mm(main.floor_insulation_thickness), + area, + perimeter, + main.wall_thickness_mm, + ) + post_u: float = u_floor( + country, + age_band, + construction, + insulation_thickness_mm, + area, + perimeter, + main.wall_thickness_mm, + ) + return current_u - post_u > _NOISE_FLOOR_W_PER_M2K