mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
9b286e4a22
commit
ae7959f57c
4 changed files with 153 additions and 0 deletions
|
|
@ -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,))
|
||||||
|
|
@ -36,3 +36,4 @@ class MeasureType(StrEnum):
|
||||||
SYSTEM_TUNE_UP = "system_tune_up"
|
SYSTEM_TUNE_UP = "system_tune_up"
|
||||||
SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned"
|
SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned"
|
||||||
SOLAR_PV = "solar_pv"
|
SOLAR_PV = "solar_pv"
|
||||||
|
SECONDARY_HEATING_REMOVAL = "secondary_heating_removal"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ _EXPECTED_VALUES = {
|
||||||
"system_tune_up",
|
"system_tune_up",
|
||||||
"system_tune_up_zoned",
|
"system_tune_up_zoned",
|
||||||
"solar_pv",
|
"solar_pv",
|
||||||
|
"secondary_heating_removal",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue