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 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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5d87f80cd..7ebf4d40e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2603,6 +2603,23 @@ _ELECTRIC_STORAGE_BOILER_CODES: Final[frozenset[int]] = frozenset( {193, 194, 195, 196} ) _ELECTRIC_CPSU_CODES: Final[frozenset[int]] = frozenset({192}) +# Electric underfloor (Table 4a 421-425) — SAP 10.2 Table 12a Grid 1 (PDF +# p.184) names the codes in its row labels: +# 422/423 "Integrated (storage+direct-acting) systems (applies to storage +# heaters 408 and underfloor heating 422 and 423)" → 7-hour 0.20 +# 424/425 "Underfloor heating (in screed above insulation, in timber floor +# or immediately below floor covering)" → 7-hour 0.90 / 10-hour 0.50 +# 421 "in concrete slab (off-peak only)" is named in NEITHER underfloor +# row — the slab is the storage medium, so it takes the "Other +# storage heaters" row (0.00 at 7-hour or 24-hour), consistent with +# §12 Rule 2 listing 421 among the off-peak-implying storage codes. +# Before these branches every underfloor code fell to the None all-low-rate +# fallback, over-crediting a 7-hour screed dwelling's space heat at 5.50p/kWh +# instead of the blended ~14.3p (ADR-0050 deferred follow-up; 22 live +# 7-hour-metered underfloor dwellings on portfolio 796). +_SLAB_UNDERFLOOR_CODES: Final[frozenset[int]] = frozenset({421}) +_INTEGRATED_UNDERFLOOR_CODES: Final[frozenset[int]] = frozenset({422, 423}) +_SCREED_OR_TIMBER_UNDERFLOOR_CODES: Final[frozenset[int]] = frozenset({424, 425}) def _table_12a_system_for_main( @@ -2622,7 +2639,9 @@ def _table_12a_system_for_main( - ASHP / GSHP (codes 211-224, 521-524, PCDB index) — wired - Storage heaters (cat 7): 408 → INTEGRATED_STORAGE_DIRECT (0.20), all others → OTHER_STORAGE_HEATERS (0.00) — wired - - Underfloor heating (421-422) — TODO + - Underfloor heating: 421 slab → OTHER_STORAGE_HEATERS (0.00), + 422/423 integrated → INTEGRATED_STORAGE_DIRECT (0.20), + 424/425 screed/timber → UNDERFLOOR_HEATING (0.90/0.50) — wired - Direct-acting electric boiler (191) → 0.90/0.50; electric storage boilers (193/194/195/196) → 0.00 — wired - Electric CPSU (192) — Appendix F high-rate cascade still TODO @@ -2634,6 +2653,19 @@ def _table_12a_system_for_main( main.main_heating_index_number is not None and heat_pump_record(main.main_heating_index_number) is not None ) + # Electric UNDERFLOOR heating — routed by the Table 4a code per the Grid 1 + # row labels (see `_SLAB_UNDERFLOOR_CODES` et al. above), checked BEFORE + # the storage branch so a stale category 7 cannot drag a screed system + # (0.90 at 7-hour) down to the all-night storage row — the code is more + # authoritative than the coarser category, mirroring the storage-over- + # room-heater precedent below. Gated on `_is_electric_main` belt-and-braces. + if _is_electric_main(main) and code is not None: + if code in _SCREED_OR_TIMBER_UNDERFLOOR_CODES: + return Table12aSystem.UNDERFLOOR_HEATING + if code in _INTEGRATED_UNDERFLOOR_CODES: + return Table12aSystem.INTEGRATED_STORAGE_DIRECT + if code in _SLAB_UNDERFLOOR_CODES: + return Table12aSystem.OTHER_STORAGE_HEATERS # Electric STORAGE heaters — SAP 10.2 Table 12a Grid 1 (PDF p.191). # Detected by RdSAP main_heating_category 7 OR, authoritatively, by a Table # 4a electric-storage-heater SAP code (401-409). Keyed on the CODE as well as 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() diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index e387c73c9..109dabaed 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1079,6 +1079,68 @@ def test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows() -> None assert space_heating_high_rate_fraction(storage, Tariff.SEVEN_HOUR) == 0.00 +def test_electric_underfloor_codes_map_to_their_table_12a_grid1_rows() -> None: + # Arrange — SAP 10.2 Table 12a Grid 1 (PDF p.184/191) names the underfloor + # codes explicitly: "Integrated (storage+direct-acting) systems (applies to + # storage heaters 408 and underfloor heating 422 and 423)" → 7-hour 0.20; + # "Underfloor heating (in screed above insulation, in timber floor or + # immediately below floor covering)" (424/425) → 7-hour 0.90 / 10-hour + # 0.50. Code 421 ("in concrete slab, off-peak only") is named in NEITHER + # underfloor row — the slab is the storage medium, so it takes "Other + # storage heaters" (0.00 at 7-hour or 24-hour), consistent with §12 Rule 2 + # listing 421 among the off-peak-implying storage codes. Before this + # resolver branch every underfloor code fell to the None fallback and + # billed space heat 100% at the night rate — on portfolio 796, 22 of the + # 90 underfloor-override dwellings sit on a 7-hour Dual meter and were + # over-credited (e.g. screed 424 at 5.50p/kWh instead of the blended + # ~14.3p). + from domain.sap10_calculator.tables.table_12a import ( + Table12aSystem, + Tariff, + space_heating_high_rate_fraction, + ) + + def _underfloor_main(code: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2701, + main_heating_category=8, # electric underfloor + sap_main_heating_code=code, + ) + + # Act + slab = _table_12a_system_for_main(_underfloor_main(421)) + integrated = [_table_12a_system_for_main(_underfloor_main(c)) for c in (422, 423)] + screed = [_table_12a_system_for_main(_underfloor_main(c)) for c in (424, 425)] + + # Assert — the three spec rows, with their published fractions. + assert slab is Table12aSystem.OTHER_STORAGE_HEATERS + assert all(s is Table12aSystem.INTEGRATED_STORAGE_DIRECT for s in integrated) + assert all(s is Table12aSystem.UNDERFLOOR_HEATING for s in screed) + assert space_heating_high_rate_fraction(slab, Tariff.SEVEN_HOUR) == 0.00 + assert ( + space_heating_high_rate_fraction( + Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR + ) + == 0.20 + ) + assert ( + space_heating_high_rate_fraction( + Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR + ) + == 0.90 + ) + assert ( + space_heating_high_rate_fraction( + Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR + ) + == 0.50 + ) + + def test_storage_heater_classified_by_sap_code_over_stale_category() -> None: # Regression: electric STORAGE heaters must resolve to a storage Table 12a # Grid 1 row from their Table 4a SAP code (401-409) even when the RdSAP