"""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, 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.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, 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", 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 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) # 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] code: Optional[int] = main.sap_main_heating_code if code is None or not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES): 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] 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 if not epc.sap_energy_source.mains_gas: return False if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(code): return False return True def _already_condensing(sap_main_heating_code: int) -> bool: """Whether an existing gas boiler already meets the new condensing boiler's winter efficiency (SAP 10.2 Table 4b). Non-Table-4b codes (e.g. solid fuel) have no comparable efficiency and so are never treated as already-condensing.""" efficiencies: Optional[tuple[float, float]] = table_4b_seasonal_efficiencies_pct( sap_main_heating_code ) if efficiencies is None: return False winter_efficiency_pct: float = efficiencies[0] return winter_efficiency_pct >= _NEW_BOILER_WINTER_EFFICIENCY_PCT 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.""" 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