From b21171575064025fc8f67391b9d9ab978046f80a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 16:04:07 +0000 Subject: [PATCH] feat(modelling): wire secondary-heating-removal into the pipeline (ADR-0028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrator runs recommend_secondary_heating_removal; report._triggers_for explains it via the lodged secondary_heating_type; harness catalogue + ARA seed price it. Re-pins the golden/integration plans it shifts: it is a cheap (\£250) SAP lever, so on gas-main certs lodging an electric secondary (691) it displaces the \£12k ASHP (0330, 0036) or joins the all-beneficial-measures package (000490, where its marginal SAP is 0 under the category-4 ASHP but the heater is still physically removed). Consistent with the optimiser's existing kitchen-sink package behaviour. Co-Authored-By: Claude Opus 4.8 --- harness/report.py | 4 ++ orchestration/modelling_orchestrator.py | 4 ++ tests/harness/test_console.py | 1 + tests/harness/test_report.py | 61 +++++++++++++------ ...test_ara_first_run_pipeline_integration.py | 30 +++++++++ 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/harness/report.py b/harness/report.py index 632c2b30..c9bd13a2 100644 --- a/harness/report.py +++ b/harness/report.py @@ -176,6 +176,10 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: epc.sap_heating.main_heating_details[0].main_heating_control ), } + if measure_type == "secondary_heating_removal": + # secondary_heating_recommendation.py fires on any lodged secondary + # system (ADR-0028); the lodged SAP code is the "why". + return {"secondary_heating_type": epc.sap_heating.secondary_heating_type} return {} diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 01428242..50be53a4 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -33,6 +33,9 @@ from domain.modelling.generators.solid_wall_recommendation import recommend_soli from domain.modelling.generators.glazing_recommendation import recommend_glazing from domain.modelling.generators.lighting_recommendation import recommend_lighting from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.secondary_heating_recommendation import ( + recommend_secondary_heating_removal, +) from domain.modelling.generators.solar_recommendation import recommend_solar from domain.modelling.solar_potential import SolarPotential from domain.geospatial.planning_restrictions import PlanningRestrictions @@ -256,6 +259,7 @@ def _candidate_recommendations( recommend_glazing(effective_epc, products, planning_restrictions), recommend_lighting(effective_epc, products), recommend_heating(effective_epc, products, planning_restrictions), + recommend_secondary_heating_removal(effective_epc, products), recommend_solar( effective_epc, products, solar_potential, planning_restrictions ), diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 4ea237bc..5712a62c 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -41,6 +41,7 @@ _GENERATOR_MEASURE_TYPES = ( "gas_boiler_upgrade", "system_tune_up", "system_tune_up_zoned", + "secondary_heating_removal", ) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index 003ff7bf..af020aee 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -80,26 +80,36 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: # Assert — the Plan ran and every fired measure names its trigger fields. assert report.plan is not None assert report.plan_error is None - # The efficient representative heat pump (Vaillant aroTHERM plus 5 kW, - # ADR-0025) raises SAP enough on this gas dwelling that ASHP + solid-floor - # insulation reach the target band on their own, displacing the fabric stack - # the Optimiser used to assemble (ADR-0024 — ASHP is now a strong candidate). + # This gas dwelling lodges an electric secondary heater (SAP 691) on a + # category-2 main, so secondary-heating removal (ADR-0028) is a very cheap + # SAP lever (\£250); the Optimiser reaches the target band via the fabric + # stack + that removal, leaving the \£12k ASHP unselected (it owns the + # economics — ADR-0024). triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) assert set(triggers) == { - "air_source_heat_pump", + "cavity_wall_insulation", + "mechanical_ventilation", "solid_floor_insulation", + "secondary_heating_removal", } - # ASHP fired because the dwelling is a non-flat house not already a heat pump - # (eligibility is physical/planning only — ADR-0024). - assert triggers["air_source_heat_pump"].triggers == { - "property_type": "0", - "main_heating_category": 2, + # Cavity-fill fired off an uninsulated cavity wall; its dependent MEV fired + # because no mechanical ventilation is lodged. + assert triggers["cavity_wall_insulation"].triggers == { + "wall_construction": 4, + "wall_insulation_type": 4, + } + assert triggers["mechanical_ventilation"].triggers == { + "mechanical_ventilation_kind": None, } # Solid-floor insulation fired off an uninsulated solid ground floor. assert triggers["solid_floor_insulation"].triggers == { "floor_insulation_thickness": None, "floor_construction_type": "Solid", } + # Secondary-heating removal fired off the lodged secondary (SAP code 691). + assert triggers["secondary_heating_removal"].triggers == { + "secondary_heating_type": 691, + } def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None: @@ -125,6 +135,23 @@ def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None: } +def test_secondary_heating_removal_surfaces_its_eligibility_triggers() -> None: + # No golden API cert selects secondary-heating removal, so the trigger branch + # is exercised directly. The generator fires on any lodged secondary, so the + # lodged SAP code is what the report should explain (ADR-0028). + from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage] + + # Arrange — a parseable 001431 cert with a secondary heating system lodged + # (SAP code 691, electric panel/convector/radiant heaters). + epc = parse_recommendation_summary("cavity_wall_001431_before.pdf") + epc.sap_heating.secondary_heating_type = 691 + + # Act / Assert + assert _triggers_for(epc, "secondary_heating_removal") == { + "secondary_heating_type": 691, + } + + 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. @@ -147,18 +174,18 @@ def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None: # Act report: PropertyReport = build_property_report(path) - # Assert — 0036 fires solid-floor insulation and the LED upgrade (it lodges - # 7 low-energy-unknown bulbs), and nothing else. + # Assert — 0036 reaches the target band with solid-floor insulation plus + # secondary-heating removal (it lodges an electric secondary, SAP 691, on a + # gas main — a cheap SAP lever, ADR-0028), and nothing else. The cheaper-to- + # target pair displaces the LED upgrade the Optimiser used to add. triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) - assert set(triggers) == {"solid_floor_insulation", "low_energy_lighting"} + assert set(triggers) == {"solid_floor_insulation", "secondary_heating_removal"} assert triggers["solid_floor_insulation"].triggers == { "floor_insulation_thickness": None, "floor_construction_type": "Solid", } - assert triggers["low_energy_lighting"].triggers == { - "incandescent_fixed_lighting_bulbs_count": 0, - "cfl_fixed_lighting_bulbs_count": None, - "low_energy_fixed_lighting_bulbs_count": 7, + assert triggers["secondary_heating_removal"].triggers == { + "secondary_heating_type": 691, } diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index b7ffb86f..cadd5daa 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -192,6 +192,14 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( is_active=True, description="Zoned heating controls + cylinder tune-up", ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), ] ) session.commit() @@ -316,6 +324,14 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( is_active=True, description="LED bulb", ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), ] ) session.commit() @@ -379,6 +395,11 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( # ADR-0025) now raises SAP even on this gas dwelling, so the Optimiser # also keeps the ASHP bundle in the least-cost-to-band package (ADR-0024). "air_source_heat_pump", + # The sample lodges an electric secondary (SAP 691), so removal is offered + # (ADR-0028); the Optimiser keeps it in its all-beneficial-measures package + # — its SAP gain is 0 once the ASHP (category 4) ignores the secondary, but + # the heater is still physically removed at its own cost. + "secondary_heating_removal", } # Each persisted measure carries the catalogue id of the Product it installs # (the MaterialRow ids seeded above), replacing the retired @@ -387,6 +408,7 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( assert by_type["suspended_floor_insulation"].material_id == 2 assert by_type["mechanical_ventilation"].material_id == 3 assert by_type["low_energy_lighting"].material_id == 4 + assert by_type["secondary_heating_removal"].material_id == 9 for rec in rec_rows: assert rec.default is True assert rec.already_installed is False @@ -491,6 +513,14 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band( is_active=True, description="LED bulb", ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), ] ) session.commit()