Model/domain/modelling/generators/heating_recommendation.py
Khalim Conn-Kowlessar 9f17a27766 feat(modelling): ASHP overlay resets water_heating_code to the HP end-state
The ASHP bundle is a fixed whole-system end-state (confirmed: always the same
contractor cylinder), so the hot-water arrangement is fixed too. The overlay now
sets water_heating_code=901 ("from main system") absolutely, so a combi (909/611)
or electric (903/908) before is reset to HW-from-the-heat-pump — previously the
overlay relied on the before already lodging 901 (true for boiler-1, not in
general). No-op for the boiler-1 pin (stays 1e-4). Cascade pins for combi /
electric-with-cylinder befores await example certs. ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:18:58 +00:00

192 lines
8.1 KiB
Python

"""The heating Recommendation Generator.
Detects a dwelling whose heating system should be replaced and emits one
"Heating & Hot Water" Recommendation of competing whole-system bundles — the
Optimiser picks at most one (ADR-0024). Each bundle is a whole-system change:
main heating + controls + fuel + meter + the implied hot water, folded into one
Measure Option's `HeatingOverlay`. Hot water is never a separate competing
measure; the legacy heating-vs-HW split double-counted.
This slice covers the high-heat-retention storage (HHRSH) bundle; the ASHP and
boiler bundles land in later slices. Detection + pricing only — impact is
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
_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
# Table 4a SAP main-heating code for high-heat-retention storage heaters; an
# existing HHR system lodges this already, so it is not re-recommended.
_HHR_STORAGE_SAP_CODE = 409
# RdSAP main_heating_category for a heat pump (Table 4a) — an existing heat pump
# is never downgraded to storage heaters.
_HEAT_PUMP_CATEGORY = 4
# The HHRSH bundle's absolute end-state (ADR-0024): high-heat-retention storage
# heaters (Table 4a code 409) on a dual off-peak meter, with an off-peak
# electric immersion hot-water cylinder. Pinned against the relodged after-cert
# in the cascade tests; `mains_gas` and the heat emitter are unchanged by this
# measure, so they are not written.
_HHR_STORAGE_OVERLAY = HeatingOverlay(
main_fuel_type=_ELECTRICITY_FUEL,
sap_main_heating_code=_HHR_STORAGE_SAP_CODE,
main_heating_control=2404,
water_heating_code=903,
water_heating_fuel=_ELECTRICITY_FUEL,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=120,
cylinder_thermostat="Y",
has_hot_water_cylinder=True,
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,
# Hot water from the main heat-pump system via the new cylinder (code 901,
# "from main system"). Set absolutely so a combi (909/611) or electric
# (903/908) before is reset to the fixed HP end-state, not just the case
# where the before already lodged 901.
water_heating_code=901,
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,
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. 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]:
"""The high-heat-retention storage bundle, offered for an electrically-heated
(or off-gas) dwelling that is not already HHR or a heat pump."""
if not _hhr_storage_eligible(epc):
return None
product = products.get(_HHR_STORAGE_MEASURE_TYPE)
return MeasureOption(
measure_type=_HHR_STORAGE_MEASURE_TYPE,
description=(
"Replace the heating with high heat retention storage heaters on an "
"off-peak tariff, with an off-peak electric hot-water cylinder"
),
overlay=EpcSimulation(heating=_HHR_STORAGE_OVERLAY),
cost=Cost(
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
),
material_id=product.id,
)
def _hhr_storage_eligible(epc: EpcPropertyData) -> bool:
"""HHR storage suits an electrically-heated or off-gas dwelling, unless it is
already HHR or a heat pump (translated from legacy `HeatingRecommender.
is_high_heat_retention_valid`, which keyed on description strings)."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if main.sap_main_heating_code == _HHR_STORAGE_SAP_CODE:
return False
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
return False
off_gas: bool = not epc.sap_energy_source.mains_gas
electric_main: bool = main.main_fuel_type == _ELECTRICITY_FUEL
return electric_main or off_gas