mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 17:03:02 +00:00
Merge branch 'main' into feature/abri-api-integration
This commit is contained in:
commit
d1b7e15d64
6 changed files with 212 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
76
domain/modelling/generators/floor_u_value_gate.py
Normal file
76
domain/modelling/generators/floor_u_value_gate.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue