From b883e75da8d2256cd5c892ceb5ede9f19ce7e6e7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 19:05:02 +0000 Subject: [PATCH] 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 --- .../generators/heating_recommendation.py | 103 +++++++++++++++ .../modelling/test_heating_recommendation.py | 121 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 domain/modelling/generators/heating_recommendation.py create mode 100644 tests/domain/modelling/test_heating_recommendation.py diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py new file mode 100644 index 00000000..b13068b8 --- /dev/null +++ b/domain/modelling/generators/heating_recommendation.py @@ -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 diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py new file mode 100644 index 00000000..aa27ae04 --- /dev/null +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -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