feat(modelling): gas-boiler-upgrade-with-cylinder option in the heating rec

Add the first boiler-upgrade option to the single "Heating & Hot Water"
Recommendation (ADR-0024 expansion): a dwelling whose existing wet boiler heats
a hot-water cylinder is offered a new gas condensing boiler, with the cylinder
jacketed when under-insulated and given a thermostat when absent. One competing
Option (the Optimiser picks <=1), folded into one composite Plan line.

The end-state is read from the Elmhurst before/after re-lodgements (cert 001431,
gas boiler upgrade - with cylinder), which REVISE ADR-0024:

- Target is always a gas condensing boiler, not fuel-preserving: every after
  lodges fuel 26. Gas->gas always; a non-gas wet boiler ->gas only with a
  mains-gas connection; electric boilers are left alone (electrification is the
  upgrade path). Eligibility = wet-boiler SAP code (Table 4a/4b 101-141 /
  151-161 / 191-196) + not an electric boiler + mains gas present.
- End-state is a Table 4b SAP code, not a PCDB index: code 102 (regular boiler
  + cylinder). The calculator derives the condensing seasonal efficiency from
  the code, so no efficiency input exists or is needed.
- A modern condensing boiler has a fanned flue: the after flips
  `fan_flue_present` False->True on every cert (SAP 10.2 Table 4f flue-fan +
  the Table 4b condensing-efficiency basis). Added as a new HeatingOverlay
  field, routed to main_heating_details[0].
- Cylinder thermostat is always added when absent (user-locked); the jacket is
  the 80 mm `cylinder_insulation_type=2` end-state, applied only when the
  cylinder is below 80 mm (never downgrading a better one). Both are conditional
  per-dwelling components, not a frozen overlay.

Cascade-pinned delta-0 (SAP/CO2/PE) against the relodged after via
`_assert_overlay_reproduces_after`. NB the absolute SAP on this dwelling is
subject to a separate Summary-path mapper roof-fidelity gap (we read the roof
better-insulated than Elmhurst, scoring ~75 vs the printed 56); the gap is
identical on before+after (the boiler measure never touches the roof) so it
cancels and the pin still proves the exact heating field-delta. Tracked on the
calculator branch.

Wires the new `gas_boiler_upgrade` MeasureType through contingencies (0.26),
the offline sample catalogue, the catalogue-coverage list, and the ARA
first-run integration seed (the option fires on any mains-gas boiler+cylinder
dwelling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-09 16:16:46 +00:00
parent 4006753620
commit 31c74ab500
13 changed files with 297 additions and 0 deletions

View file

@ -20,6 +20,7 @@ _CONTINGENCY_RATES: dict[str, float] = {
"low_energy_lighting": 0.26,
"high_heat_retention_storage_heaters": 0.10,
"air_source_heat_pump": 0.25,
"gas_boiler_upgrade": 0.26,
"solar_pv": 0.15,
}

View file

@ -27,6 +27,7 @@ _HEATING_SURFACE = "Heating & Hot Water"
_HHR_STORAGE_MEASURE_TYPE = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS
_ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP
_GAS_BOILER_UPGRADE_MEASURE_TYPE = MeasureType.GAS_BOILER_UPGRADE
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
_ELECTRICITY_FUEL = 30
@ -111,6 +112,45 @@ _ASHP_OVERLAY = HeatingOverlay(
)
# --- Gas boiler upgrade (Heating/HW expansion): replace an existing wet boiler
# with a modern gas condensing boiler. Validated against Elmhurst before/after
# re-lodgements (cert 001431): the upgrade always targets mains gas — gas->gas
# directly, and a non-gas wet boiler (oil/LPG/solid) ->gas ONLY where a mains-gas
# connection is present (electric boilers are left alone; electrification is the
# national target). The end-state is a Table 4b SAP code (not a PCDB index): code
# 102 for a regular boiler heating a hot-water cylinder, code 104 for a combi
# (no cylinder, a later slice). The calculator derives the condensing-boiler
# seasonal efficiency from the code, so no efficiency input is needed. ---
# Mains-gas main/water fuel code (Elmhurst -> SAP10 Table 12).
_MAINS_GAS_FUEL = 26
# Table 4a heat-emitter code for radiators (the wet-distribution end-state).
_RADIATOR_EMITTER = 1
# Table 4b SAP main-heating code for a regular gas boiler heating a cylinder.
_REGULAR_GAS_BOILER_SAP_CODE = 102
# Water-heating code 901 — hot water from the main heating system.
_WATER_FROM_MAIN_SYSTEM_CODE = 901
# Wet-boiler SAP main_heating_code ranges (SAP 10.2 Table 4a + 4b): gas/oil
# boilers 101-141, solid-fuel boilers 151-161, electric boilers 191-196 (held
# locally so the generator does not depend on the calculator's internals,
# mirroring `domain/sap10_calculator/rdsap/cert_to_inputs.py`). Electric boilers
# are a wet system but are deliberately not upgraded to gas.
_WET_BOILER_SAP_CODE_RANGES: tuple[range, ...] = (
range(101, 142),
range(151, 162),
range(191, 197),
)
_ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197)
# Cylinder jacket end-state (from the after-cert): an 80 mm jacket
# (`cylinder_insulation_type=2`). The jacket is added only when the existing
# cylinder is below this thickness — bringing every cylinder up to 80 mm and
# never downgrading a better-insulated one.
_CYLINDER_JACKET_INSULATION_TYPE = 2
_MIN_CYLINDER_INSULATION_MM = 80
# --- ASHP cost interpretation (ADR-0025): read the dwelling into the typed
# inputs the catalogue math needs. The modelling-layer half of the split; the
# pricing itself lives on `Products`. ---
@ -201,11 +241,94 @@ def recommend_heating(
if ashp_option is not None:
options.append(ashp_option)
boiler_option = _boiler_upgrade_with_cylinder_option(epc, products)
if boiler_option is not None:
options.append(boiler_option)
if not options:
return None
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
def _boiler_upgrade_with_cylinder_option(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[MeasureOption]:
"""The gas-condensing-boiler-with-cylinder bundle: a new regular gas boiler
(Table 4b code 102, fanned flue) for a dwelling whose existing wet boiler
heats a hot-water cylinder, plus the conditional cylinder fixes (a jacket
when under-insulated, a thermostat when absent). Offered only where a
mains-gas connection makes the gas end-state installable (ADR-0024 revised)."""
if not _boiler_upgrade_eligible(epc):
return None
if not epc.has_hot_water_cylinder:
return None
product = products.get(_GAS_BOILER_UPGRADE_MEASURE_TYPE)
return MeasureOption(
measure_type=_GAS_BOILER_UPGRADE_MEASURE_TYPE,
description=(
"Replace the boiler with a gas condensing boiler and insulate and "
"thermostat the hot-water cylinder"
),
overlay=EpcSimulation(heating=_boiler_cylinder_overlay(epc)),
cost=Cost(
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
),
material_id=product.id,
)
def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool:
"""Whether a dwelling's existing wet boiler can be upgraded to a gas
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."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
code: Optional[int] = main.sap_main_heating_code
if code is None:
return False
if not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
return False
if code in _ELECTRIC_BOILER_SAP_CODE_RANGE:
return False
return epc.sap_energy_source.mains_gas
def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay:
"""Build the per-dwelling boiler-with-cylinder end-state: a regular gas
condensing boiler on radiators, hot water from the main system, and the
conditional cylinder fixes an 80 mm jacket only when the cylinder is
under-insulated, a thermostat only when one is absent. The existing cylinder
size, heating controls, and meter are left unchanged."""
sap_heating = epc.sap_heating
jacket_type: Optional[int] = None
jacket_thickness_mm: Optional[int] = None
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
thermostat: Optional[str] = (
"Y" if sap_heating.cylinder_thermostat != "Y" else None
)
return HeatingOverlay(
main_fuel_type=_MAINS_GAS_FUEL,
heat_emitter_type=_RADIATOR_EMITTER,
sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE,
fan_flue_present=True,
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
water_heating_fuel=_MAINS_GAS_FUEL,
cylinder_insulation_type=jacket_type,
cylinder_insulation_thickness_mm=jacket_thickness_mm,
cylinder_thermostat=thermostat,
has_hot_water_cylinder=True,
)
def _cylinder_under_insulated(thickness_mm: Optional[int]) -> bool:
"""Whether a hot-water cylinder is below the 80 mm jacket end-state (an
un-jacketed cylinder lodges no thickness)."""
return thickness_mm is None or thickness_mm < _MIN_CYLINDER_INSULATION_MM
def _ashp_option(
epc: EpcPropertyData,
products: ProductRepository,

View file

@ -32,4 +32,5 @@ class MeasureType(StrEnum):
MECHANICAL_VENTILATION = "mechanical_ventilation"
HIGH_HEAT_RETENTION_STORAGE_HEATERS = "high_heat_retention_storage_heaters"
AIR_SOURCE_HEAT_PUMP = "air_source_heat_pump"
GAS_BOILER_UPGRADE = "gas_boiler_upgrade"
SOLAR_PV = "solar_pv"

View file

@ -70,6 +70,7 @@ _MAIN_HEATING_FIELDS: tuple[str, ...] = (
"sap_main_heating_code",
"main_heating_index_number",
"main_heating_category",
"fan_flue_present",
)
_SAP_HEATING_FIELDS: tuple[str, ...] = (
"water_heating_code",

View file

@ -118,6 +118,10 @@ class HeatingOverlay:
sap_main_heating_code: Optional[int] = None
main_heating_index_number: Optional[int] = None
main_heating_category: Optional[int] = None
# A modern condensing boiler has a fanned (room-sealed) flue; the boiler
# upgrade sets this True (SAP 10.2 Table 4f flue-fan electricity + the
# Table 4b condensing-boiler seasonal-efficiency basis depend on it).
fan_flue_present: Optional[bool] = None
# sap_heating (top-level)
water_heating_code: Optional[int] = None
water_heating_fuel: Optional[int] = None

View file

@ -13,5 +13,6 @@
"low_energy_lighting": { "unit_cost_per_m2": 8.0 },
"high_heat_retention_storage_heaters": { "unit_cost_per_m2": 3500.0 },
"air_source_heat_pump": { "unit_cost_per_m2": 12000.0 },
"gas_boiler_upgrade": { "unit_cost_per_m2": 3000.0 },
"solar_pv": { "unit_cost_per_m2": 0.0 }
}

View file

@ -755,6 +755,36 @@ def test_gas_boiler_instant_hw_before_baselines() -> None:
assert result.sap_score_continuous > 0.0
def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a mains-gas wet boiler (SAP code 114) heating an uninsulated
# hot-water cylinder (no insulation, no thermostat) re-lodged as a new gas
# condensing boiler with a cylinder (SAP code 102, fanned flue), the cylinder
# jacketed (insulation type 2 / 80 mm) and given a thermostat. The boiler
# upgrade leaves the (already adequate) controls + cylinder size + meter
# unchanged. Validates the boiler-with-cylinder option end-state at delta 0.
#
# NB the absolute SAP on this dwelling is subject to a separate Summary-path
# mapper roof-fidelity gap (our calculator reads the roof better-insulated
# than Elmhurst, so it scores ~75 where Elmhurst prints 56); the gap is
# identical on before + after (the boiler measure never touches the roof), so
# it cancels and this pin still proves the overlay applies Elmhurst's exact
# heating field-delta. Tracked on the calculator branch, not here.
before: EpcPropertyData = parse_recommendation_summary(
"boiler_cyl_gas_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"boiler_cyl_gas_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
# --- Solar PV cascade pins (ADR-0026) -------------------------------------
#
# The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp,

View file

@ -250,3 +250,129 @@ def test_existing_heat_pump_yields_no_ashp_bundle() -> None:
assert "air_source_heat_pump" not in {
o.measure_type for o in recommendation.options
}
# --- Gas boiler upgrade (Heating/HW expansion) ----------------------------
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")
def test_gas_boiler_with_cylinder_dwelling_yields_a_boiler_upgrade_bundle() -> None:
# Arrange — a mains-gas wet boiler with an uninsulated, un-thermostatted
# cylinder: the upgrade fires both conditional cylinder fixes.
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — the absolute boiler end-state (code 102, fanned flue) with the
# cylinder jacketed (type 2 / 80 mm) and thermostatted; controls, cylinder
# size, and meter are left unchanged.
assert recommendation is not None
options = {o.measure_type.value: o for o in recommendation.options}
assert "gas_boiler_upgrade" in options
assert options["gas_boiler_upgrade"].overlay.heating == HeatingOverlay(
main_fuel_type=26,
heat_emitter_type=1,
sap_main_heating_code=102,
fan_flue_present=True,
water_heating_code=901,
water_heating_fuel=26,
cylinder_insulation_type=2,
cylinder_insulation_thickness_mm=80,
cylinder_thermostat="Y",
has_hot_water_cylinder=True,
)
def test_boiler_upgrade_skips_jacket_when_cylinder_already_insulated() -> None:
# Arrange — the same dwelling but with an already well-insulated cylinder
# (100 mm > the 80 mm jacket end-state): the jacket must not be re-applied
# (and must never downgrade it).
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
baseline.sap_heating.cylinder_insulation_thickness_mm = 100
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — no jacket fields, but the thermostat still added (absent before).
assert recommendation is not None
overlay = next(
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
).overlay.heating
assert overlay is not None
assert overlay.cylinder_insulation_type is None
assert overlay.cylinder_insulation_thickness_mm is None
assert overlay.cylinder_thermostat == "Y"
def test_boiler_upgrade_skips_thermostat_when_already_present() -> None:
# Arrange — the same dwelling but the cylinder already has a thermostat.
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
baseline.sap_heating.cylinder_thermostat = "Y"
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — no thermostat field, but the jacket still added (uninsulated).
assert recommendation is not None
overlay = next(
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
).overlay.heating
assert overlay is not None
assert overlay.cylinder_thermostat is None
assert overlay.cylinder_insulation_type == 2
def test_no_cylinder_dwelling_yields_no_boiler_with_cylinder_bundle() -> None:
# Arrange — a wet gas boiler with no hot-water cylinder (a combi); the with-
# cylinder option does not apply (the combi option lands in a later slice).
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
baseline.has_hot_water_cylinder = False
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert
if recommendation is not None:
assert "gas_boiler_upgrade" not 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.
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 191
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert
if recommendation is not None:
assert "gas_boiler_upgrade" not in {
o.measure_type for o in recommendation.options
}
def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> None:
# Arrange — an oil boiler (Table 4b code 130) with no mains-gas connection:
# a gas boiler cannot be installed, so no upgrade is offered (the gas end-
# state is gated on a mains-gas connection).
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 130
baseline.sap_energy_source.mains_gas = False
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert
if recommendation is not None:
assert "gas_boiler_upgrade" not in {
o.measure_type for o in recommendation.options
}

View file

@ -26,6 +26,7 @@ _EXPECTED_VALUES = {
"mechanical_ventilation",
"high_heat_retention_storage_heaters",
"air_source_heat_pump",
"gas_boiler_upgrade",
"solar_pv",
}

View file

@ -38,6 +38,7 @@ _GENERATOR_MEASURE_TYPES = (
"low_energy_lighting",
"high_heat_retention_storage_heaters",
"air_source_heat_pump",
"gas_boiler_upgrade",
)

View file

@ -168,6 +168,14 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
is_active=True,
description="LED bulb",
),
MaterialRow(
id=6,
type="gas_boiler_upgrade",
total_cost=3000.0,
cost_unit="gbp_per_unit",
is_active=True,
description="Gas condensing boiler",
),
]
)
session.commit()