feat(modelling): wire the ASHP bundle into the candidate pool

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 17:12:07 +00:00
parent a1fc697d93
commit 0f89845321
6 changed files with 46 additions and 2 deletions

View file

@ -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,
}

View file

@ -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 {}

View file

@ -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 }
}

View file

@ -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]

View file

@ -35,6 +35,7 @@ _GENERATOR_MEASURE_TYPES = (
"secondary_glazing",
"low_energy_lighting",
"high_heat_retention_storage_heaters",
"air_source_heat_pump",
)

View file

@ -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",