From 0f89845321daea60b23e35fd9c2c132b0debaf1b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 17:12:07 +0000 Subject: [PATCH] feat(modelling): wire the ASHP bundle into the candidate pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_heating now receives planning_restrictions in the orchestrator (the ASHP planning gate); the ASHP bundle joins the free candidate pool for every house/bungalow. Catalogue + contingency (legacy 0.25) gain air_source_heat_pump; report.py _triggers_for explains the ASHP trigger; the harness forcing test covers it. Integration tests seed an air_source_heat_pump MaterialRow (ASHP fires on every house, the broadest trigger yet). NB the optimiser correctly does NOT select ASHP for an EPC-band goal — gas->electric does not improve the SAP cost-rating; ASHP is a CO2/PE measure, selectable once non-EPC goals land. ASHP bundle COMPLETE (S5-S7). ADR-0024. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/contingencies.py | 1 + harness/report.py | 9 ++++++ harness/sample_catalogue.json | 3 +- orchestration/modelling_orchestrator.py | 2 +- tests/harness/test_console.py | 1 + ...test_ara_first_run_pipeline_integration.py | 32 +++++++++++++++++++ 6 files changed, 46 insertions(+), 2 deletions(-) diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index 8d011ffb..7114313d 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -19,6 +19,7 @@ _CONTINGENCY_RATES: dict[str, float] = { "secondary_glazing": 0.15, "low_energy_lighting": 0.26, "high_heat_retention_storage_heaters": 0.10, + "air_source_heat_pump": 0.25, } diff --git a/harness/report.py b/harness/report.py index f9459bc4..c93f0d02 100644 --- a/harness/report.py +++ b/harness/report.py @@ -144,6 +144,15 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: ), "mains_gas": epc.sap_energy_source.mains_gas, } + if measure_type == "air_source_heat_pump": + # heating_recommendation.py offers ASHP to any non-flat house/bungalow + # not already a heat pump (eligibility is physical/planning only). + return { + "property_type": epc.property_type, + "main_heating_category": ( + epc.sap_heating.main_heating_details[0].main_heating_category + ), + } return {} diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index fb34e28b..0d17b2b6 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -11,5 +11,6 @@ "double_glazing": { "unit_cost_per_m2": 600.0 }, "secondary_glazing": { "unit_cost_per_m2": 510.0 }, "low_energy_lighting": { "unit_cost_per_m2": 8.0 }, - "high_heat_retention_storage_heaters": { "unit_cost_per_m2": 3500.0 } + "high_heat_retention_storage_heaters": { "unit_cost_per_m2": 3500.0 }, + "air_source_heat_pump": { "unit_cost_per_m2": 12000.0 } } diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index df375f65..4773b2f1 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -210,7 +210,7 @@ def _candidate_recommendations( recommend_floor_insulation(effective_epc, products), recommend_glazing(effective_epc, products, planning_restrictions), recommend_lighting(effective_epc, products), - recommend_heating(effective_epc, products), + recommend_heating(effective_epc, products, planning_restrictions), ) return [recommendation for recommendation in found if recommendation is not None] diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 2f1d6bd5..a252b06a 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -35,6 +35,7 @@ _GENERATOR_MEASURE_TYPES = ( "secondary_glazing", "low_energy_lighting", "high_heat_retention_storage_heaters", + "air_source_heat_pump", ) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 96f2b0dd..49c77033 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -128,6 +128,14 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( # the double-glazing Product (ADR-0022). session.add_all( [ + MaterialRow( + id=5, + type="air_source_heat_pump", + total_cost=12000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Air source heat pump", + ), MaterialRow( id=1, type="solid_floor_insulation", @@ -244,6 +252,14 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( ) session.add_all( [ + MaterialRow( + id=5, + type="air_source_heat_pump", + total_cost=12000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Air source heat pump", + ), MaterialRow( id=1, type="cavity_wall_insulation", @@ -407,6 +423,14 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band( # nothing is ultimately selected. session.add_all( [ + MaterialRow( + id=14, + type="air_source_heat_pump", + total_cost=12000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Air source heat pump", + ), MaterialRow( id=10, type="cavity_wall_insulation", @@ -548,6 +572,14 @@ def test_listed_uprn_ingested_blocks_solid_wall_insulation_in_modelling( # ventilation dependency, so every Product they reach for must exist. session.add_all( [ + MaterialRow( + id=5, + type="air_source_heat_pump", + total_cost=12000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Air source heat pump", + ), MaterialRow( id=1, type="external_wall_insulation",