mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
b55ab3727f
commit
a9da21c4b6
2 changed files with 193 additions and 6 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue