Model/domain/modelling/generators/heating_recommendation.py
Khalim Conn-Kowlessar b18fc3878b ASHP sizing selects the smallest pump meeting the dwelling's design heat loss 🟩
The sizing ladder (Vaillant aroTHERM plus 3.5-12 kW PCDB records) picks
the smallest rung whose rated output meets the design heat loss, so SAP
10.2 Appendix N reads the heat-pump efficiency near its PSR peak instead
of collapsing on an undersized fixed unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:13:46 +00:00

823 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, is_heat_network_main
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import (
AshpCostInputs,
AshpExistingSystem,
BoilerCostInputs,
Products,
TuneUpCostInputs,
)
from domain.modelling.measure_type import MeasureType
from domain.modelling.product import Product
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record
from domain.sap10_calculator.tables.table_4b import (
table_4b_seasonal_efficiencies_pct,
)
from repositories.product.product_repository import ProductRepository
_HEATING_SURFACE = "Heating & Hot Water"
_HHR_STORAGE_MEASURE_TYPE = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS
_ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP
_GAS_BOILER_UPGRADE_MEASURE_TYPE = MeasureType.GAS_BOILER_UPGRADE
_SYSTEM_TUNE_UP_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP
_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP_ZONED
# 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,
# RdSAP electric-storage-heater category (7). Set so the overlaid EPC stays
# internally consistent: without it the pre-existing system's category (e.g.
# 10 = electric room heaters) survives alongside the new storage code 409,
# and the SAP 10.2 Table 12a resolver — which classifies by category —
# misreads the off-peak storage heating as direct-acting electric and prices
# 100% at the peak rate (verified: UPRN 100020942571 HHRSH SAP 48 vs
# accredited Elmhurst 71; category 7 -> 73). See ADR-0024.
main_heating_category=7,
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",
# Single off-peak electric immersion — drives the SAP 10.2 Table 13 HW
# high-rate split (matches the relodged after-cert; without it the HW
# bills 100% at the low rate, +1.26 SAP over the reference).
immersion_heating_type=1,
has_hot_water_cylinder=True,
meter_type="Dual",
)
# Representative heat-pump products Domna installs (one per brand we hold
# contractor install rates for), as PCDB Table 362 indices — the catalogue we
# may simulate the ASHP bundle with. Each is a valid, currently-available,
# ~5 kW air-to-water unit providing space + water heating, chosen for high SAP
# 10.2 Appendix N efficiency (space η at the dwelling's PSR, with a healthy
# water η — many high-space records collapse on water and were rejected):
#
# Vaillant 110257 aroTHERM plus 5 kW space ~402% / water ~288%
# Mitsubishi 104570 Ecodan PUZ-WM50VHA 5.0 kW space ~368% / water ~288%
# Daikin 105008 Altherma ERGA04DVA 5.5 kW space ~376% / water ~288%
# Samsung 108774 AE050CXYDEK 5 kW (R290) space ~394% / water ~309%
# Grant 103768 AERONA3 HPID6R32 4.8 kW space ~395% / water ~332%
#
# We fix the Vaillant for the tracer: it is widely available for install and a
# strong all-round SAP performer. (Promoting this to a per-dwelling choice is a
# clean future change — see the sizing note below.)
_VAILLANT_AROTHERM_PLUS_5KW_PCDB = 110257
# NOTE (sizing): the bundle installs ONE fixed ~5 kW product regardless of the
# dwelling. SAP 10.2 Appendix N reads heat-pump efficiency at the dwelling's PSR
# (= pump max output / design heat loss), so a fixed output is a deliberate
# simplification: a 5 kW unit lands at a good PSR (~0.8-1.0) for modest
# dwellings but is undersized for high-heat-loss ones (low PSR → lower space
# efficiency), leaving SAP on the table. Sizing the pump to the dwelling (and
# selecting the matching PCDB record) is future work — it also feeds the
# size-banded ASHP costing.
# The ASHP bundle's absolute end-state (ADR-0024): the fixed, representative,
# contractor-installable heat pump above (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=_VAILLANT_AROTHERM_PLUS_5KW_PCDB,
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",
gas_connection_available=False,
)
# ASHP sizing ladder (ADR-0049): the Vaillant aroTHERM plus "& AI VIH RW" family,
# one representative PCDB Table 362 record per nominal size, ascending by rated
# max output (kW). The pump is sized to the dwelling's design heat loss so SAP
# 10.2 Appendix N reads the heat-pump efficiency near its PSR peak (~1.0), rather
# than a single fixed unit that is grossly undersized on high-heat-loss dwellings
# — where the PSR collapses and the ASHP scores at/below the resistance baseline.
# 110257 (the 5 kW rung) is the previously-fixed anchor, validated against the
# relodged after-cert.
_ASHP_SIZING_LADDER: tuple[tuple[float, int], ...] = (
(3.76, 110249), # aroTHERM plus 3.5 kW
(4.37, 110257), # aroTHERM plus 5 kW
(6.40, 110265), # aroTHERM plus 7 kW
(7.93, 110273), # aroTHERM plus 10 kW
(11.48, 110281), # aroTHERM plus 12 kW
)
def select_ashp_pcdb_id(design_heat_loss_kw: float) -> int:
"""The PCDB heat-pump record to install for a dwelling with
`design_heat_loss_kw`: the smallest ladder rung whose rated output meets the
load (PSR >= 1.0), capped at the largest rung when the load exceeds the
domestic aroTHERM plus range (ADR-0049)."""
for max_output_kw, pcdb_id in _ASHP_SIZING_LADDER:
if max_output_kw >= design_heat_loss_kw:
return pcdb_id
return _ASHP_SIZING_LADDER[-1][1]
# --- Gas boiler upgrade (Heating/HW expansion): replace an existing wet boiler
# with a modern gas condensing boiler. Validated against Elmhurst before/after
# re-lodgements (cert 001431): the upgrade always targets mains gas — gas->gas
# directly, and a non-gas wet boiler (oil/LPG/solid) ->gas ONLY where a mains-gas
# connection is present (electric boilers are left alone; electrification is the
# national target). The end-state is a Table 4b SAP code (not a PCDB index): code
# 102 for a regular boiler heating a hot-water cylinder, code 104 for a combi
# (no cylinder, a later slice). The calculator derives the condensing-boiler
# seasonal efficiency from the code, so no efficiency input is needed. ---
# Mains-gas main/water fuel code (Elmhurst -> SAP10 Table 12).
_MAINS_GAS_FUEL = 26
# Table 4a heat-emitter code for radiators (the wet-distribution end-state).
_RADIATOR_EMITTER = 1
# Table 4b SAP main-heating codes for the new gas condensing boiler: code 102
# for a regular boiler heating a cylinder, code 104 for a combi (no cylinder).
_REGULAR_GAS_BOILER_SAP_CODE = 102
_COMBI_GAS_BOILER_SAP_CODE = 104
# Water-heating code 901 — hot water from the main heating system.
_WATER_FROM_MAIN_SYSTEM_CODE = 901
# Elmhurst boiler flue type for the new condensing boiler (room-sealed/balanced);
# every relodged after lodges type 2. SAP-inert, written for end-state fidelity.
_CONDENSING_BOILER_FLUE_TYPE = 2
# Controls upgrade (SAP 10.2 Table 4e Group 1, PDF p.172): bring an inadequate
# boiler control up to full programmer + room thermostat + TRVs (code 2106).
# "Inadequate" = the Group-1 codes whose description carries NO room thermostat
# (2101 no control, 2102 programmer-only, 2107/2108/2109 programmer+TRVs without
# a room thermostat, 2111 TRVs and bypass) — these lack boiler interlock (Table
# 4c(2) / footnote c)), so adding a room thermostat is a genuine improvement.
# Controls with a room thermostat (2103/2104/2105/2106/2113) or better time-and-
# temperature zone control (2110/2112) are left unchanged — never downgraded.
_FULL_BOILER_CONTROL = 2106
_INADEQUATE_BOILER_CONTROL_CODES: frozenset[int] = frozenset(
{2101, 2102, 2107, 2108, 2109, 2111}
)
# System tune-up control end-states (SAP 10.2 Table 4e Group 1): the two best
# competing control upgrades offered while KEEPING the existing boiler —
# "standard" (programmer + room thermostat + TRVs, code 2106) and "zone"
# (time-and-temperature zone control, code 2110, type 3). Zone gives more SAP
# uplift for more cost, so the Optimiser steps to it when its extra SAP is
# needed (ADR-0024).
_STANDARD_CONTROL = _FULL_BOILER_CONTROL # 2106
_ZONE_CONTROL = 2110
# Controls already providing standard (2106) or better — a standard tune-up
# would be a no-op or a downgrade, so it is not offered to these.
_STANDARD_OR_BETTER_CONTROL_CODES: frozenset[int] = frozenset({2106, 2110, 2112})
# Controls already providing zone control (type 3) — a zone tune-up is not
# offered to these.
_ZONE_CONTROL_CODES: frozenset[int] = frozenset({2110, 2112})
# Wet-boiler SAP main_heating_code ranges (SAP 10.2 Table 4a + 4b): gas/oil
# boilers 101-141, solid-fuel boilers 151-161, electric boilers 191-196 (held
# locally so the generator does not depend on the calculator's internals,
# mirroring `domain/sap10_calculator/rdsap/cert_to_inputs.py`). Electric boilers
# are a wet system but are deliberately not upgraded to gas.
_WET_BOILER_SAP_CODE_RANGES: tuple[range, ...] = (
range(101, 142),
range(151, 162),
range(191, 197),
)
_ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197)
# RdSAP main_heating_category values that denote a wet central-heating boiler:
# 1 ("central heating with separate HW") and 2 ("boiler and radiators"). Both
# imply a wet primary loop — the fuel (not the category) says gas/oil/electric.
# Used only for the fallback classification of a lodgement that carries a
# category but no `sap_main_heating_code` (mirrors the calculator's
# `cert_to_inputs._is_wet_boiler_main`).
_WET_BOILER_CATEGORIES: frozenset[int] = frozenset({1, 2})
# Electricity main-fuel codes: the SAP Table 12 code (30, Elmhurst/cascade) and
# the raw gov-API enum (29). An electric-fuelled boiler is the electrification
# path, not a gas swap, so it is excluded from the gas-boiler upgrade even when
# the SAP code is absent (the code range can't identify it).
_ELECTRICITY_FUEL_CODES: frozenset[int] = frozenset({29, 30})
# Cylinder jacket end-state (from the after-cert): an 80 mm jacket
# (`cylinder_insulation_type=2`). The jacket is added only when the existing
# cylinder is below this thickness — bringing every cylinder up to 80 mm and
# never downgrading a better-insulated one.
_CYLINDER_JACKET_INSULATION_TYPE = 2
_MIN_CYLINDER_INSULATION_MM = 80
# The new condensing boiler's winter efficiency: SAP 10.2 Table 4b codes 102
# (regular condensing) and 104 (condensing combi) both lodge 84% winter. A
# like-for-like gas swap onto an existing gas boiler that already meets this
# gains nothing, so it is not offered (the dwelling gets a tune-up instead). The
# gate is gas-only: a non-gas boiler → gas is a fuel switch whose value is not
# captured by winter efficiency alone, so it is never suppressed on efficiency.
_NEW_BOILER_WINTER_EFFICIENCY_PCT = 84.0
# --- ASHP cost interpretation (ADR-0025): read the dwelling into the typed
# inputs the catalogue math needs. The modelling-layer half of the split; the
# pricing itself lives on `Products`. ---
# A dwelling at or below this floor area is treated as a 1-2 bed property (only
# affects the electric-storage decommission line — a £270 swing).
_SMALL_PROPERTY_MAX_M2 = 75.0
# Design heat loss proxy: industry rule of thumb ~50 W per m2 of floor area.
# The cost pump-size band is a minor lever, so this floor-area proxy is used in
# preference to the calculator's HLC (ADR-0025).
_KW_PER_M2 = 0.05
# Radiators ~= habitable rooms + kitchen + hall + bathroom (RdSAP excludes the
# latter three from habitable rooms); fallback ~1 radiator per 13 m2.
_RADIATOR_ROOM_OFFSET = 3
_RADIATOR_M2_PER_RADIATOR = 13.0
# main_fuel_type codes (gov API enum and/or Table 12) by fuel. Classification
# keys on the heating *fuel*, NOT the `mains_gas` flag — that flag means gas is
# available at the property, which is True even for electrically-heated dwellings
# on a gas street (every 001431 electric fixture lodges mains_gas=True).
_GAS_FUEL_CODES = frozenset({26, 1})
_OIL_FUEL_CODES = frozenset({28, 4, 71, 73, 75, 76})
_LPG_FUEL_CODES = frozenset({27, 2, 3, 5, 9})
def ashp_cost_inputs(epc: EpcPropertyData) -> AshpCostInputs:
"""Read an `EpcPropertyData` into the typed inputs `Products.ashp_bundle_cost`
needs: the existing system, property-size band, design heat loss (floor-area
proxy), radiator count, and whether a wet system can be reused (ADR-0025)."""
system: AshpExistingSystem = _existing_system(epc)
floor_area: float = epc.total_floor_area_m2
return AshpCostInputs(
existing_system=system,
is_small_property=floor_area <= _SMALL_PROPERTY_MAX_M2,
design_heat_loss_kw=floor_area * _KW_PER_M2,
radiator_count=_radiator_count(epc),
has_reusable_wet_system=system
in (AshpExistingSystem.GAS, AshpExistingSystem.OIL, AshpExistingSystem.LPG),
)
def _existing_system(epc: EpcPropertyData) -> AshpExistingSystem:
"""Classify the dwelling's pre-retrofit system for decommission + reuse,
keyed on the heating *fuel code* (not the misleading `mains_gas` flag).
Electricity, gas, oil and LPG map to their categories; a dwelling with no
lodged main system to NONE; anything unrecognised to OTHER (which prices the
gas-line decommission fallback). The storage-vs-other-electric split is
deliberately not made — both price the same decommission line (ADR-0025)."""
details: list[MainHeatingDetail] = epc.sap_heating.main_heating_details
if not details:
return AshpExistingSystem.NONE
fuel = details[0].main_fuel_type
if fuel == _ELECTRICITY_FUEL:
return AshpExistingSystem.ELECTRIC_STORAGE
if fuel in _GAS_FUEL_CODES:
return AshpExistingSystem.GAS
if fuel in _OIL_FUEL_CODES:
return AshpExistingSystem.OIL
if fuel in _LPG_FUEL_CODES:
return AshpExistingSystem.LPG
return AshpExistingSystem.OTHER
def _radiator_count(epc: EpcPropertyData) -> int:
"""Estimate radiators from habitable rooms (+ kitchen/hall/bathroom), or
from floor area when the room count is missing (ADR-0025). Products clamps
to its distribution table bounds."""
habitable: int = epc.habitable_rooms_count
if habitable > 0:
return habitable + _RADIATOR_ROOM_OFFSET
return round(epc.total_floor_area_m2 / _RADIATOR_M2_PER_RADIATOR)
def recommend_heating(
epc: EpcPropertyData,
products: ProductRepository,
restrictions: PlanningRestrictions = PlanningRestrictions(),
considered_measures: Optional[frozenset[MeasureType]] = None,
) -> 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)
boiler_option = _boiler_upgrade_option(epc, products)
if boiler_option is not None:
options.append(boiler_option)
options.extend(_system_tune_up_options(epc, products, considered_measures))
if not options:
return None
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
def _system_tune_up_options(
epc: EpcPropertyData,
products: ProductRepository,
considered_measures: Optional[frozenset[MeasureType]] = None,
) -> list[MeasureOption]:
"""The system tune-up options: keep the existing wet boiler but install
better heating controls (standard 2106 and/or zone 2110, as competing
options) and fix the cylinder (jacket when under-insulated, thermostat when
absent). Each control option is offered only when it genuinely improves the
existing controls — never a downgrade or a no-op (ADR-0024)."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if not _is_wet_boiler(main):
return []
control = main.main_heating_control
control_code: Optional[int] = control if isinstance(control, int) else None
options: list[MeasureOption] = []
if control_code not in _STANDARD_OR_BETTER_CONTROL_CODES:
options.append(
_tune_up_option(
epc,
products,
measure_type=_SYSTEM_TUNE_UP_MEASURE_TYPE,
control=_STANDARD_CONTROL,
description=(
"Tune up the heating: install a programmer, room thermostat "
"and TRVs and insulate and thermostat the hot-water cylinder"
),
)
)
if control_code not in _ZONE_CONTROL_CODES and (
considered_measures is None
or _SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE in considered_measures
):
options.append(
_tune_up_option(
epc,
products,
measure_type=_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE,
control=_ZONE_CONTROL,
description=(
"Tune up the heating: install time-and-temperature zone "
"control and insulate and thermostat the hot-water cylinder"
),
)
)
return options
def _tune_up_option(
epc: EpcPropertyData,
products: ProductRepository,
*,
measure_type: MeasureType,
control: int,
description: str,
) -> MeasureOption:
"""One tune-up Option: the existing boiler is kept; only the heating control
and the conditional cylinder fixes change. Cost is composed per dwelling from
those components (ADR-0027); the catalogue row is read for its id."""
product = products.get(measure_type)
cost: Cost = Products().tune_up_cost(
tune_up_cost_inputs(epc, is_zoned=control == _ZONE_CONTROL)
)
return MeasureOption(
measure_type=measure_type,
description=description,
overlay=EpcSimulation(heating=_tune_up_overlay(epc, control)),
cost=cost,
material_id=product.id,
)
def _tune_up_overlay(epc: EpcPropertyData, control: int) -> HeatingOverlay:
"""Build a tune-up end-state: set the heating control to ``control`` and
apply the conditional cylinder fixes (an 80 mm jacket when under-insulated, a
thermostat when absent) — only when the dwelling has a cylinder. The boiler,
fuel and meter are left unchanged (the boiler is kept)."""
sap_heating = epc.sap_heating
jacket_type: Optional[int] = None
jacket_thickness_mm: Optional[int] = None
thermostat: Optional[str] = None
if epc.has_hot_water_cylinder:
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
if sap_heating.cylinder_thermostat != "Y":
thermostat = "Y"
return HeatingOverlay(
main_heating_control=control,
cylinder_insulation_type=jacket_type,
cylinder_insulation_thickness_mm=jacket_thickness_mm,
cylinder_thermostat=thermostat,
)
def _boiler_upgrade_option(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[MeasureOption]:
"""The gas-condensing-boiler upgrade for a dwelling with an existing wet
boiler: a combi (Table 4b code 104) where there is no cylinder, or a regular
boiler (code 102) heating the existing cylinder where there is one. Both
upgrade inadequate controls and the cylinder variant adds the conditional
cylinder fixes (a jacket when under-insulated, a thermostat when absent). One
Option per dwelling — a dwelling has a cylinder or it does not — offered only
where a mains-gas connection makes the gas end-state installable (ADR-0024
revised)."""
if not _boiler_upgrade_eligible(epc):
return None
has_cylinder: bool = epc.has_hot_water_cylinder
overlay: HeatingOverlay = (
_boiler_cylinder_overlay(epc) if has_cylinder else _boiler_combi_overlay(epc)
)
description: str = (
"Replace the boiler with a gas condensing boiler and insulate and "
"thermostat the hot-water cylinder"
if has_cylinder
else "Replace the boiler with a gas condensing combi boiler"
)
# Cost is composed per dwelling from the boiler + the controls/cylinder
# fixes the overlay installs (ADR-0027), not the flat catalogue scalar; the
# catalogue row is still read for its id.
product = products.get(_GAS_BOILER_UPGRADE_MEASURE_TYPE)
cost: Cost = Products().boiler_bundle_cost(boiler_cost_inputs(epc))
return MeasureOption(
measure_type=_GAS_BOILER_UPGRADE_MEASURE_TYPE,
description=description,
overlay=EpcSimulation(heating=overlay),
cost=cost,
material_id=product.id,
)
def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool:
"""Whether a dwelling's existing wet boiler can be upgraded to a gas
condensing boiler. The gas end-state is installable only with a mains-gas
connection, so gas dwellings always qualify and a non-gas wet boiler
(oil/LPG/solid) qualifies only where mains gas is present. Electric boilers
are left alone — electrification, not a gas swap, is their upgrade path. A
gas boiler that already meets the new condensing efficiency is not re-offered
a like-for-like swap (it gains nothing — the dwelling gets a tune-up
instead); a non-gas boiler is a fuel switch, so it is never gated on
efficiency."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if not _is_wet_boiler(main):
return False
if _is_electric_boiler(main):
return False
if not epc.sap_energy_source.gas_connection_available:
return False
if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(main):
return False
return True
def _is_wet_boiler(main: MainHeatingDetail) -> bool:
"""Whether the main system is a wet boiler the tune-up / boiler-upgrade
generators can evaluate. Identifies by the Table 4a/4b `sap_main_heating_code`
when lodged; when it is absent, falls back to the gov-EPC-API lodgement shape
(a PCDB Table 105 gas/oil boiler index, or an RdSAP `main_heating_category`
of 1/2 "central heating") — the same fallback the calculator's
`cert_to_inputs._is_wet_boiler_main` uses, so the generators evaluate the
same population the calculator already models as wet boilers (#1388 A#2).
Heat pumps (category 4) are never wet boilers."""
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
return False
code: Optional[int] = main.sap_main_heating_code
if code is not None:
return any(code in r for r in _WET_BOILER_SAP_CODE_RANGES)
index: Optional[int] = main.main_heating_index_number
if index is not None and gas_oil_boiler_record(index) is not None:
return True
return main.main_heating_category in _WET_BOILER_CATEGORIES
def _is_electric_boiler(main: MainHeatingDetail) -> bool:
"""Whether the wet boiler runs on electricity — the electrification path, not
a gas swap, so it is excluded from the gas-boiler upgrade. Identified by the
Table 4a electric-boiler code range when lodged, else by an electricity main
fuel (the code range can't identify an electric boiler lodged with no SAP
code)."""
code: Optional[int] = main.sap_main_heating_code
if code is not None:
return code in _ELECTRIC_BOILER_SAP_CODE_RANGE
return main.main_fuel_type in _ELECTRICITY_FUEL_CODES
def _already_condensing(main: MainHeatingDetail) -> bool:
"""Whether an existing gas boiler already meets the new condensing boiler's
winter efficiency. Uses the SAP 10.2 Table 4b figure for the lodged
`sap_main_heating_code`; when no code is lodged (the gov-EPC-API PCDB cohort),
reads the boiler's declared winter efficiency from its PCDB Table 105 record
instead. A boiler whose efficiency cannot be resolved (no Table 4b entry, no
PCDB record) is not treated as already-condensing — the upgrade is offered."""
code: Optional[int] = main.sap_main_heating_code
if code is not None:
efficiencies: Optional[tuple[float, float]] = (
table_4b_seasonal_efficiencies_pct(code)
)
if efficiencies is None:
return False
return efficiencies[0] >= _NEW_BOILER_WINTER_EFFICIENCY_PCT
index: Optional[int] = main.main_heating_index_number
if index is not None:
record = gas_oil_boiler_record(index)
if record is not None and record.winter_efficiency_pct is not None:
return record.winter_efficiency_pct >= _NEW_BOILER_WINTER_EFFICIENCY_PCT
return False
def _boiler_combi_overlay(epc: EpcPropertyData) -> HeatingOverlay:
"""Build the per-dwelling combi end-state: a gas condensing combi (Table 4b
code 104, fanned flue) on radiators with hot water from the boiler, plus a
controls upgrade when the existing controls are inadequate. No cylinder, so
no cylinder fields are touched."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
return HeatingOverlay(
main_fuel_type=_MAINS_GAS_FUEL,
heat_emitter_type=_RADIATOR_EMITTER,
sap_main_heating_code=_COMBI_GAS_BOILER_SAP_CODE,
fan_flue_present=True,
boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE,
main_heating_control=_upgraded_boiler_control(main),
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
water_heating_fuel=_MAINS_GAS_FUEL,
)
def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay:
"""Build the per-dwelling boiler-with-cylinder end-state: a regular gas
condensing boiler on radiators, hot water from the main system, a controls
upgrade when the existing controls are inadequate, and the conditional
cylinder fixes — an 80 mm jacket only when the cylinder is under-insulated, a
thermostat only when one is absent. The existing cylinder size and meter are
left unchanged."""
sap_heating = epc.sap_heating
main: MainHeatingDetail = sap_heating.main_heating_details[0]
jacket_type: Optional[int] = None
jacket_thickness_mm: Optional[int] = None
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
thermostat: Optional[str] = "Y" if sap_heating.cylinder_thermostat != "Y" else None
return HeatingOverlay(
main_fuel_type=_MAINS_GAS_FUEL,
heat_emitter_type=_RADIATOR_EMITTER,
sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE,
fan_flue_present=True,
boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE,
main_heating_control=_upgraded_boiler_control(main),
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
water_heating_fuel=_MAINS_GAS_FUEL,
cylinder_insulation_type=jacket_type,
cylinder_insulation_thickness_mm=jacket_thickness_mm,
cylinder_thermostat=thermostat,
has_hot_water_cylinder=True,
)
def _cylinder_under_insulated(thickness_mm: Optional[int]) -> bool:
"""Whether a hot-water cylinder is below the 80 mm jacket end-state (an
un-jacketed cylinder lodges no thickness)."""
return thickness_mm is None or thickness_mm < _MIN_CYLINDER_INSULATION_MM
def _upgraded_boiler_control(main: MainHeatingDetail) -> Optional[int]:
"""The full-controls code (2106) when the existing boiler control is
inadequate (lacks a room thermostat — SAP 10.2 Table 4e Group 1), else
``None`` to leave a room-thermostatted or better control unchanged. So the
overlay only ever moves controls where it genuinely improves them."""
control = main.main_heating_control
code: Optional[int] = control if isinstance(control, int) else None
if code is None and isinstance(control, str) and control.isdigit():
code = int(control)
if code in _INADEQUATE_BOILER_CONTROL_CODES:
return _FULL_BOILER_CONTROL
return None
# --- Boiler / tune-up cost interpretation (ADR-0027): read the dwelling into the
# typed inputs the catalogue math needs. The pricing itself lives on `Products`;
# this is the modelling-layer half that the catalogue stays free of. ---
# SAP 10.2 Table 4e Group 1 (PDF p.172) — which standard-control parts each
# boiler control code already provides: (has_programmer, has_room_thermostat,
# has_TRVs). Lets the standard-controls cost charge only the missing parts to
# reach 2106 (programmer + room thermostat + TRVs). Zone codes (2110/2112) are
# omitted — a standard upgrade is never offered to them.
_CONTROL_FEATURES_BY_CODE: dict[int, tuple[bool, bool, bool]] = {
2101: (False, False, False), # No time or thermostatic control
2102: (True, False, False), # Programmer, no room thermostat
2103: (False, True, False), # Room thermostat only
2104: (True, True, False), # Programmer and room thermostat
2105: (True, True, False), # Programmer and at least two room thermostats
2106: (True, True, True), # Programmer, room thermostat and TRVs
2107: (True, False, True), # Programmer, TRVs and bypass
2108: (True, False, True), # Programmer, TRVs and flow switch
2109: (True, False, True), # Programmer, TRVs and boiler energy manager
2111: (False, False, True), # TRVs and bypass
2113: (False, True, True), # Room thermostat and TRVs
}
def _control_features(main: MainHeatingDetail) -> tuple[bool, bool, bool]:
"""The standard-control parts a dwelling already has, from its SAP control
code. An unrecognised/absent code defaults to none present (charge the full
standard kit) — conservative, and the standard option is only offered when
the control is improvable anyway."""
control = main.main_heating_control
code: Optional[int] = control if isinstance(control, int) else None
return (
_CONTROL_FEATURES_BY_CODE.get(code, (False, False, False))
if (code is not None)
else (False, False, False)
)
def _cylinder_fix_needs(epc: EpcPropertyData) -> tuple[bool, bool]:
"""Whether the dwelling needs a cylinder jacket and/or a thermostat — the
same predicates the overlay uses (only when a cylinder exists)."""
if not epc.has_hot_water_cylinder:
return (False, False)
sap_heating = epc.sap_heating
needs_jacket: bool = _cylinder_under_insulated(
sap_heating.cylinder_insulation_thickness_mm
)
needs_thermostat: bool = sap_heating.cylinder_thermostat != "Y"
return (needs_jacket, needs_thermostat)
def tune_up_cost_inputs(epc: EpcPropertyData, *, is_zoned: bool) -> TuneUpCostInputs:
"""Read a dwelling into the inputs `Products.tune_up_cost` needs: the control
level, the radiator count (per-radiator items), the standard-control parts
already fitted, and the cylinder fixes that apply (ADR-0027)."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
has_programmer, has_room_thermostat, has_trvs = _control_features(main)
needs_jacket, needs_thermostat = _cylinder_fix_needs(epc)
return TuneUpCostInputs(
is_zoned=is_zoned,
radiator_count=_radiator_count(epc),
has_programmer=has_programmer,
has_room_thermostat=has_room_thermostat,
has_trvs=has_trvs,
needs_cylinder_jacket=needs_jacket,
needs_cylinder_thermostat=needs_thermostat,
)
def boiler_cost_inputs(epc: EpcPropertyData) -> BoilerCostInputs:
"""Read a dwelling into the inputs `Products.boiler_bundle_cost` needs: the
boiler is always priced; controls are added only when the upgrade fires a
controls change, and the cylinder fixes when applicable (ADR-0027)."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
has_programmer, has_room_thermostat, has_trvs = _control_features(main)
needs_jacket, needs_thermostat = _cylinder_fix_needs(epc)
return BoilerCostInputs(
upgrades_controls=_upgraded_boiler_control(main) is not None,
radiator_count=_radiator_count(epc),
has_programmer=has_programmer,
has_room_thermostat=has_room_thermostat,
has_trvs=has_trvs,
needs_cylinder_jacket=needs_jacket,
needs_cylinder_thermostat=needs_thermostat,
)
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
# Cost is composed per-dwelling from the rate sheet (ADR-0025), not the
# single catalogue scalar; the catalogue row is read only for its id, so an
# absent ASHP row must not suppress the bundle — it just carries no id.
product: Optional[Product] = products.get_optional(_ASHP_MEASURE_TYPE)
cost: Cost = Products().ashp_bundle_cost(ashp_cost_inputs(epc))
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,
material_id=product.id if product is not None else None,
)
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. Community heating (SAP Table 4a codes
301304 or category 6) is excluded by topology: replacing a shared heat
network with an individual ASHP is never appropriate."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
return False
if is_heat_network_main(main):
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 a non-gas-fuel dwelling, unless it is already HHR or a
heat pump. Eligibility keys on the heating *fuel* (main_fuel_type not in
_GAS_FUEL_CODES), not the gas-connection flag — a dwelling on a gas street
with electric or oil heating has gas_connection_available=True but still
qualifies. Community heating (SAP Table 4a codes 301304 or category 6) is
excluded by topology regardless of fuel: ripping out a shared heat network
to install storage heaters is never appropriate."""
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
if is_heat_network_main(main):
return False
return main.main_fuel_type not in _GAS_FUEL_CODES