feat(modelling): secondary-heating-removal generator + MeasureType (ADR-0028)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 13:35:14 +00:00
parent 9b286e4a22
commit ae7959f57c
4 changed files with 153 additions and 0 deletions

View file

@ -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,))

View file

@ -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"

View file

@ -30,6 +30,7 @@ _EXPECTED_VALUES = {
"system_tune_up",
"system_tune_up_zoned",
"solar_pv",
"secondary_heating_removal",
}

View file

@ -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