feat(modelling): gate boiler upgrade on the existing boiler's efficiency

Don't offer a like-for-like gas boiler swap to a dwelling whose existing gas
boiler is already at least as efficient as the new condensing boiler (SAP 10.2
Table 4b codes 102/104 = 84% winter) — it gains nothing, and the dwelling gets
the tune-up (cylinder + controls) instead. `_already_condensing` compares the
existing code's Table 4b winter efficiency to 84%; a non-Table-4b code (solid
fuel) has no comparable efficiency and is never treated as already-condensing.

The gate is GAS-ONLY: a non-gas boiler → gas is a fuel switch whose value (cost
/ carbon) is not captured by winter efficiency, so oil/LPG/coal → gas is never
suppressed on efficiency grounds (only gated on the mains-gas connection).

This correctly demotes the gas-with-cylinder example (cert lodges code 114
"Regular, condensing", 84% winter) to a tune-up case — confirming that 114→102
is ~0 boiler-efficiency gain in both our calc and Elmhurst (both Table 4b 84%);
Elmhurst's uplift there came from the cylinder + flue, not the boiler. The
boiler-with-cylinder overlay stays validated by the lpg pin (code 115, non-
condensing + cylinder) and by recasting the 114 fixtures' code to a pre-1998
non-condensing boiler (110) in the boiler tests — the overlay overwrites the
code to 102 regardless, so only eligibility changes, not the delta-0 result.
New tests: an already-condensing gas boiler yields no boiler upgrade (but a
tune-up); an oil condensing boiler is not gated (the fuel switch survives).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 07:15:58 +00:00
parent ae7e6a0c42
commit 6ce6e89de1
3 changed files with 86 additions and 5 deletions

View file

@ -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:

View file

@ -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"
)

View file

@ -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.