mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-22 08:48:38 +00:00
Add the first boiler-upgrade option to the single "Heating & Hot Water" Recommendation (ADR-0024 expansion): a dwelling whose existing wet boiler heats a hot-water cylinder is offered a new gas condensing boiler, with the cylinder jacketed when under-insulated and given a thermostat when absent. One competing Option (the Optimiser picks <=1), folded into one composite Plan line. The end-state is read from the Elmhurst before/after re-lodgements (cert 001431, gas boiler upgrade - with cylinder), which REVISE ADR-0024: - Target is always a gas condensing boiler, not fuel-preserving: every after lodges fuel 26. Gas->gas always; a non-gas wet boiler ->gas only with a mains-gas connection; electric boilers are left alone (electrification is the upgrade path). Eligibility = wet-boiler SAP code (Table 4a/4b 101-141 / 151-161 / 191-196) + not an electric boiler + mains gas present. - End-state is a Table 4b SAP code, not a PCDB index: code 102 (regular boiler + cylinder). The calculator derives the condensing seasonal efficiency from the code, so no efficiency input exists or is needed. - A modern condensing boiler has a fanned flue: the after flips `fan_flue_present` False->True on every cert (SAP 10.2 Table 4f flue-fan + the Table 4b condensing-efficiency basis). Added as a new HeatingOverlay field, routed to main_heating_details[0]. - Cylinder thermostat is always added when absent (user-locked); the jacket is the 80 mm `cylinder_insulation_type=2` end-state, applied only when the cylinder is below 80 mm (never downgrading a better one). Both are conditional per-dwelling components, not a frozen overlay. Cascade-pinned delta-0 (SAP/CO2/PE) against the relodged after via `_assert_overlay_reproduces_after`. NB the absolute SAP on this dwelling is subject to a separate Summary-path mapper roof-fidelity gap (we read the roof better-insulated than Elmhurst, scoring ~75 vs the printed 56); the gap is identical on before+after (the boiler measure never touches the roof) so it cancels and the pin still proves the exact heating field-delta. Tracked on the calculator branch. Wires the new `gas_boiler_upgrade` MeasureType through contingencies (0.26), the offline sample catalogue, the catalogue-coverage list, and the ARA first-run integration seed (the option fires on any mains-gas boiler+cylinder dwelling). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
417 lines
19 KiB
Python
417 lines
19 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.products import AshpCostInputs, AshpExistingSystem, Products
|
|
from domain.modelling.measure_type import MeasureType
|
|
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 = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS
|
|
_ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP
|
|
_GAS_BOILER_UPGRADE_MEASURE_TYPE = MeasureType.GAS_BOILER_UPGRADE
|
|
|
|
# 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",
|
|
)
|
|
|
|
# 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",
|
|
mains_gas=False,
|
|
)
|
|
|
|
|
|
# --- 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 code for a regular gas boiler heating a cylinder.
|
|
_REGULAR_GAS_BOILER_SAP_CODE = 102
|
|
# Water-heating code 901 — hot water from the main heating system.
|
|
_WATER_FROM_MAIN_SYSTEM_CODE = 901
|
|
|
|
# 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)
|
|
|
|
# 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
|
|
|
|
|
|
# --- 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(),
|
|
) -> 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_with_cylinder_option(epc, products)
|
|
if boiler_option is not None:
|
|
options.append(boiler_option)
|
|
|
|
if not options:
|
|
return None
|
|
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
|
|
|
|
|
|
def _boiler_upgrade_with_cylinder_option(
|
|
epc: EpcPropertyData, products: ProductRepository
|
|
) -> Optional[MeasureOption]:
|
|
"""The gas-condensing-boiler-with-cylinder bundle: a new regular gas boiler
|
|
(Table 4b code 102, fanned flue) for a dwelling whose existing wet boiler
|
|
heats a hot-water cylinder, plus the conditional cylinder fixes (a jacket
|
|
when under-insulated, a thermostat when absent). Offered only where a
|
|
mains-gas connection makes the gas end-state installable (ADR-0024 revised)."""
|
|
if not _boiler_upgrade_eligible(epc):
|
|
return None
|
|
if not epc.has_hot_water_cylinder:
|
|
return None
|
|
product = products.get(_GAS_BOILER_UPGRADE_MEASURE_TYPE)
|
|
return MeasureOption(
|
|
measure_type=_GAS_BOILER_UPGRADE_MEASURE_TYPE,
|
|
description=(
|
|
"Replace the boiler with a gas condensing boiler and insulate and "
|
|
"thermostat the hot-water cylinder"
|
|
),
|
|
overlay=EpcSimulation(heating=_boiler_cylinder_overlay(epc)),
|
|
cost=Cost(
|
|
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
|
|
),
|
|
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."""
|
|
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
|
code: Optional[int] = main.sap_main_heating_code
|
|
if code is None:
|
|
return False
|
|
if not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
|
|
return False
|
|
if code in _ELECTRIC_BOILER_SAP_CODE_RANGE:
|
|
return False
|
|
return epc.sap_energy_source.mains_gas
|
|
|
|
|
|
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, 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, heating controls, and meter are left unchanged."""
|
|
sap_heating = epc.sap_heating
|
|
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,
|
|
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 _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 still read for its id.
|
|
product = products.get(_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,
|
|
)
|
|
|
|
|
|
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
|