Model/tests/domain/modelling/test_heating_recommendation.py
Khalim Conn-Kowlessar 63dd69ff8b feat(modelling): gas combi boiler upgrade + controls-when-inadequate
Extend the gas-boiler-upgrade Option to combi (no-cylinder) dwellings and add
the controls upgrade shared by both boiler shapes. A dwelling has a cylinder or
it does not, so the one `gas_boiler_upgrade` Option is shaped per dwelling:

- no cylinder -> a gas condensing combi (Table 4b code 104), no cylinder fields
  touched;
- a cylinder  -> a regular boiler (code 102) heating it, with the conditional
  cylinder jacket/thermostat (slice 1).

Controls: bring an inadequate boiler control up to full programmer + room
thermostat + TRVs (SAP 10.2 Table 4e Group 1 code 2106). "Inadequate" = the
Group-1 codes with NO room thermostat (2101, 2102, 2107, 2108, 2109, 2111) —
these lack boiler interlock (Table 4c(2) / footnote c) p.171), so adding a room
thermostat genuinely improves SAP. Room-thermostatted (2103/2104/2105/2106/2113)
or better zone controls (2110/2112) are left unchanged — never downgraded, so
no phantom uplift. The with-cylinder cert (control 2106) is therefore untouched
and its pin still holds at delta 0.

Validated by the combi before/after re-lodgement (cert 001431, gas boiler
upgrade - no cylinder): control 2111 "TRVs and bypass" -> 2106, fan flue
False->True, SAP code 112 -> 104. Cascade-pinned delta 0 (SAP/CO2/PE). Removed
the slice-1 placeholder test asserting no boiler Option fires without a cylinder
(the combi Option now correctly fires there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:28:00 +00:00

411 lines
16 KiB
Python

"""Behaviour of the heating Recommendation Generator: detecting a dwelling
whose heating system should be replaced and emitting one "Heating & Hot Water"
Recommendation of competing whole-system bundles (ADR-0024). This slice covers
the high-heat-retention storage (HHRSH) bundle; ASHP and boiler bundles land in
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
from domain.modelling.simulation import HeatingOverlay
from repositories.product.product_repository import ProductRepository
from tests.domain.modelling._elmhurst_recommendation import (
parse_recommendation_summary,
)
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
# Electricity main-fuel code (Elmhurst → SAP10) and the Table 4a SAP code an
# existing (non-HHR) electric storage system lodges.
_ELECTRICITY = 30
_OLD_STORAGE_SAP_CODE = 402
def _electric_storage_baseline() -> EpcPropertyData:
"""A 000490 dwelling re-cast as an existing (non-HHR) electric storage
system: electric main fuel, Table 4a code 402."""
epc: EpcPropertyData = build_epc()
main = epc.sap_heating.main_heating_details[0]
main.main_fuel_type = _ELECTRICITY
main.sap_main_heating_code = _OLD_STORAGE_SAP_CODE
return epc
class _StubProducts(ProductRepository):
"""In-memory ProductRepository returning a fixed HHRSH product cost."""
def get(self, measure_type: str) -> Product:
return Product(
measure_type=measure_type, unit_cost_per_m2=3500.0, contingency_rate=0.26
)
def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None:
# Arrange — an existing electric storage system (not HHR).
baseline: EpcPropertyData = _electric_storage_baseline()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — one "Heating & Hot Water" Recommendation carrying the HHRSH
# bundle, whose overlay is the absolute HHR end-state.
assert recommendation is not None
assert recommendation.surface == "Heating & Hot Water"
options = {o.measure_type.value: o for o in recommendation.options}
assert "high_heat_retention_storage_heaters" in options
assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay(
main_fuel_type=30,
sap_main_heating_code=409,
main_heating_control=2404,
water_heating_code=903,
water_heating_fuel=30,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=120,
cylinder_thermostat="Y",
has_hot_water_cylinder=True,
meter_type="Dual",
)
def test_on_gas_boiler_dwelling_yields_no_hhr_storage_bundle() -> None:
# Arrange — 000490 is a mains-gas combi (fuel 26, mains_gas True). HHR
# storage suits off-gas / electric dwellings, not an on-gas gas boiler.
baseline: EpcPropertyData = build_epc()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — no HHRSH bundle (no other bundle is built in this slice → None).
if recommendation is not None:
assert "high_heat_retention_storage_heaters" not in {
o.measure_type for o in recommendation.options
}
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 (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
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:
# Arrange — an electric dwelling already on a heat pump (category 4) is never
# downgraded to storage heaters.
baseline: EpcPropertyData = _electric_storage_baseline()
baseline.sap_heating.main_heating_details[0].main_heating_category = 4
# Act / Assert
assert recommend_heating(baseline, _StubProducts()) is None
def test_hhr_storage_bundle_carries_the_product_cost_and_contingency() -> None:
# Arrange
baseline: EpcPropertyData = _electric_storage_baseline()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — the bundle's fully-loaded cost + contingency come from the product.
assert recommendation is not None
option = next(
o
for o in recommendation.options
if o.measure_type == "high_heat_retention_storage_heaters"
)
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.value: 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=110257,
main_heating_category=4,
water_heating_code=901,
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_ashp_bundle_carries_the_composite_per_dwelling_cost() -> None:
# Arrange — a mains-gas regular boiler with a cylinder (90 m2, 7 habitable
# rooms): the ASHP reuses the existing wet system (ADR-0025).
epc: EpcPropertyData = parse_recommendation_summary(
"ashp_from_system_boiler_with_cylinder_001431_before.pdf"
)
# Act
recommendation: Recommendation | None = recommend_heating(epc, _StubProducts())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
)
# Assert — composite: decommission (gas) 720 + pump (4.5 kW band) 9720 +
# cylinder 2382.60 + reuse distribution (flush 168 + 0.5 x dist(10) 5220 =
# 2778) = 15600.60, with the separate 25% ASHP contingency.
assert option.cost is not None
assert abs(option.cost.total - 15600.60) <= 1e-9
assert abs(option.cost.contingency_rate - 0.25) <= 1e-9
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
}
# --- 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_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
}
def _gas_combi_baseline() -> EpcPropertyData:
"""A mains-gas combi (Table 4b code 112, no cylinder) with inadequate
controls (2111 "TRVs and bypass" — no room thermostat)."""
return parse_recommendation_summary("boiler_combi_gas_001431_before.pdf")
def test_gas_combi_dwelling_yields_a_combi_boiler_upgrade_bundle() -> None:
# Arrange — a mains-gas combi with no cylinder and inadequate controls:
# the upgrade replaces it with a condensing combi (code 104) and upgrades
# the controls to 2106, touching no cylinder fields.
baseline: EpcPropertyData = _gas_combi_baseline()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert
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=104,
fan_flue_present=True,
main_heating_control=2106,
water_heating_code=901,
water_heating_fuel=26,
)
def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None:
# Arrange — the same combi but with already-adequate controls (2113, room
# thermostat and TRVs): the upgrade must not move the controls (and must
# never downgrade a better control).
baseline: EpcPropertyData = _gas_combi_baseline()
baseline.sap_heating.main_heating_details[0].main_heating_control = 2113
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert
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.main_heating_control is None