diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index ea2f3c4c..464d72bf 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -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, } diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index c6eb0247..2a6e908a 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -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]: diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py index b1c4bd9c..70a52c90 100644 --- a/domain/modelling/measure_type.py +++ b/domain/modelling/measure_type.py @@ -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" diff --git a/harness/report.py b/harness/report.py index 002cbd22..632c2b30 100644 --- a/harness/report.py +++ b/harness/report.py @@ -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 {} diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index e15e4537..02eb24eb 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -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 } } diff --git a/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf b/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf new file mode 100644 index 00000000..2d209593 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf b/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf new file mode 100644 index 00000000..7a5b4c13 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf b/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf new file mode 100644 index 00000000..e6c37477 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf b/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf new file mode 100644 index 00000000..ebc431d8 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf differ diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index a83a7c89..a124c5c4 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -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, diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 297b1f8e..b0f86edf 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -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 diff --git a/tests/domain/modelling/test_measure_type.py b/tests/domain/modelling/test_measure_type.py index 56fd5a4c..214df8bc 100644 --- a/tests/domain/modelling/test_measure_type.py +++ b/tests/domain/modelling/test_measure_type.py @@ -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", } diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 1adc9951..4ea237bc 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -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", ) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index 1dcf9123..003ff7bf 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -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" diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 3b72ba13..b7ffb86f 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -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()