feat(modelling): wire secondary-heating-removal into the pipeline (ADR-0028)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 16:04:07 +00:00
parent 797b71cd13
commit b211715750
5 changed files with 83 additions and 17 deletions

View file

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

View file

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

View file

@ -41,6 +41,7 @@ _GENERATOR_MEASURE_TYPES = (
"gas_boiler_upgrade",
"system_tune_up",
"system_tune_up_zoned",
"secondary_heating_removal",
)

View file

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

View file

@ -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()