feat(modelling): system tune-up options (standard + zone controls)

Add the system tune-up to the heating Recommendation: keep the existing wet
boiler but install better heating controls and fix the cylinder. Two competing
Options (the Optimiser picks <=1 across the whole heating rec) per the user's
two best control end-states:

- system_tune_up        — standard controls (programmer + room thermostat +
  TRVs, SAP 10.2 Table 4e code 2106)
- system_tune_up_zoned  — time-and-temperature zone control (code 2110, type 3):
  more SAP uplift for more cost

Both keep the boiler (no fuel / SAP code / flue change), set the control
ABSOLUTELY to their end-state, and apply the conditional cylinder fixes (an
80 mm jacket when under-insulated, a thermostat when absent — only when a
cylinder exists). Each control option is offered only when it genuinely improves
the existing control — standard is skipped when the control is already 2106 /
2110 / 2112, zone when already 2110 / 2112 — so neither is ever a downgrade or a
no-op.

Validated against the Elmhurst "system tune up" re-lodgements (cert 001431):
nine befores spanning controls 2101-2113 all converge to the two common afters,
proving the control overlay is absolute. The cascade pin is parametrised over
two starting controls (2101 "no control" + 2113 "room thermostat and TRVs") x
both afters, delta 0 (SAP/CO2/PE).

Wires the two MeasureTypes through contingencies (0.15), the offline catalogue
(500 / 900), the catalogue-coverage list, the report triggers, and the ARA
first-run seed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-10 10:20:46 +00:00
parent 3d108a9d9e
commit 07f534ee11
15 changed files with 274 additions and 0 deletions

View file

@ -21,6 +21,8 @@ _CONTINGENCY_RATES: dict[str, float] = {
"high_heat_retention_storage_heaters": 0.10,
"air_source_heat_pump": 0.25,
"gas_boiler_upgrade": 0.26,
"system_tune_up": 0.15,
"system_tune_up_zoned": 0.15,
"solar_pv": 0.15,
}

View file

@ -28,6 +28,8 @@ _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
_SYSTEM_TUNE_UP_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP
_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP_ZONED
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
_ELECTRICITY_FUEL = 30
@ -149,6 +151,21 @@ _INADEQUATE_BOILER_CONTROL_CODES: frozenset[int] = frozenset(
{2101, 2102, 2107, 2108, 2109, 2111}
)
# System tune-up control end-states (SAP 10.2 Table 4e Group 1): the two best
# competing control upgrades offered while KEEPING the existing boiler —
# "standard" (programmer + room thermostat + TRVs, code 2106) and "zone"
# (time-and-temperature zone control, code 2110, type 3). Zone gives more SAP
# uplift for more cost, so the Optimiser steps to it when its extra SAP is
# needed (ADR-0024).
_STANDARD_CONTROL = _FULL_BOILER_CONTROL # 2106
_ZONE_CONTROL = 2110
# Controls already providing standard (2106) or better — a standard tune-up
# would be a no-op or a downgrade, so it is not offered to these.
_STANDARD_OR_BETTER_CONTROL_CODES: frozenset[int] = frozenset({2106, 2110, 2112})
# Controls already providing zone control (type 3) — a zone tune-up is not
# offered to these.
_ZONE_CONTROL_CODES: frozenset[int] = frozenset({2110, 2112})
# 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,
@ -263,11 +280,103 @@ def recommend_heating(
if boiler_option is not None:
options.append(boiler_option)
options.extend(_system_tune_up_options(epc, products))
if not options:
return None
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
def _system_tune_up_options(
epc: EpcPropertyData, products: ProductRepository
) -> list[MeasureOption]:
"""The system tune-up options: keep the existing wet boiler but install
better heating controls (standard 2106 and/or zone 2110, as competing
options) and fix the cylinder (jacket when under-insulated, thermostat when
absent). Each control option is offered only when it genuinely improves the
existing controls never a downgrade or a no-op (ADR-0024)."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
code: Optional[int] = main.sap_main_heating_code
if code is None or not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
return []
control = main.main_heating_control
control_code: Optional[int] = control if isinstance(control, int) else None
options: list[MeasureOption] = []
if control_code not in _STANDARD_OR_BETTER_CONTROL_CODES:
options.append(
_tune_up_option(
epc,
products,
measure_type=_SYSTEM_TUNE_UP_MEASURE_TYPE,
control=_STANDARD_CONTROL,
description=(
"Tune up the heating: install a programmer, room thermostat "
"and TRVs and insulate and thermostat the hot-water cylinder"
),
)
)
if control_code not in _ZONE_CONTROL_CODES:
options.append(
_tune_up_option(
epc,
products,
measure_type=_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE,
control=_ZONE_CONTROL,
description=(
"Tune up the heating: install time-and-temperature zone "
"control and insulate and thermostat the hot-water cylinder"
),
)
)
return options
def _tune_up_option(
epc: EpcPropertyData,
products: ProductRepository,
*,
measure_type: MeasureType,
control: int,
description: str,
) -> MeasureOption:
"""One tune-up Option: the existing boiler is kept; only the heating control
and the conditional cylinder fixes change."""
product = products.get(measure_type)
return MeasureOption(
measure_type=measure_type,
description=description,
overlay=EpcSimulation(heating=_tune_up_overlay(epc, control)),
cost=Cost(
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
),
material_id=product.id,
)
def _tune_up_overlay(epc: EpcPropertyData, control: int) -> HeatingOverlay:
"""Build a tune-up end-state: set the heating control to ``control`` and
apply the conditional cylinder fixes (an 80 mm jacket when under-insulated, a
thermostat when absent) only when the dwelling has a cylinder. The boiler,
fuel and meter are left unchanged (the boiler is kept)."""
sap_heating = epc.sap_heating
jacket_type: Optional[int] = None
jacket_thickness_mm: Optional[int] = None
thermostat: Optional[str] = None
if epc.has_hot_water_cylinder:
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
if sap_heating.cylinder_thermostat != "Y":
thermostat = "Y"
return HeatingOverlay(
main_heating_control=control,
cylinder_insulation_type=jacket_type,
cylinder_insulation_thickness_mm=jacket_thickness_mm,
cylinder_thermostat=thermostat,
)
def _boiler_upgrade_option(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[MeasureOption]:

View file

@ -33,4 +33,6 @@ class MeasureType(StrEnum):
HIGH_HEAT_RETENTION_STORAGE_HEATERS = "high_heat_retention_storage_heaters"
AIR_SOURCE_HEAT_PUMP = "air_source_heat_pump"
GAS_BOILER_UPGRADE = "gas_boiler_upgrade"
SYSTEM_TUNE_UP = "system_tune_up"
SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned"
SOLAR_PV = "solar_pv"

View file

@ -164,6 +164,18 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]:
"mains_gas": epc.sap_energy_source.mains_gas,
"has_hot_water_cylinder": epc.has_hot_water_cylinder,
}
if measure_type in ("system_tune_up", "system_tune_up_zoned"):
# heating_recommendation.py offers a tune-up (keep the boiler, upgrade
# the controls + fix the cylinder) to a wet-boiler dwelling whose
# existing control can still be improved.
return {
"sap_main_heating_code": (
epc.sap_heating.main_heating_details[0].sap_main_heating_code
),
"main_heating_control": (
epc.sap_heating.main_heating_details[0].main_heating_control
),
}
return {}

View file

@ -14,5 +14,7 @@
"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 },
"system_tune_up": { "unit_cost_per_m2": 500.0 },
"system_tune_up_zoned": { "unit_cost_per_m2": 900.0 },
"solar_pv": { "unit_cost_per_m2": 0.0 }
}

View file

@ -887,6 +887,53 @@ def test_coal_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> No
_assert_overlay_reproduces_after(before, after, option.overlay)
@pytest.mark.parametrize(
"before_fixture, after_fixture, measure_type",
[
# The system tune-up keeps the existing boiler and forces the heating
# controls to a fixed end-state (standard 2106 / zone 2110) ABSOLUTELY —
# proven by reproducing each common after from two different starting
# controls (2101 "no control" and 2113 "room thermostat and TRVs") — plus
# the conditional cylinder jacket + thermostat (both befores are
# uninsulated / un-thermostatted, so both fire).
(
"tune_up_from_2101_001431_before.pdf",
"tune_up_standard_001431_after.pdf",
"system_tune_up",
),
(
"tune_up_from_2113_001431_before.pdf",
"tune_up_standard_001431_after.pdf",
"system_tune_up",
),
(
"tune_up_from_2101_001431_before.pdf",
"tune_up_zoned_001431_after.pdf",
"system_tune_up_zoned",
),
(
"tune_up_from_2113_001431_before.pdf",
"tune_up_zoned_001431_after.pdf",
"system_tune_up_zoned",
),
],
)
def test_system_tune_up_overlay_reproduces_the_relodged_after(
before_fixture: str, after_fixture: str, measure_type: str
) -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(before_fixture)
after: EpcPropertyData = parse_recommendation_summary(after_fixture)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == measure_type
)
# 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

@ -394,6 +394,71 @@ def test_gas_combi_dwelling_yields_a_combi_boiler_upgrade_bundle() -> None:
)
def _tune_up_baseline() -> EpcPropertyData:
"""A mains-gas wet boiler (kept) with "no control" (2101) and an uninsulated,
un-thermostatted cylinder the system tune-up dwelling."""
return parse_recommendation_summary("tune_up_from_2101_001431_before.pdf")
def test_wet_boiler_dwelling_yields_both_tune_up_options() -> None:
# Arrange — a wet boiler whose controls can be improved: both the standard
# (2106) and zone (2110) control tune-ups are offered as competing options,
# each keeping the boiler and fixing the cylinder.
baseline: EpcPropertyData = _tune_up_baseline()
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — both options carry the control end-state + the cylinder fixes, and
# leave the boiler untouched (no fuel / sap code / flue fields).
assert recommendation is not None
options = {o.measure_type.value: o for o in recommendation.options}
assert options["system_tune_up"].overlay.heating == HeatingOverlay(
main_heating_control=2106,
cylinder_insulation_type=2,
cylinder_insulation_thickness_mm=80,
cylinder_thermostat="Y",
)
assert options["system_tune_up_zoned"].overlay.heating == HeatingOverlay(
main_heating_control=2110,
cylinder_insulation_type=2,
cylinder_insulation_thickness_mm=80,
cylinder_thermostat="Y",
)
def test_tune_up_standard_not_offered_when_controls_already_standard() -> None:
# Arrange — controls are already standard (2106): the standard tune-up would
# be a control no-op, so only the zone tune-up is offered.
baseline: EpcPropertyData = _tune_up_baseline()
baseline.sap_heating.main_heating_details[0].main_heating_control = 2106
# 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 "system_tune_up" not in measure_types
assert "system_tune_up_zoned" in measure_types
def test_tune_up_neither_offered_when_controls_already_zoned() -> None:
# Arrange — controls are already zone control (2110): neither tune-up would
# improve them, so neither is offered.
baseline: EpcPropertyData = _tune_up_baseline()
baseline.sap_heating.main_heating_details[0].main_heating_control = 2110
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert
if recommendation is not None:
measure_types = {o.measure_type for o in recommendation.options}
assert "system_tune_up" not in measure_types
assert "system_tune_up_zoned" not in measure_types
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

View file

@ -27,6 +27,8 @@ _EXPECTED_VALUES = {
"high_heat_retention_storage_heaters",
"air_source_heat_pump",
"gas_boiler_upgrade",
"system_tune_up",
"system_tune_up_zoned",
"solar_pv",
}

View file

@ -39,6 +39,8 @@ _GENERATOR_MEASURE_TYPES = (
"high_heat_retention_storage_heaters",
"air_source_heat_pump",
"gas_boiler_upgrade",
"system_tune_up",
"system_tune_up_zoned",
)

View file

@ -125,6 +125,21 @@ def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None:
}
def test_system_tune_up_surfaces_its_eligibility_triggers() -> None:
# Like the boiler-upgrade trigger, no golden cert selects a tune-up, so the
# branch is covered directly.
from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage]
# Arrange — a wet boiler (SAP code 102) with "no control" (2101): the wet-
# boiler code and the improvable control are what the report should explain.
epc = parse_recommendation_summary("tune_up_from_2101_001431_before.pdf")
# Act / Assert — both tune-up measure types surface the same eligibility.
expected = {"sap_main_heating_code": 102, "main_heating_control": 2101}
assert _triggers_for(epc, "system_tune_up") == expected
assert _triggers_for(epc, "system_tune_up_zoned") == expected
def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None:
# Arrange
path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json"

View file

@ -176,6 +176,22 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
is_active=True,
description="Gas condensing boiler",
),
MaterialRow(
id=7,
type="system_tune_up",
total_cost=500.0,
cost_unit="gbp_per_unit",
is_active=True,
description="Heating controls + cylinder tune-up",
),
MaterialRow(
id=8,
type="system_tune_up_zoned",
total_cost=900.0,
cost_unit="gbp_per_unit",
is_active=True,
description="Zoned heating controls + cylinder tune-up",
),
]
)
session.commit()