mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
820 lines
39 KiB
Python
820 lines
39 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, 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)."""
|
||
raise NotImplementedError
|
||
|
||
|
||
# --- 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
|
||
301–304 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 301–304 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
|