mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Review findings on PR #1526: - tests/domain/modelling/_optimiser_fixtures.py is the one home for the overlay constants, the ScoredOption builder, the additive per-kind StubScorer and the forced ventilation dependency; test_optimiser.py and test_optimiser_fabric_first.py had byte-identical copies of each (and _StubScorer / _VentStubScorer fold into one parameterised stub). - Fixture worlds are domain-plausible per team convention: the fabric-vs- heating contrast is a £12,000 EWI against a £3,200 gas boiler rather than a £500 heat pump undercutting a £1,000 cavity wall; heating overlays carry real identities (SAP Table 4a code 104 for the boiler, a PCDF index for the heat pump) instead of code 201 doubling as both; whole-dwelling double glazing is £3,500, not £500. - Dead knobs removed: the unused _ROOF_OVERLAY, the always-zero roof gain, the duplicate _BOILER_OVERLAY, and the nested conditional expressions in the interaction stubs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""Behaviour of the Fabric First two-phase Optimiser: phase 1 optimises the
|
||
fabric measures (wall / roof / floor insulation + glazing) with the full
|
||
budget; if the truthful post-fabric score meets the Scenario target the
|
||
package is fabric-only. Otherwise phase 2 optimises the remaining measures on
|
||
top, where the starting point is the dwelling with the phase-1 fabric applied
|
||
and only the leftover budget is spendable. Mirrors the legacy engine's
|
||
``enforce_fabric_first`` (funding_optimiser.optimise_with_scenarios) on the
|
||
new truthful-re-score core (ADR-0016).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Sequence
|
||
|
||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||
from domain.modelling.measure_type import MeasureType
|
||
from domain.modelling.optimisation.optimiser import (
|
||
OptimisedPackage,
|
||
ScoredOption,
|
||
optimise_package_fabric_first,
|
||
)
|
||
from domain.modelling.scoring.package_scorer import Score
|
||
from domain.modelling.simulation import EpcSimulation
|
||
from tests.domain.modelling._optimiser_fixtures import (
|
||
ASHP_OVERLAY,
|
||
BOILER_OVERLAY,
|
||
GLAZING_OVERLAY,
|
||
WALL_OVERLAY,
|
||
StubScorer,
|
||
scored_option,
|
||
selected_types,
|
||
ventilation_dependency,
|
||
)
|
||
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
||
build_epc,
|
||
)
|
||
|
||
_AIRTIGHTNESS_TRIGGERS: frozenset[MeasureType] = frozenset(
|
||
{MeasureType.CAVITY_WALL_INSULATION, MeasureType.DOUBLE_GLAZING}
|
||
)
|
||
|
||
|
||
def test_fabric_reaching_the_target_excludes_non_fabric_measures() -> None:
|
||
# Arrange — the £3,200 boiler is the cheapest route to the target (a plain
|
||
# least-cost-to-target run would take it alone), but the wall by itself
|
||
# reaches the target: fabric first means the package stops at the fabric.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("external_wall_insulation", gain=12.0, cost=12000.0, overlay=WALL_OVERLAY)],
|
||
[scored_option("gas_boiler_upgrade", gain=15.0, cost=3200.0, overlay=BOILER_OVERLAY)],
|
||
]
|
||
scorer = StubScorer(base=60.0, wall=12.0, heating=15.0)
|
||
|
||
# Act — target 69 (gain 9 over the 60 baseline).
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=scorer,
|
||
baseline_epc=build_epc(),
|
||
budget=15000.0,
|
||
target_sap=69.0,
|
||
)
|
||
|
||
# Assert — fabric only: the wall (true 72 ≥ 69); the boiler is never
|
||
# considered because the upgrade requirement is already met.
|
||
assert selected_types(package.selected) == {"external_wall_insulation"}
|
||
assert abs(package.score.sap_continuous - 72.0) <= 1e-9
|
||
|
||
|
||
def test_fabric_short_of_target_is_topped_up_with_non_fabric_measures() -> None:
|
||
# Arrange — all the fabric there is (the wall, +5) cannot reach the target;
|
||
# phase 2 must add the heat pump on top of the retained fabric.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||
[scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)],
|
||
]
|
||
scorer = StubScorer(base=60.0, wall=5.0, heating=20.0)
|
||
|
||
# Act — target 75 (gain 15); fabric alone tops out at 65.
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=scorer,
|
||
baseline_epc=build_epc(),
|
||
budget=20000.0,
|
||
target_sap=75.0,
|
||
)
|
||
|
||
# Assert — the fabric is kept and the heat pump lands on top of it; the
|
||
# score is the truthful whole-package figure (60 + 5 + 20).
|
||
assert selected_types(package.selected) == {
|
||
"cavity_wall_insulation",
|
||
"air_source_heat_pump",
|
||
}
|
||
assert abs(package.score.sap_continuous - 85.0) <= 1e-9
|
||
|
||
|
||
def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None:
|
||
# Arrange — the £8000 heat pump alone would fit the £8500 budget and reach
|
||
# the target, but fabric first commits the £1000 wall first, leaving £7500:
|
||
# the heat pump no longer fits. Fabric priority wins over the target.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||
[scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)],
|
||
]
|
||
scorer = StubScorer(base=60.0, wall=5.0, heating=20.0)
|
||
|
||
# Act — target 78 (gain 18).
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=scorer,
|
||
baseline_epc=build_epc(),
|
||
budget=8500.0,
|
||
target_sap=78.0,
|
||
)
|
||
|
||
# Assert — wall only; the target is missed rather than the fabric skipped.
|
||
assert selected_types(package.selected) == {"cavity_wall_insulation"}
|
||
assert abs(package.score.sap_continuous - 65.0) <= 1e-9
|
||
|
||
|
||
class _AirtightnessScorer:
|
||
"""A stub where tightening the envelope demands ventilation: the cavity
|
||
wall is +5 SAP, the new double glazing is worthless on the raw dwelling
|
||
but +4 once the wall is insulated, and every ventilation overlay present
|
||
costs −1 — so a double injection is visible in the package score."""
|
||
|
||
def score(
|
||
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||
) -> Score:
|
||
wall = any(
|
||
part.wall_insulation_type is not None
|
||
for sim in simulations
|
||
for part in sim.building_parts.values()
|
||
)
|
||
glazing = any(sim.glazing is not None for sim in simulations)
|
||
vents = sum(1 for sim in simulations if sim.ventilation is not None)
|
||
sap = 60.0
|
||
if wall:
|
||
sap += 5.0
|
||
if wall and glazing:
|
||
sap += 4.0
|
||
sap -= float(vents)
|
||
return Score(
|
||
sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0
|
||
)
|
||
|
||
|
||
def test_ventilation_dependency_is_injected_once_across_both_phases() -> None:
|
||
# Arrange — the cavity wall (phase 1) and the double glazing (skipped in
|
||
# phase 1 on merit, picked in phase 2 on its post-fabric worth) both
|
||
# trigger the same forced ventilation. It must land in the package exactly
|
||
# once — phase 2 sees the phase-1 dwelling as already ventilated.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||
[scored_option("double_glazing", gain=0.0, cost=3500.0, overlay=GLAZING_OVERLAY)],
|
||
]
|
||
scorer = _AirtightnessScorer()
|
||
|
||
# Act — target 68: phase 1 gives 60 + 5 − 1 = 64; the glazing's
|
||
# post-fabric +4 closes it, but only if ventilation is not double-counted.
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=scorer,
|
||
baseline_epc=build_epc(),
|
||
budget=10000.0,
|
||
target_sap=68.0,
|
||
dependencies=[
|
||
ventilation_dependency(cost=300.0, triggers=_AIRTIGHTNESS_TRIGGERS)
|
||
],
|
||
)
|
||
|
||
# Assert — one ventilation, and the truthful total counts its penalty once:
|
||
# 60 + 5 wall + 4 glazing − 1 ventilation = 68.
|
||
ventilation_count = sum(
|
||
1
|
||
for scored in package.selected
|
||
if scored.option.measure_type == MeasureType.MECHANICAL_VENTILATION
|
||
)
|
||
assert ventilation_count == 1
|
||
assert selected_types(package.selected) == {
|
||
"cavity_wall_insulation",
|
||
"double_glazing",
|
||
"mechanical_ventilation",
|
||
}
|
||
assert abs(package.score.sap_continuous - 68.0) <= 1e-9
|
||
|
||
|
||
def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None:
|
||
# Arrange — the envelope work is already done (no fabric Recommendation
|
||
# survives generation); fabric first must not veto the run, it just means
|
||
# phase 1 has nothing to do.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)],
|
||
]
|
||
scorer = StubScorer(base=60.0, heating=20.0)
|
||
|
||
# Act — target 75.
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=scorer,
|
||
baseline_epc=build_epc(),
|
||
budget=20000.0,
|
||
target_sap=75.0,
|
||
)
|
||
|
||
# Assert — the heat pump package, exactly as a plain run would produce.
|
||
assert selected_types(package.selected) == {"air_source_heat_pump"}
|
||
assert abs(package.score.sap_continuous - 80.0) <= 1e-9
|
||
|
||
|
||
def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None:
|
||
# Arrange — a max-gain goal (no SAP target). Plain max-gain would spend the
|
||
# whole £8000 on the heat pump (+20); fabric first commits the wall (+5)
|
||
# before the remainder is considered, pricing the heat pump out.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||
[scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)],
|
||
]
|
||
scorer = StubScorer(base=60.0, wall=5.0, heating=20.0)
|
||
|
||
# Act — no target: the flag applies to every goal, not just Increasing EPC.
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=scorer,
|
||
baseline_epc=build_epc(),
|
||
budget=8000.0,
|
||
target_sap=None,
|
||
)
|
||
|
||
# Assert — wall first; the heat pump no longer fits the leftover £7000.
|
||
assert selected_types(package.selected) == {"cavity_wall_insulation"}
|
||
assert abs(package.score.sap_continuous - 65.0) <= 1e-9
|
||
|
||
|
||
class _InteractionScorer:
|
||
"""A stub whose boiler gain collapses once the wall is insulated (+10 raw,
|
||
+3 post-fabric) while the heat pump's holds (+8 either way) — so a phase 2
|
||
that keeps valuing candidates against the raw baseline picks the wrong
|
||
heating system."""
|
||
|
||
def score(
|
||
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||
) -> Score:
|
||
wall_present = any(
|
||
part.wall_insulation_type is not None
|
||
for sim in simulations
|
||
for part in sim.building_parts.values()
|
||
)
|
||
sap = 60.0 + (5.0 if wall_present else 0.0)
|
||
for sim in simulations:
|
||
if sim.heating is None:
|
||
continue
|
||
if sim.heating.sap_main_heating_code is not None:
|
||
sap += 3.0 if wall_present else 10.0
|
||
if sim.heating.main_heating_index_number is not None:
|
||
sap += 8.0
|
||
return Score(
|
||
sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0
|
||
)
|
||
|
||
|
||
class _GlazingInteractionScorer:
|
||
"""A stub where glazing is worthless on the raw dwelling (+0) but worth +4
|
||
once the wall is insulated — so phase 1's max-gain fabric pass leaves it
|
||
out, and only a phase 2 that re-admits unpicked fabric can close the
|
||
target with it."""
|
||
|
||
def score(
|
||
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||
) -> Score:
|
||
wall_present = any(
|
||
part.wall_insulation_type is not None
|
||
for sim in simulations
|
||
for part in sim.building_parts.values()
|
||
)
|
||
glazing_present = any(sim.glazing is not None for sim in simulations)
|
||
heating_present = any(sim.heating is not None for sim in simulations)
|
||
sap = 60.0
|
||
if wall_present:
|
||
sap += 5.0
|
||
if wall_present and glazing_present:
|
||
sap += 4.0
|
||
if heating_present:
|
||
sap += 10.0
|
||
return Score(
|
||
sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0
|
||
)
|
||
|
||
|
||
def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None:
|
||
# Arrange — glazing loses phase 1 on merit (it scores nothing on the raw
|
||
# dwelling), but post-wall it is the only affordable way to the target:
|
||
# the heat pump that could also close it does not fit the leftover budget.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||
[scored_option("double_glazing", gain=0.0, cost=3500.0, overlay=GLAZING_OVERLAY)],
|
||
[scored_option("air_source_heat_pump", gain=10.0, cost=8000.0, overlay=ASHP_OVERLAY)],
|
||
]
|
||
scorer = _GlazingInteractionScorer()
|
||
|
||
# Act — target 69 (gain 9); budget £5000 keeps the heat pump out of reach
|
||
# after the wall's £1000.
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=scorer,
|
||
baseline_epc=build_epc(),
|
||
budget=5000.0,
|
||
target_sap=69.0,
|
||
)
|
||
|
||
# Assert — the skipped glazing re-enters on its post-fabric worth: 60 + 5
|
||
# wall + 4 glazing = 69, target met.
|
||
assert selected_types(package.selected) == {
|
||
"cavity_wall_insulation",
|
||
"double_glazing",
|
||
}
|
||
assert abs(package.score.sap_continuous - 69.0) <= 1e-9
|
||
|
||
|
||
def test_phase_two_values_candidates_against_the_post_fabric_dwelling() -> None:
|
||
# Arrange — one heating Recommendation, two Options. The boiler's role-1
|
||
# signal (vs the raw baseline, +10) beats the heat pump's (+8) and it is
|
||
# cheaper — but on the insulated dwelling the boiler is only worth +3.
|
||
# Only a heat pump gets the fabric-applied dwelling to the target.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||
[
|
||
scored_option("gas_boiler_upgrade", gain=10.0, cost=3200.0, overlay=BOILER_OVERLAY),
|
||
scored_option("air_source_heat_pump", gain=8.0, cost=8000.0, overlay=ASHP_OVERLAY),
|
||
],
|
||
]
|
||
scorer = _InteractionScorer()
|
||
|
||
# Act — target 73: wall (65) + boiler-post-fabric (+3) = 68 misses; wall +
|
||
# heat pump (+8) = 73 reaches. The heating group is consumed by whichever
|
||
# option phase 2 warm-starts with, so the choice must be made on
|
||
# post-fabric values, not raw-baseline signals.
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=scorer,
|
||
baseline_epc=build_epc(),
|
||
budget=20000.0,
|
||
target_sap=73.0,
|
||
)
|
||
|
||
# Assert
|
||
assert selected_types(package.selected) == {
|
||
"cavity_wall_insulation",
|
||
"air_source_heat_pump",
|
||
}
|
||
assert abs(package.score.sap_continuous - 73.0) <= 1e-9
|