"""The heating Recommendation Generator. Detects a dwelling whose heating system should be replaced and emits one "Heating & Hot Water" Recommendation of competing whole-system bundles — the Optimiser picks at most one (ADR-0024). Each bundle is a whole-system change: main heating + controls + fuel + meter + the implied hot water, folded into one Measure Option's `HeatingOverlay`. Hot water is never a separate competing measure; the legacy heating-vs-HW split double-counted. This slice covers the high-heat-retention storage (HHRSH) bundle; the ASHP and boiler bundles land in later slices. Detection + pricing only — impact is produced by scoring (ADR-0016). """ from typing import Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import EpcSimulation, HeatingOverlay from repositories.product.product_repository import ProductRepository _HEATING_SURFACE = "Heating & Hot Water" _HHR_STORAGE_MEASURE_TYPE = "high_heat_retention_storage_heaters" _ASHP_MEASURE_TYPE = "air_source_heat_pump" # Electricity main-fuel code (Elmhurst → SAP10 Table 12). _ELECTRICITY_FUEL = 30 # Table 4a SAP main-heating code for high-heat-retention storage heaters; an # existing HHR system lodges this already, so it is not re-recommended. _HHR_STORAGE_SAP_CODE = 409 # RdSAP main_heating_category for a heat pump (Table 4a) — an existing heat pump # is never downgraded to storage heaters. _HEAT_PUMP_CATEGORY = 4 # The HHRSH bundle's absolute end-state (ADR-0024): high-heat-retention storage # heaters (Table 4a code 409) on a dual off-peak meter, with an off-peak # electric immersion hot-water cylinder. Pinned against the relodged after-cert # in the cascade tests; `mains_gas` and the heat emitter are unchanged by this # measure, so they are not written. _HHR_STORAGE_OVERLAY = HeatingOverlay( main_fuel_type=_ELECTRICITY_FUEL, sap_main_heating_code=_HHR_STORAGE_SAP_CODE, main_heating_control=2404, water_heating_code=903, water_heating_fuel=_ELECTRICITY_FUEL, cylinder_size=2, cylinder_insulation_type=1, cylinder_insulation_thickness_mm=120, cylinder_thermostat="Y", has_hot_water_cylinder=True, meter_type="Dual", ) # The ASHP bundle's absolute end-state (ADR-0024): a fixed, representative, # contractor-installable heat pump (PCDB index 101413, RdSAP category 4) with # time-and-temperature-zone control (2210), a heat-pump hot-water cylinder, a # single (non off-peak) meter, and the dwelling switched off mains gas. The # index is the efficiency anchor — the applicator clears any stale # `sap_main_heating_code` when an index is set, so the calculator resolves the # heat pump's SCOP from the PCDB record. Pinned against the relodged after-cert. _ASHP_OVERLAY = HeatingOverlay( main_fuel_type=_ELECTRICITY_FUEL, main_heating_control=2210, main_heating_index_number=101413, main_heating_category=_HEAT_PUMP_CATEGORY, # Hot water from the main heat-pump system via the new cylinder (code 901, # "from main system"). Set absolutely so a combi (909/611) or electric # (903/908) before is reset to the fixed HP end-state, not just the case # where the before already lodged 901. water_heating_code=901, water_heating_fuel=_ELECTRICITY_FUEL, cylinder_size=4, cylinder_insulation_type=1, cylinder_insulation_thickness_mm=50, cylinder_thermostat="Y", has_hot_water_cylinder=True, meter_type="Single", mains_gas=False, ) def recommend_heating( epc: EpcPropertyData, products: ProductRepository, restrictions: PlanningRestrictions = PlanningRestrictions(), ) -> Optional[Recommendation]: """Return a "Heating & Hot Water" Recommendation of competing whole-system bundles for the dwelling, else None when no bundle is eligible. ASHP is additionally gated by the Property's planning protections (ADR-0024).""" options: list[MeasureOption] = [] hhr_option = _hhr_storage_option(epc, products) if hhr_option is not None: options.append(hhr_option) ashp_option = _ashp_option(epc, products, restrictions) if ashp_option is not None: options.append(ashp_option) if not options: return None return Recommendation(surface=_HEATING_SURFACE, options=tuple(options)) def _ashp_option( epc: EpcPropertyData, products: ProductRepository, restrictions: PlanningRestrictions, ) -> Optional[MeasureOption]: """The air-source heat-pump bundle, offered for any non-flat house/bungalow that is not listed/heritage and not already a heat pump.""" if not _ashp_eligible(epc, restrictions): return None product = products.get(_ASHP_MEASURE_TYPE) return MeasureOption( measure_type=_ASHP_MEASURE_TYPE, description=( "Replace the heating with an air-source heat pump, time-and-" "temperature-zone controls and a heat-pump hot-water cylinder" ), overlay=EpcSimulation(heating=_ASHP_OVERLAY), cost=Cost( total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate ), material_id=product.id, ) def _ashp_eligible(epc: EpcPropertyData, restrictions: PlanningRestrictions) -> bool: """ASHP suits any non-flat house/bungalow that is not already a heat pump and is not fabric-protected. Eligibility encodes only physical/planning installability — the Optimiser owns the economics (ADR-0024), so floor area, built form, fuel, and fabric are deliberately not gates. A conservation area does not exclude ASHP (offered with a planning caveat); a listed/heritage protection (`blocks_internal`) does.""" main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] if main.main_heating_category == _HEAT_PUMP_CATEGORY: return False if restrictions.blocks_internal: return False return _is_house_or_bungalow(epc) def _is_house_or_bungalow(epc: EpcPropertyData) -> bool: """Whether the dwelling is a house or bungalow (not a flat/maisonette). The Elmhurst path lodges the name; the API path a stringified RdSAP code (`PROPERTY_TYPE_LOOKUP`: 0 House, 1 Bungalow, 2 Flat, 3 Maisonette).""" raw: str = (epc.property_type or "").strip() if raw.lower() in ("house", "bungalow"): return True if raw.isdigit(): return PROPERTY_TYPE_LOOKUP.get(int(raw)) in ("House", "Bungalow") return False def _hhr_storage_option( epc: EpcPropertyData, products: ProductRepository ) -> Optional[MeasureOption]: """The high-heat-retention storage bundle, offered for an electrically-heated (or off-gas) dwelling that is not already HHR or a heat pump.""" if not _hhr_storage_eligible(epc): return None product = products.get(_HHR_STORAGE_MEASURE_TYPE) return MeasureOption( measure_type=_HHR_STORAGE_MEASURE_TYPE, description=( "Replace the heating with high heat retention storage heaters on an " "off-peak tariff, with an off-peak electric hot-water cylinder" ), overlay=EpcSimulation(heating=_HHR_STORAGE_OVERLAY), cost=Cost( total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate ), material_id=product.id, ) def _hhr_storage_eligible(epc: EpcPropertyData) -> bool: """HHR storage suits an electrically-heated or off-gas dwelling, unless it is already HHR or a heat pump (translated from legacy `HeatingRecommender. is_high_heat_retention_valid`, which keyed on description strings).""" main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] if main.sap_main_heating_code == _HHR_STORAGE_SAP_CODE: return False if main.main_heating_category == _HEAT_PUMP_CATEGORY: return False off_gas: bool = not epc.sap_energy_source.mains_gas electric_main: bool = main.main_fuel_type == _ELECTRICITY_FUEL return electric_main or off_gas