Model/tests/domain/modelling/test_optimiser_fabric_first.py
Khalim Conn-Kowlessar 832a30a985 Optimiser test fixtures are shared and domain-plausible 🟪
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>
2026-07-10 10:29:45 +00:00

349 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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