From 6f136a8d6a590f4a49317c0767858132e14db560 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 21:10:45 +0000 Subject: [PATCH] feat(modelling): classify ASHP existing system by fuel code Slice 8 of ADR-0025 costing. _existing_system keys on the heating fuel code, not the mains_gas flag -- the 001431 electric fixtures all lodge mains_gas=True (gas available at the property) while heating electrically (fuel 30), which the flag-based check misread as gas (and would have wrongly reused a non-existent wet system). Electric/gas/oil/LPG map to their categories; empty details -> NONE; unrecognised -> OTHER (gas-line fallback). Co-Authored-By: Claude Opus 4.8 --- .../generators/heating_recommendation.py | 34 ++++++------ .../domain/modelling/test_ashp_cost_inputs.py | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 7fed266c..ea2c93b4 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -125,9 +125,11 @@ _KW_PER_M2 = 0.05 # latter three from habitable rooms); fallback ~1 radiator per 13 m2. _RADIATOR_ROOM_OFFSET = 3 _RADIATOR_M2_PER_RADIATOR = 13.0 -# SAP main-heating code lodged when a dwelling has no heating system. -_NO_SYSTEM_SAP_CODE = 999 -# main_fuel_type codes (gov API enum and/or Table 12) for off-gas wet fuels. +# 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}) @@ -149,21 +151,23 @@ def ashp_cost_inputs(epc: EpcPropertyData) -> AshpCostInputs: def _existing_system(epc: EpcPropertyData) -> AshpExistingSystem: - """Classify the dwelling's pre-retrofit system for decommission + reuse. - Mains gas is the most reliable signal (`mains_gas`); electricity keys on the - fuel code; oil/LPG on their fuel codes; an absent system on SAP code 999. - The storage-vs-other-electric split is deliberately not made — both price - the same decommission line (ADR-0025).""" - main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] - if main.sap_main_heating_code == _NO_SYSTEM_SAP_CODE: + """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 - if epc.sap_energy_source.mains_gas: - return AshpExistingSystem.GAS - if main.main_fuel_type == _ELECTRICITY_FUEL: + fuel = details[0].main_fuel_type + if fuel == _ELECTRICITY_FUEL: return AshpExistingSystem.ELECTRIC_STORAGE - if main.main_fuel_type in _OIL_FUEL_CODES: + if fuel in _GAS_FUEL_CODES: + return AshpExistingSystem.GAS + if fuel in _OIL_FUEL_CODES: return AshpExistingSystem.OIL - if main.main_fuel_type in _LPG_FUEL_CODES: + if fuel in _LPG_FUEL_CODES: return AshpExistingSystem.LPG return AshpExistingSystem.OTHER diff --git a/tests/domain/modelling/test_ashp_cost_inputs.py b/tests/domain/modelling/test_ashp_cost_inputs.py index db4ff1e8..c598f9ed 100644 --- a/tests/domain/modelling/test_ashp_cost_inputs.py +++ b/tests/domain/modelling/test_ashp_cost_inputs.py @@ -5,6 +5,9 @@ size band, design heat loss (floor-area proxy), radiator count, and whether a wet system can be reused — the catalogue math (Products) stays EPC-free. """ +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.generators.heating_recommendation import ashp_cost_inputs from domain.modelling.products import AshpCostInputs, AshpExistingSystem from tests.domain.modelling._elmhurst_recommendation import ( @@ -29,3 +32,53 @@ def test_mains_gas_dwelling_maps_to_a_reusable_wet_gas_system() -> None: assert abs(inputs.design_heat_loss_kw - 4.5) <= 1e-9 assert inputs.radiator_count == 10 assert inputs.has_reusable_wet_system is True + + +def test_electric_dwelling_has_no_reusable_wet_system() -> None: + # Arrange — an electric storage-heater dwelling (no wet system). + epc = parse_recommendation_summary( + "hhr_storage_from_electric_storage_001431_before.pdf" + ) + + # Act + inputs: AshpCostInputs = ashp_cost_inputs(epc) + + # Assert — electric, so a full new wet distribution is needed. + assert inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE + assert inputs.has_reusable_wet_system is False + + +def test_classification_keys_on_fuel_not_the_mains_gas_flag() -> None: + # Arrange — the 001431 electric fixtures all lodge mains_gas=True (gas is + # available at the property) while heating electrically (fuel 30). The + # classifier must key on the fuel, not the flag, or it would misread these + # as gas and wrongly reuse a non-existent wet system. + epc = parse_recommendation_summary( + "hhr_storage_from_electric_storage_001431_before.pdf" + ) + + # Act / Assert + assert epc.sap_energy_source.mains_gas is True + inputs: AshpCostInputs = ashp_cost_inputs(epc) + assert inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE + assert inputs.has_reusable_wet_system is False + + +def test_oil_and_lpg_dwellings_are_reusable_wet_systems() -> None: + # Arrange — no oil/LPG fixture exists, so mutate an off-gas dwelling's main + # fuel to oil (28) and LPG (27); both are wet boiler systems. + base: EpcPropertyData = parse_recommendation_summary( + "hhr_storage_from_electric_storage_001431_before.pdf" + ) + + def _with_fuel(code: int) -> EpcPropertyData: + clone: EpcPropertyData = copy.deepcopy(base) + clone.sap_energy_source.mains_gas = False + clone.sap_heating.main_heating_details[0].main_fuel_type = code + clone.sap_heating.main_heating_details[0].sap_main_heating_code = 199 + return clone + + # Act / Assert + assert ashp_cost_inputs(_with_fuel(28)).existing_system is AshpExistingSystem.OIL + assert ashp_cost_inputs(_with_fuel(27)).existing_system is AshpExistingSystem.LPG + assert ashp_cost_inputs(_with_fuel(28)).has_reusable_wet_system is True