diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index a7feaa96..410eb5e6 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -27,6 +27,9 @@ from domain.modelling.products import ( from domain.modelling.measure_type import MeasureType 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" @@ -191,6 +194,14 @@ _ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197) _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 @@ -427,7 +438,11 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool: 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.""" + 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: @@ -436,7 +451,24 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool: return False if code in _ELECTRIC_BOILER_SAP_CODE_RANGE: return False - return epc.sap_energy_source.mains_gas + 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: diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index a124c5c4..c89c2aa5 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -772,6 +772,11 @@ def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None: before: EpcPropertyData = parse_recommendation_summary( "boiler_cyl_gas_001431_before.pdf" ) + # The cert lodges code 114 (already condensing), which the efficiency gate + # excludes from a like-for-like swap; recast to a pre-1998 non-condensing + # boiler (110) so the upgrade is offered. The overlay overwrites the code to + # 102 regardless, so this changes only eligibility, not the validated result. + before.sap_heating.main_heating_details[0].sap_main_heating_code = 110 after: EpcPropertyData = parse_recommendation_summary( "boiler_cyl_gas_001431_after.pdf" ) diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 9e4ca030..5e8e1576 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -256,9 +256,17 @@ def test_existing_heat_pump_yields_no_ashp_bundle() -> None: def _gas_boiler_with_cylinder_baseline() -> EpcPropertyData: - """A mains-gas wet boiler (Table 4b code 114) heating an uninsulated, un- - thermostatted hot-water cylinder — the boiler-with-cylinder dwelling.""" - return parse_recommendation_summary("boiler_cyl_gas_001431_before.pdf") + """A mains-gas wet boiler heating an uninsulated, un-thermostatted hot-water + cylinder — the boiler-with-cylinder dwelling. The cert lodges code 114 + (already condensing), which the efficiency gate excludes from a like-for-like + swap; recast to a pre-1998 non-condensing boiler (code 110) so the boiler + upgrade is a genuine candidate (the overlay overwrites the code to 102 + regardless of the before).""" + epc: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + epc.sap_heating.main_heating_details[0].sap_main_heating_code = 110 + return epc def test_gas_boiler_with_cylinder_dwelling_yields_a_boiler_upgrade_bundle() -> None: @@ -329,6 +337,42 @@ def test_boiler_upgrade_skips_thermostat_when_already_present() -> None: assert overlay.cylinder_insulation_type == 2 +def test_already_condensing_gas_boiler_yields_no_boiler_upgrade() -> None: + # Arrange — the real cert: a mains-gas boiler already condensing (Table 4b + # code 114, 84% winter — the same as the new code 102). A like-for-like swap + # gains nothing, so the boiler upgrade is not offered; the dwelling still + # gets a tune-up for its cylinder + controls. + baseline: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + measure_types = {o.measure_type for o in recommendation.options} + assert "gas_boiler_upgrade" not in measure_types + assert "system_tune_up_zoned" in measure_types + + +def test_non_gas_boiler_is_not_gated_on_efficiency() -> None: + # Arrange — an oil condensing boiler (Table 4b code 127, 84% winter — meets + # the new gas boiler's efficiency) on a mains-gas street. Unlike a gas + # boiler, the oil→gas fuel switch has value beyond efficiency, so it is NOT + # suppressed by the efficiency gate. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 127 + baseline.sap_heating.main_heating_details[0].main_fuel_type = 28 # oil + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the boiler upgrade is still offered (the fuel switch). + assert recommendation is not None + assert "gas_boiler_upgrade" in {o.measure_type for o in recommendation.options} + + def test_electric_boiler_dwelling_yields_no_gas_boiler_upgrade() -> None: # Arrange — an electric boiler (Table 4a code 191) is left alone: # electrification, not a gas swap, is its upgrade path.