mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
797b71cd13
commit
b211715750
5 changed files with 83 additions and 17 deletions
|
|
@ -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 {}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ _GENERATOR_MEASURE_TYPES = (
|
|||
"gas_boiler_upgrade",
|
||||
"system_tune_up",
|
||||
"system_tune_up_zoned",
|
||||
"secondary_heating_removal",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue