Model/tests/domain/modelling/test_optimiser_fabric_first.py
Khalim Conn-Kowlessar fcf46263bc Fabric-first scenario stops at fabric when the target is met 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:36:02 +00:00

119 lines
4.2 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 (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.modelling.measure_type import MeasureType
from domain.modelling.optimisation.optimiser import (
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,
HeatingOverlay,
)
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))
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