mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
211 lines
7.9 KiB
Python
211 lines
7.9 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))
|
|
_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
|
|
|
|
|
|
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
|
|
)
|
|
|
|
|
|
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
|