feat(modelling): recommend_heating offers the ASHP bundle

Adds the air-source heat-pump Option to the competing "Heating & Hot Water"
bundles. Its overlay is the absolute heat-pump end-state (fixed representative
PCDB index 101413 + category 4 + control 2210 + HWP cylinder + single meter +
off mains gas), pinned against the relodged after-cert next slice. Eligibility
is physical/planning only (ADR-0024, research-grounded): any non-flat
house/bungalow, not listed/heritage (PlanningRestrictions.blocks_internal —
conservation is offered with a caveat, not excluded), not already a heat pump;
floor area / built form / fuel / fabric are deliberately not gates. recommend_
heating gains a restrictions param (defaulted). An already-HHR electric house
now correctly gets ASHP as a better end-state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 16:56:00 +00:00
parent b55ab3727f
commit a9da21c4b6
2 changed files with 193 additions and 6 deletions

View file

@ -15,6 +15,8 @@ produced by scoring (ADR-0016).
from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from repositories.product.product_repository import ProductRepository
@ -22,6 +24,7 @@ from repositories.product.product_repository import ProductRepository
_HEATING_SURFACE = "Heating & Hot Water"
_HHR_STORAGE_MEASURE_TYPE = "high_heat_retention_storage_heaters"
_ASHP_MEASURE_TYPE = "air_source_heat_pump"
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
_ELECTRICITY_FUEL = 30
@ -51,23 +54,103 @@ _HHR_STORAGE_OVERLAY = HeatingOverlay(
meter_type="Dual",
)
# The ASHP bundle's absolute end-state (ADR-0024): a fixed, representative,
# contractor-installable heat pump (PCDB index 101413, RdSAP category 4) with
# time-and-temperature-zone control (2210), a heat-pump hot-water cylinder, a
# single (non off-peak) meter, and the dwelling switched off mains gas. The
# index is the efficiency anchor — the applicator clears any stale
# `sap_main_heating_code` when an index is set, so the calculator resolves the
# heat pump's SCOP from the PCDB record. Pinned against the relodged after-cert.
_ASHP_OVERLAY = HeatingOverlay(
main_fuel_type=_ELECTRICITY_FUEL,
main_heating_control=2210,
main_heating_index_number=101413,
main_heating_category=_HEAT_PUMP_CATEGORY,
water_heating_fuel=_ELECTRICITY_FUEL,
cylinder_size=4,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=50,
cylinder_thermostat="Y",
has_hot_water_cylinder=True,
meter_type="Single",
mains_gas=False,
)
def recommend_heating(
epc: EpcPropertyData, products: ProductRepository
epc: EpcPropertyData,
products: ProductRepository,
restrictions: PlanningRestrictions = PlanningRestrictions(),
) -> Optional[Recommendation]:
"""Return a "Heating & Hot Water" Recommendation of competing whole-system
bundles for the dwelling, else None when no bundle is eligible."""
bundles for the dwelling, else None when no bundle is eligible. ASHP is
additionally gated by the Property's planning protections (ADR-0024)."""
options: list[MeasureOption] = []
hhr_option = _hhr_storage_option(epc, products)
if hhr_option is not None:
options.append(hhr_option)
ashp_option = _ashp_option(epc, products, restrictions)
if ashp_option is not None:
options.append(ashp_option)
if not options:
return None
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
def _ashp_option(
epc: EpcPropertyData,
products: ProductRepository,
restrictions: PlanningRestrictions,
) -> Optional[MeasureOption]:
"""The air-source heat-pump bundle, offered for any non-flat house/bungalow
that is not listed/heritage and not already a heat pump."""
if not _ashp_eligible(epc, restrictions):
return None
product = products.get(_ASHP_MEASURE_TYPE)
return MeasureOption(
measure_type=_ASHP_MEASURE_TYPE,
description=(
"Replace the heating with an air-source heat pump, time-and-"
"temperature-zone controls and a heat-pump hot-water cylinder"
),
overlay=EpcSimulation(heating=_ASHP_OVERLAY),
cost=Cost(
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
),
material_id=product.id,
)
def _ashp_eligible(epc: EpcPropertyData, restrictions: PlanningRestrictions) -> bool:
"""ASHP suits any non-flat house/bungalow that is not already a heat pump and
is not fabric-protected. Eligibility encodes only physical/planning
installability the Optimiser owns the economics (ADR-0024), so floor area,
built form, fuel, and fabric are deliberately not gates. A conservation area
does not exclude ASHP (offered with a planning caveat); a listed/heritage
protection (`blocks_internal`) does."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
return False
if restrictions.blocks_internal:
return False
return _is_house_or_bungalow(epc)
def _is_house_or_bungalow(epc: EpcPropertyData) -> bool:
"""Whether the dwelling is a house or bungalow (not a flat/maisonette). The
Elmhurst path lodges the name; the API path a stringified RdSAP code
(`PROPERTY_TYPE_LOOKUP`: 0 House, 1 Bungalow, 2 Flat, 3 Maisonette)."""
raw: str = (epc.property_type or "").strip()
if raw.lower() in ("house", "bungalow"):
return True
if raw.isdigit():
return PROPERTY_TYPE_LOOKUP.get(int(raw)) in ("House", "Bungalow")
return False
def _hhr_storage_option(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[MeasureOption]:

View file

@ -6,6 +6,7 @@ later slices. Detection + pricing only; impact is produced by scoring (ADR-0016)
"""
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.generators.heating_recommendation import recommend_heating
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
@ -83,14 +84,21 @@ def test_on_gas_boiler_dwelling_yields_no_hhr_storage_bundle() -> None:
}
def test_already_hhr_storage_dwelling_yields_no_bundle() -> None:
def test_already_hhr_storage_dwelling_yields_no_hhr_bundle() -> None:
# Arrange — an electric dwelling already on HHR storage (Table 4a code 409)
# must not be told to install HHR storage again.
# must not be told to install HHR storage again (ASHP may still be offered as
# a better end-state — it is a house here).
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
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — no HHRSH bundle.
if recommendation is not None:
assert "high_heat_retention_storage_heaters" not in {
o.measure_type for o in recommendation.options
}
def test_existing_heat_pump_dwelling_yields_no_hhr_storage_bundle() -> None:
@ -120,3 +128,99 @@ def test_hhr_storage_bundle_carries_the_product_cost_and_contingency() -> None:
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
def _gas_boiler_house() -> EpcPropertyData:
"""A 000490 mains-gas combi dwelling, explicitly a House — ASHP-eligible."""
epc: EpcPropertyData = build_epc()
epc.property_type = "House"
return epc
def test_gas_boiler_house_yields_an_ashp_bundle() -> None:
# Arrange — a mains-gas house; ASHP is offered for any non-flat house/
# bungalow regardless of current system or efficiency (ADR-0024).
baseline: EpcPropertyData = _gas_boiler_house()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — the ASHP bundle carries the absolute heat-pump end-state.
assert recommendation is not None
options = {o.measure_type: o for o in recommendation.options}
assert "air_source_heat_pump" in options
assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay(
main_fuel_type=30,
main_heating_control=2210,
main_heating_index_number=101413,
main_heating_category=4,
water_heating_fuel=30,
cylinder_size=4,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=50,
cylinder_thermostat="Y",
has_hot_water_cylinder=True,
meter_type="Single",
mains_gas=False,
)
def test_listed_building_yields_no_ashp_bundle() -> None:
# Arrange — a listed building protects the fabric; an external ASHP unit is
# not auto-offered (ADR-0024). The dwelling is on gas, so HHRSH is also out.
baseline: EpcPropertyData = _gas_boiler_house()
# Act
recommendation: Recommendation | None = recommend_heating(
baseline, _StubProducts(), PlanningRestrictions(is_listed=True)
)
# Assert
assert recommendation is None
def test_conservation_area_still_yields_an_ashp_bundle() -> None:
# Arrange — unlike glazing, a conservation area does NOT exclude ASHP; it is
# offered with a planning caveat (ADR-0024, research-grounded).
baseline: EpcPropertyData = _gas_boiler_house()
# Act
recommendation: Recommendation | None = recommend_heating(
baseline, _StubProducts(), PlanningRestrictions(in_conservation_area=True)
)
# Assert
assert recommendation is not None
assert "air_source_heat_pump" in {o.measure_type for o in recommendation.options}
def test_flat_yields_no_ashp_bundle() -> None:
# Arrange — flats are not auto-offered an individual ASHP (siting/lease/
# MCS-020 need a survey — ADR-0024).
baseline: EpcPropertyData = _gas_boiler_house()
baseline.property_type = "Flat"
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — no ASHP (and the gas flat is not HHRSH-eligible either → None).
if recommendation is not None:
assert "air_source_heat_pump" not in {
o.measure_type for o in recommendation.options
}
def test_existing_heat_pump_yields_no_ashp_bundle() -> None:
# Arrange — a dwelling already on a heat pump (category 4) is not re-offered
# an ASHP.
baseline: EpcPropertyData = _gas_boiler_house()
baseline.sap_heating.main_heating_details[0].main_heating_category = 4
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert
if recommendation is not None:
assert "air_source_heat_pump" not in {
o.measure_type for o in recommendation.options
}