Merge pull request #1462 from Hestia-Homes/fix/floor-u-value-gate

Floor insulation gated on the derived floor U-value (ADR-0051 floor arm)
This commit is contained in:
KhalimCK 2026-07-03 17:53:34 +01:00 committed by GitHub
commit adcdce4271
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 117 additions and 3 deletions

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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()