From 128981289a3942ff5fefefeac5921ef56cf384b4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 16:05:04 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Floor=20insulation=20is=20withheld=20when?= =?UTF-8?q?=20it=20cannot=20lower=20the=20derived=20floor=20U=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../modelling/test_floor_recommendation.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/domain/modelling/test_floor_recommendation.py b/tests/domain/modelling/test_floor_recommendation.py index 1ce5bd7f3..59ad21889 100644 --- a/tests/domain/modelling/test_floor_recommendation.py +++ b/tests/domain/modelling/test_floor_recommendation.py @@ -66,6 +66,28 @@ def test_uninsulated_solid_floor_yields_solid_insulation() -> None: assert recommendation.options[0].measure_type == "solid_floor_insulation" +def test_at_regs_age_band_floor_yields_no_recommendation() -> None: + # Arrange — a solid ground floor lodged as-built at construction age band + # L (2012-2022). RdSAP's BS EN ISO 13370 cascade assigns it the at-regs + # U-value and 100 mm of added insulation resolves to the same (or a + # no-better) value — the measure cannot change the modelled floor, so + # offering it sells £3-4k of insulation for +0.00 SAP (portfolio 796: + # properties 709782/709790). The Floor U-Value Gate withholds it — the + # floor arm of ADR-0051's deferred follow-up. + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _main(baseline) + main.floor_construction_type = "Solid" + main.construction_age_band = "L" + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + def test_already_insulated_floor_yields_no_recommendation() -> None: # Arrange baseline: EpcPropertyData = build_epc() From fb3516ee95c0bcae73fbdf28d4a6831c19a3509b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 16:06:45 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Floor=20insulation=20is=20withheld=20when?= =?UTF-8?q?=20it=20cannot=20lower=20the=20derived=20floor=20U=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../generators/floor_recommendation.py | 11 +++ .../generators/floor_u_value_gate.py | 76 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 domain/modelling/generators/floor_u_value_gate.py 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 From 2a0cf05fdd72d8872ec2db9635804db79b493442 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 16:08:57 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Amend=20ADR-0051:=20the=20floor=20U-value?= =?UTF-8?q?=20gate=20landed;=20roof=20stays=20evidence-gated=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...ed-only-when-it-lowers-the-derived-wall-u-value.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/adr/0051-wall-insulation-offered-only-when-it-lowers-the-derived-wall-u-value.md b/docs/adr/0051-wall-insulation-offered-only-when-it-lowers-the-derived-wall-u-value.md index 90db6f335..8b63c4f0d 100644 --- a/docs/adr/0051-wall-insulation-offered-only-when-it-lowers-the-derived-wall-u-value.md +++ b/docs/adr/0051-wall-insulation-offered-only-when-it-lowers-the-derived-wall-u-value.md @@ -54,9 +54,14 @@ materiality threshold — economics stay with the Optimiser, ADR-0016/0024). precedent as the heating generator reading the calculator's PCDB/Table 4b. - **Deferred:** (a) a cert that lodges an explicit `wall_u_value` overrides the cascade in the calculator, making fabric measures score +0.00 regardless of - this gate — the measure overlay should adjust the lodged U; (b) roof/floor - generators have the same anti-pattern in principle (roof age-band eligibility - is partly covered by ADR-0047); both tracked as follow-ups. + this gate — the measure overlay should adjust the lodged U (probed on + portfolio 796: 0/300 sampled certs lodge one, so no live evidence yet); + (b) roof/floor generators have the same anti-pattern in principle (roof + age-band eligibility is partly covered by ADR-0047). **Amended:** the floor + arm landed as `floor_u_value_gate` (probed live: solid_floor_insulation at + £3-4k for +0.00 on age-band-L dwellings 709782/709790); a 40-dwelling + band-K/L probe found no roof no-ops, so the roof arm stays unimplemented + until evidence appears. ### Alternatives rejected