Model/tests/domain/modelling/test_optimiser_fabric_first.py
Khalim Conn-Kowlessar 0220bee87c Forced ventilation is injected once across both fabric-first phases 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:41:27 +00:00

394 lines
15 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 (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.modelling.measure_type import MeasureType
from domain.modelling.optimisation.optimiser import (
MeasureDependency,
OptimisedPackage,
ScoredOption,
optimise_package_fabric_first,
)
from domain.modelling.recommendation import Cost, MeasureOption
from domain.modelling.scoring.package_scorer import Score
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
GlazingOverlay,
HeatingOverlay,
VentilationOverlay,
)
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
_WALL_OVERLAY = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2)
}
)
_ROOF_OVERLAY = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300)
}
)
_HEATING_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201))
_BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201))
_ASHP_OVERLAY = EpcSimulation(
heating=HeatingOverlay(main_heating_index_number=13000)
)
def _scored(
measure_type: str, *, gain: float, cost: float, overlay: EpcSimulation
) -> ScoredOption:
return ScoredOption(
option=MeasureOption(
measure_type=MeasureType(measure_type),
description=measure_type,
overlay=overlay,
cost=Cost(total=cost, contingency_rate=0.0),
),
sap_gain=gain,
)
class _StubScorer:
"""Deterministic stand-in for PackageScorer: the package SAP is a base plus
a fixed true gain per overlay kind present (wall / roof / heating), so the
two-phase selection is exercised without the calculator."""
def __init__(
self, *, base: float, wall: float, roof: float, heating: float
) -> None:
self._base = base
self._wall = wall
self._roof = roof
self._heating = heating
def score(
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
) -> Score:
sap = self._base
for sim in simulations:
if sim.heating is not None:
sap += self._heating
for part in sim.building_parts.values():
if part.wall_insulation_type is not None:
sap += self._wall
if part.roof_insulation_thickness is not None:
sap += self._roof
return Score(
sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0
)
def _selected_types(package: OptimisedPackage) -> set[str]:
return {scored.option.measure_type for scored in package.selected}
def test_fabric_reaching_the_target_excludes_non_fabric_measures() -> None:
# Arrange — the ASHP dominates on both gain and SAP-per-£ (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("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored("air_source_heat_pump", gain=30.0, cost=500.0, overlay=_HEATING_OVERLAY)],
]
scorer = _StubScorer(base=60.0, wall=10.0, roof=0.0, heating=30.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=10000.0,
target_sap=69.0,
)
# Assert — fabric only: the wall (true 70 ≥ 69); the heat pump is never
# considered because the upgrade requirement is already met.
assert _selected_types(package) == {"cavity_wall_insulation"}
assert abs(package.score.sap_continuous - 70.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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)],
]
scorer = _StubScorer(base=60.0, wall=5.0, roof=0.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) == {
"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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)],
]
scorer = _StubScorer(base=60.0, wall=5.0, roof=0.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) == {"cavity_wall_insulation"}
assert abs(package.score.sap_continuous - 65.0) <= 1e-9
_IWI_OVERLAY = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3)
}
)
_VENT_OVERLAY = EpcSimulation(
ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE")
)
class _TwoWallScorer:
"""A stub with two wall treatments (cavity type=2, internal type=3): the
internal wall is worthless raw but +4 once the cavity is done, and every
ventilation overlay present costs 1 — so both phases trigger the same
forced ventilation dependency and a double injection is visible in the
package score."""
def score(
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
) -> Score:
cavity = any(
part.wall_insulation_type == 2
for sim in simulations
for part in sim.building_parts.values()
)
internal = any(
part.wall_insulation_type == 3
for sim in simulations
for part in sim.building_parts.values()
)
vents = sum(1 for sim in simulations if sim.ventilation is not None)
sap = 60.0
sap += 5.0 if cavity else 0.0
sap += (4.0 if cavity else 0.0) if internal else 0.0
sap -= float(vents)
return Score(
sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0
)
def _wall_ventilation_dependency(*, cost: float) -> MeasureDependency:
return MeasureDependency(
triggers=frozenset(
{
MeasureType.CAVITY_WALL_INSULATION,
MeasureType.INTERNAL_WALL_INSULATION,
}
),
required=ScoredOption(
option=MeasureOption(
measure_type=MeasureType.MECHANICAL_VENTILATION,
description="mechanical_ventilation",
overlay=_VENT_OVERLAY,
cost=Cost(total=cost, contingency_rate=0.0),
),
sap_gain=0.0,
),
)
def test_ventilation_dependency_is_injected_once_across_both_phases() -> None:
# Arrange — the cavity wall (phase 1) and the internal wall (picked in
# phase 2 on its post-cavity 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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored("internal_wall_insulation", gain=0.0, cost=2000.0, overlay=_IWI_OVERLAY)],
]
scorer = _TwoWallScorer()
# Act — target 68: phase 1 gives 60 + 5 1 = 64; the internal wall'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=[_wall_ventilation_dependency(cost=300.0)],
)
# Assert — one ventilation, and the truthful total counts its penalty once:
# 60 + 5 cavity + 4 internal 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) == {
"cavity_wall_insulation",
"internal_wall_insulation",
"mechanical_ventilation",
}
assert abs(package.score.sap_continuous - 68.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
)
_GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2))
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
sap += 5.0 if wall_present else 0.0
sap += (4.0 if wall_present else 0.0) if glazing_present else 0.0
sap += 10.0 if heating_present else 0.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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[_scored("double_glazing", gain=0.0, cost=500.0, overlay=_GLAZING_OVERLAY)],
[_scored("air_source_heat_pump", gain=10.0, cost=8000.0, overlay=_HEATING_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) == {
"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("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=_WALL_OVERLAY)],
[
_scored("gas_boiler_upgrade", gain=10.0, cost=2000.0, overlay=_BOILER_OVERLAY),
_scored("air_source_heat_pump", gain=8.0, cost=6000.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) == {
"cavity_wall_insulation",
"air_source_heat_pump",
}
assert abs(package.score.sap_continuous - 73.0) <= 1e-9