feat(modelling): recommend_heating offers the HHR storage bundle

The heating Recommendation Generator (HHRSH first). Emits one "Heating & Hot
Water" Recommendation whose competing whole-system bundles the Optimiser picks
from; this slice builds the high-heat-retention storage Option. Its overlay is
the absolute HHR end-state (Table 4a code 409 + control 2404 + dual off-peak
meter + off-peak electric cylinder), pinned against the relodged after-cert in
the next slice. Eligibility translates legacy is_high_heat_retention_valid to
structured predicates (electric or off-gas main, not already HHR/heat-pump).
mains_gas and the heat emitter are unchanged by the measure, so unset. ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 19:05:02 +00:00
parent 2f6a1e2479
commit b883e75da8
2 changed files with 224 additions and 0 deletions

View file

@ -0,0 +1,103 @@
"""The heating Recommendation Generator.
Detects a dwelling whose heating system should be replaced and emits one
"Heating & Hot Water" Recommendation of competing whole-system bundles the
Optimiser picks at most one (ADR-0024). Each bundle is a whole-system change:
main heating + controls + fuel + meter + the implied hot water, folded into one
Measure Option's `HeatingOverlay`. Hot water is never a separate competing
measure; the legacy heating-vs-HW split double-counted.
This slice covers the high-heat-retention storage (HHRSH) bundle; the ASHP and
boiler bundles land in later slices. Detection + pricing only impact is
produced by scoring (ADR-0016).
"""
from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from repositories.product.product_repository import ProductRepository
_HEATING_SURFACE = "Heating & Hot Water"
_HHR_STORAGE_MEASURE_TYPE = "high_heat_retention_storage_heaters"
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
_ELECTRICITY_FUEL = 30
# Table 4a SAP main-heating code for high-heat-retention storage heaters; an
# existing HHR system lodges this already, so it is not re-recommended.
_HHR_STORAGE_SAP_CODE = 409
# RdSAP main_heating_category for a heat pump (Table 4a) — an existing heat pump
# is never downgraded to storage heaters.
_HEAT_PUMP_CATEGORY = 4
# The HHRSH bundle's absolute end-state (ADR-0024): high-heat-retention storage
# heaters (Table 4a code 409) on a dual off-peak meter, with an off-peak
# electric immersion hot-water cylinder. Pinned against the relodged after-cert
# in the cascade tests; `mains_gas` and the heat emitter are unchanged by this
# measure, so they are not written.
_HHR_STORAGE_OVERLAY = HeatingOverlay(
main_fuel_type=_ELECTRICITY_FUEL,
sap_main_heating_code=_HHR_STORAGE_SAP_CODE,
main_heating_control=2404,
water_heating_code=903,
water_heating_fuel=_ELECTRICITY_FUEL,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=120,
has_hot_water_cylinder=True,
meter_type="Dual",
)
def recommend_heating(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[Recommendation]:
"""Return a "Heating & Hot Water" Recommendation of competing whole-system
bundles for the dwelling, else None when no bundle is eligible."""
options: list[MeasureOption] = []
hhr_option = _hhr_storage_option(epc, products)
if hhr_option is not None:
options.append(hhr_option)
if not options:
return None
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
def _hhr_storage_option(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[MeasureOption]:
"""The high-heat-retention storage bundle, offered for an electrically-heated
(or off-gas) dwelling that is not already HHR or a heat pump."""
if not _hhr_storage_eligible(epc):
return None
product = products.get(_HHR_STORAGE_MEASURE_TYPE)
return MeasureOption(
measure_type=_HHR_STORAGE_MEASURE_TYPE,
description=(
"Replace the heating with high heat retention storage heaters on an "
"off-peak tariff, with an off-peak electric hot-water cylinder"
),
overlay=EpcSimulation(heating=_HHR_STORAGE_OVERLAY),
cost=Cost(
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
),
material_id=product.id,
)
def _hhr_storage_eligible(epc: EpcPropertyData) -> bool:
"""HHR storage suits an electrically-heated or off-gas dwelling, unless it is
already HHR or a heat pump (translated from legacy `HeatingRecommender.
is_high_heat_retention_valid`, which keyed on description strings)."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if main.sap_main_heating_code == _HHR_STORAGE_SAP_CODE:
return False
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
return False
off_gas: bool = epc.sap_energy_source is None or not epc.sap_energy_source.mains_gas
electric_main: bool = main.main_fuel_type == _ELECTRICITY_FUEL
return electric_main or off_gas

View file

@ -0,0 +1,121 @@
"""Behaviour of the heating Recommendation Generator: detecting a dwelling
whose heating system should be replaced and emitting one "Heating & Hot Water"
Recommendation of competing whole-system bundles (ADR-0024). This slice covers
the high-heat-retention storage (HHRSH) bundle; ASHP and boiler bundles land in
later slices. Detection + pricing only; impact is produced by scoring (ADR-0016).
"""
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.generators.heating_recommendation import recommend_heating
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.simulation import HeatingOverlay
from repositories.product.product_repository import ProductRepository
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
# Electricity main-fuel code (Elmhurst → SAP10) and the Table 4a SAP code an
# existing (non-HHR) electric storage system lodges.
_ELECTRICITY = 30
_OLD_STORAGE_SAP_CODE = 402
def _electric_storage_baseline() -> EpcPropertyData:
"""A 000490 dwelling re-cast as an existing (non-HHR) electric storage
system: electric main fuel, Table 4a code 402."""
epc: EpcPropertyData = build_epc()
main = epc.sap_heating.main_heating_details[0]
main.main_fuel_type = _ELECTRICITY
main.sap_main_heating_code = _OLD_STORAGE_SAP_CODE
return epc
class _StubProducts(ProductRepository):
"""In-memory ProductRepository returning a fixed HHRSH product cost."""
def get(self, measure_type: str) -> Product:
return Product(
measure_type=measure_type, unit_cost_per_m2=3500.0, contingency_rate=0.26
)
def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None:
# Arrange — an existing electric storage system (not HHR).
baseline: EpcPropertyData = _electric_storage_baseline()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — one "Heating & Hot Water" Recommendation carrying the HHRSH
# bundle, whose overlay is the absolute HHR end-state.
assert recommendation is not None
assert recommendation.surface == "Heating & Hot Water"
options = {o.measure_type: o for o in recommendation.options}
assert "high_heat_retention_storage_heaters" in options
assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay(
main_fuel_type=30,
sap_main_heating_code=409,
main_heating_control=2404,
water_heating_code=903,
water_heating_fuel=30,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=120,
has_hot_water_cylinder=True,
meter_type="Dual",
)
def test_on_gas_boiler_dwelling_yields_no_hhr_storage_bundle() -> None:
# Arrange — 000490 is a mains-gas combi (fuel 26, mains_gas True). HHR
# storage suits off-gas / electric dwellings, not an on-gas gas boiler.
baseline: EpcPropertyData = build_epc()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — no HHRSH bundle (no other bundle is built in this slice → None).
if recommendation is not None:
assert "high_heat_retention_storage_heaters" not in {
o.measure_type for o in recommendation.options
}
def test_already_hhr_storage_dwelling_yields_no_bundle() -> None:
# Arrange — an electric dwelling already on HHR storage (Table 4a code 409)
# must not be told to install HHR storage again.
baseline: EpcPropertyData = _electric_storage_baseline()
baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 409
# Act / Assert
assert recommend_heating(baseline, _StubProducts()) is None
def test_existing_heat_pump_dwelling_yields_no_hhr_storage_bundle() -> None:
# Arrange — an electric dwelling already on a heat pump (category 4) is never
# downgraded to storage heaters.
baseline: EpcPropertyData = _electric_storage_baseline()
baseline.sap_heating.main_heating_details[0].main_heating_category = 4
# Act / Assert
assert recommend_heating(baseline, _StubProducts()) is None
def test_hhr_storage_bundle_carries_the_product_cost_and_contingency() -> None:
# Arrange
baseline: EpcPropertyData = _electric_storage_baseline()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — the bundle's fully-loaded cost + contingency come from the product.
assert recommendation is not None
option = next(
o
for o in recommendation.options
if o.measure_type == "high_heat_retention_storage_heaters"
)
assert option.cost is not None
assert abs(option.cost.total - 3500.0) <= 1e-9
assert abs(option.cost.contingency_rate - 0.26) <= 1e-9