diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 4c018635..d1300338 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -15,6 +15,8 @@ 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 @@ -22,6 +24,7 @@ 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 @@ -51,23 +54,103 @@ _HHR_STORAGE_OVERLAY = HeatingOverlay( 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, + 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 + 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.""" + 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]: diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 5ecf8c44..90203d0f 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -6,6 +6,7 @@ later slices. Detection + pricing only; impact is produced by scoring (ADR-0016) """ from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.generators.heating_recommendation import recommend_heating from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation @@ -83,14 +84,21 @@ def test_on_gas_boiler_dwelling_yields_no_hhr_storage_bundle() -> None: } -def test_already_hhr_storage_dwelling_yields_no_bundle() -> None: +def test_already_hhr_storage_dwelling_yields_no_hhr_bundle() -> None: # Arrange — an electric dwelling already on HHR storage (Table 4a code 409) - # must not be told to install HHR storage again. + # must not be told to install HHR storage again (ASHP may still be offered as + # a better end-state — it is a house here). baseline: EpcPropertyData = _electric_storage_baseline() baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 409 - # Act / Assert - assert recommend_heating(baseline, _StubProducts()) is None + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no HHRSH bundle. + if recommendation is not None: + assert "high_heat_retention_storage_heaters" not in { + o.measure_type for o in recommendation.options + } def test_existing_heat_pump_dwelling_yields_no_hhr_storage_bundle() -> None: @@ -120,3 +128,99 @@ def test_hhr_storage_bundle_carries_the_product_cost_and_contingency() -> None: assert option.cost is not None assert abs(option.cost.total - 3500.0) <= 1e-9 assert abs(option.cost.contingency_rate - 0.26) <= 1e-9 + + +def _gas_boiler_house() -> EpcPropertyData: + """A 000490 mains-gas combi dwelling, explicitly a House — ASHP-eligible.""" + epc: EpcPropertyData = build_epc() + epc.property_type = "House" + return epc + + +def test_gas_boiler_house_yields_an_ashp_bundle() -> None: + # Arrange — a mains-gas house; ASHP is offered for any non-flat house/ + # bungalow regardless of current system or efficiency (ADR-0024). + baseline: EpcPropertyData = _gas_boiler_house() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the ASHP bundle carries the absolute heat-pump end-state. + assert recommendation is not None + options = {o.measure_type: o for o in recommendation.options} + assert "air_source_heat_pump" in options + assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay( + main_fuel_type=30, + main_heating_control=2210, + main_heating_index_number=101413, + main_heating_category=4, + water_heating_fuel=30, + 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 test_listed_building_yields_no_ashp_bundle() -> None: + # Arrange — a listed building protects the fabric; an external ASHP unit is + # not auto-offered (ADR-0024). The dwelling is on gas, so HHRSH is also out. + baseline: EpcPropertyData = _gas_boiler_house() + + # Act + recommendation: Recommendation | None = recommend_heating( + baseline, _StubProducts(), PlanningRestrictions(is_listed=True) + ) + + # Assert + assert recommendation is None + + +def test_conservation_area_still_yields_an_ashp_bundle() -> None: + # Arrange — unlike glazing, a conservation area does NOT exclude ASHP; it is + # offered with a planning caveat (ADR-0024, research-grounded). + baseline: EpcPropertyData = _gas_boiler_house() + + # Act + recommendation: Recommendation | None = recommend_heating( + baseline, _StubProducts(), PlanningRestrictions(in_conservation_area=True) + ) + + # Assert + assert recommendation is not None + assert "air_source_heat_pump" in {o.measure_type for o in recommendation.options} + + +def test_flat_yields_no_ashp_bundle() -> None: + # Arrange — flats are not auto-offered an individual ASHP (siting/lease/ + # MCS-020 need a survey — ADR-0024). + baseline: EpcPropertyData = _gas_boiler_house() + baseline.property_type = "Flat" + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no ASHP (and the gas flat is not HHRSH-eligible either → None). + if recommendation is not None: + assert "air_source_heat_pump" not in { + o.measure_type for o in recommendation.options + } + + +def test_existing_heat_pump_yields_no_ashp_bundle() -> None: + # Arrange — a dwelling already on a heat pump (category 4) is not re-offered + # an ASHP. + baseline: EpcPropertyData = _gas_boiler_house() + baseline.sap_heating.main_heating_details[0].main_heating_category = 4 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + if recommendation is not None: + assert "air_source_heat_pump" not in { + o.measure_type for o in recommendation.options + }