From ae7959f57c876e6bb0d238d86ee5d64235741634 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 13:35:14 +0000 Subject: [PATCH] feat(modelling): secondary-heating-removal generator + MeasureType (ADR-0028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_secondary_heating_removal offers one standalone Option that clears the lodged secondary system. Eligibility is purely physical (offer iff sap_heating.secondary_heating_type is set) — no effectiveness gate, since a lodged secondary is a fixed emitter per RdSAP (portables are ignored), and the electric-storage §A.2.2 no-op is the Optimiser's call (ADR-0028 decisions 1-2). Priced at a flat per-dwelling decommission cost, not room-scaled. Co-Authored-By: Claude Opus 4.8 --- .../secondary_heating_recommendation.py | 57 +++++++++++ domain/modelling/measure_type.py | 1 + tests/domain/modelling/test_measure_type.py | 1 + .../test_secondary_heating_recommendation.py | 94 +++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 domain/modelling/generators/secondary_heating_recommendation.py create mode 100644 tests/domain/modelling/test_secondary_heating_recommendation.py diff --git a/domain/modelling/generators/secondary_heating_recommendation.py b/domain/modelling/generators/secondary_heating_recommendation.py new file mode 100644 index 00000000..16e40e38 --- /dev/null +++ b/domain/modelling/generators/secondary_heating_recommendation.py @@ -0,0 +1,57 @@ +"""The Secondary Heating Removal Recommendation Generator (ADR-0028). + +Offers to strip a dwelling's lodged secondary heating system so the main system +serves 100% of space heating. A **standalone, co-selectable** Recommendation — +not an Option in the Heating & Hot Water rec — because removing a secondary +heater is independent of (and combinable with) a tune-up or boiler upgrade. + +Eligibility is purely physical: offered **iff a secondary is lodged** +(`sap_heating.secondary_heating_type` is set). RdSAP only records a secondary +when a *fixed* emitter is present (portable plug-in heaters are ignored), so a +lodged secondary is by definition a fixed unit worth removing. There is no +effectiveness gate — on an electric-storage main RdSAP §A.2.2 forces a default +secondary back, making removal a no-op, but that is the Optimiser's call (it +owns the economics), not eligibility's. Detection + pricing only; impact is +produced later by scoring (ADR-0016). + +Priced at a flat per-dwelling decommission cost (one electrician visit to +disconnect a fixed/hard-wired heater + localised making-good), not scaled by +room count — the EPC lodges one secondary system with no heater count (ADR-0028). +""" + +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.measure_type import MeasureType +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation, SecondaryHeatingOverlay +from repositories.product.product_repository import ProductRepository + +_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE: Final[MeasureType] = ( + MeasureType.SECONDARY_HEATING_REMOVAL +) + + +def recommend_secondary_heating_removal( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a Secondary Heating Removal Recommendation — its single Option + clears the lodged secondary system — else None when no secondary is lodged + (nothing physical to remove).""" + if epc.sap_heating.secondary_heating_type is None: + return None + + product = products.get(_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE) + overlay = EpcSimulation(secondary_heating=SecondaryHeatingOverlay()) + cost = Cost( + total=product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + option = MeasureOption( + measure_type=_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE, + description="Remove the secondary heating system", + overlay=overlay, + cost=cost, + material_id=product.id, + ) + return Recommendation(surface="Secondary Heating", options=(option,)) diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py index 70a52c90..a1882853 100644 --- a/domain/modelling/measure_type.py +++ b/domain/modelling/measure_type.py @@ -36,3 +36,4 @@ class MeasureType(StrEnum): SYSTEM_TUNE_UP = "system_tune_up" SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned" SOLAR_PV = "solar_pv" + SECONDARY_HEATING_REMOVAL = "secondary_heating_removal" diff --git a/tests/domain/modelling/test_measure_type.py b/tests/domain/modelling/test_measure_type.py index 214df8bc..93431c21 100644 --- a/tests/domain/modelling/test_measure_type.py +++ b/tests/domain/modelling/test_measure_type.py @@ -30,6 +30,7 @@ _EXPECTED_VALUES = { "system_tune_up", "system_tune_up_zoned", "solar_pv", + "secondary_heating_removal", } diff --git a/tests/domain/modelling/test_secondary_heating_recommendation.py b/tests/domain/modelling/test_secondary_heating_recommendation.py new file mode 100644 index 00000000..13ae6f69 --- /dev/null +++ b/tests/domain/modelling/test_secondary_heating_recommendation.py @@ -0,0 +1,94 @@ +"""Behaviour of the Secondary Heating Removal Recommendation Generator: offering +to strip a dwelling's lodged secondary heating system so the main serves 100% of +space heating (ADR-0028). A standalone, co-selectable Recommendation; eligibility +is purely physical (offer iff a secondary is lodged) — the Optimiser de-selects +the cases where removal cannot move SAP. Detection + pricing only (ADR-0016). +""" + +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.secondary_heating_recommendation import ( + recommend_secondary_heating_removal, +) +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import SecondaryHeatingOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed flat per-dwelling + decommission price (ADR-0028).""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=250.0, + contingency_rate=0.25, + id=11, + ) + + +def _without_secondary(epc: EpcPropertyData) -> EpcPropertyData: + """Return a copy of `epc` with no secondary heating system lodged.""" + clone: EpcPropertyData = copy.deepcopy(epc) + clone.sap_heating.secondary_heating_type = None + clone.sap_heating.secondary_fuel_type = None + return clone + + +def test_dwelling_with_a_lodged_secondary_yields_a_removal_recommendation() -> None: + # Arrange — 000490 lodges a secondary system (SAP code 691, electric panel/ + # convector/radiant heaters). + baseline: EpcPropertyData = build_epc() + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert — one Option whose overlay clears the secondary. + assert recommendation is not None + assert recommendation.surface == "Secondary Heating" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "secondary_heating_removal" + assert option.overlay.secondary_heating == SecondaryHeatingOverlay() + + +def test_dwelling_without_a_secondary_yields_no_recommendation() -> None: + # Arrange — nothing lodged to remove (RdSAP only records a secondary when a + # fixed emitter is present; a portable would not be lodged at all). + baseline: EpcPropertyData = _without_secondary(build_epc()) + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_recommendation_prices_a_flat_per_dwelling_decommission() -> None: + # Arrange — a lodged secondary; the cost is a flat per-dwelling decommission + # figure (one electrician visit + localised making-good), not room-scaled. + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert cost.total == 250.0 + assert cost.contingency_rate == 0.25 + assert recommendation.options[0].material_id == 11