mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
This commit is contained in:
parent
b37ee071b7
commit
832a30a985
3 changed files with 291 additions and 370 deletions
144
tests/domain/modelling/_optimiser_fixtures.py
Normal file
144
tests/domain/modelling/_optimiser_fixtures.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""Shared fixtures for the Optimiser test files: distinguishable Simulation
|
||||
Overlays (so a stub scorer can attribute a true gain per measure kind), the
|
||||
ScoredOption builder, the additive per-kind stub scorer, and the forced
|
||||
ventilation Measure Dependency edge.
|
||||
|
||||
Fixture values stay domain-plausible: overlay heating codes are real SAP
|
||||
Table 4a codes (104 = mains-gas combi boiler, heat pumps carry a PCDF index),
|
||||
and tests price measures at realistic magnitudes (a CWI around £1,000, an
|
||||
ASHP around £8,000)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, 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, ScoredOption
|
||||
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,
|
||||
)
|
||||
|
||||
WALL_OVERLAY = EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2)
|
||||
}
|
||||
)
|
||||
ROOF_OVERLAY = EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300)
|
||||
}
|
||||
)
|
||||
FLOOR_OVERLAY = EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(floor_insulation_thickness=100)
|
||||
}
|
||||
)
|
||||
GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2))
|
||||
VENT_OVERLAY = EpcSimulation(
|
||||
ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE")
|
||||
)
|
||||
# SAP Table 4a code 104: a mains-gas combi boiler.
|
||||
BOILER_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=104))
|
||||
# Heat pumps are expressed as a PCDF product index, as the generator emits them.
|
||||
ASHP_OVERLAY = EpcSimulation(heating=HeatingOverlay(main_heating_index_number=13000))
|
||||
|
||||
_WALL_TRIGGERS: frozenset[MeasureType] = frozenset(
|
||||
{MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_WALL_INSULATION}
|
||||
)
|
||||
|
||||
|
||||
def scored_option(
|
||||
measure_type: str,
|
||||
*,
|
||||
gain: float,
|
||||
cost: float,
|
||||
overlay: Optional[EpcSimulation] = None,
|
||||
) -> ScoredOption:
|
||||
"""A one-Option fixture: ``gain`` is the role-1 warm-start signal, ``cost``
|
||||
the total install cost. Omit ``overlay`` where the test never re-scores."""
|
||||
return ScoredOption(
|
||||
option=MeasureOption(
|
||||
measure_type=MeasureType(measure_type),
|
||||
description=measure_type,
|
||||
overlay=overlay if overlay is not None else EpcSimulation(),
|
||||
cost=Cost(total=cost, contingency_rate=0.0),
|
||||
),
|
||||
sap_gain=gain,
|
||||
)
|
||||
|
||||
|
||||
class StubScorer:
|
||||
"""A deterministic stand-in for PackageScorer: the package SAP is a base
|
||||
plus a fixed *true* gain per measure kind present (detected by overlay
|
||||
field), decoupled from the role-1 signal — so selection, repair and the
|
||||
two-phase split are exercised without the calculator. Kinds a test does
|
||||
not use default to 0."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base: float,
|
||||
wall: float = 0.0,
|
||||
roof: float = 0.0,
|
||||
floor: float = 0.0,
|
||||
heating: float = 0.0,
|
||||
vent: float = 0.0,
|
||||
) -> None:
|
||||
self._base = base
|
||||
self._wall = wall
|
||||
self._roof = roof
|
||||
self._floor = floor
|
||||
self._heating = heating
|
||||
self._vent = vent
|
||||
|
||||
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
|
||||
if sim.ventilation is not None:
|
||||
sap += self._vent
|
||||
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
|
||||
if part.floor_insulation_thickness is not None:
|
||||
sap += self._floor
|
||||
return Score(
|
||||
sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0
|
||||
)
|
||||
|
||||
|
||||
def selected_types(selection: Sequence[ScoredOption]) -> set[str]:
|
||||
return {scored.option.measure_type for scored in selection}
|
||||
|
||||
|
||||
def ventilation_dependency(
|
||||
*, cost: float, triggers: frozenset[MeasureType] = _WALL_TRIGGERS
|
||||
) -> MeasureDependency:
|
||||
"""A forced 'airtightness requires ventilation' edge for the tests."""
|
||||
return MeasureDependency(
|
||||
triggers=triggers,
|
||||
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, # placeholder; optimise_package scores the real signal
|
||||
),
|
||||
)
|
||||
|
|
@ -8,14 +8,7 @@ selection with synthetic scores and no calculator.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
)
|
||||
from domain.modelling.optimisation.optimiser import (
|
||||
MeasureDependency,
|
||||
OptimisedPackage,
|
||||
ScoredOption,
|
||||
optimise,
|
||||
|
|
@ -23,104 +16,30 @@ from domain.modelling.optimisation.optimiser import (
|
|||
optimise_package,
|
||||
)
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.scoring.package_scorer import Score
|
||||
from domain.modelling.recommendation import Cost, MeasureOption
|
||||
from domain.modelling.simulation import (
|
||||
BuildingPartOverlay,
|
||||
EpcSimulation,
|
||||
VentilationOverlay,
|
||||
from tests.domain.modelling._optimiser_fixtures import (
|
||||
FLOOR_OVERLAY,
|
||||
ROOF_OVERLAY,
|
||||
WALL_OVERLAY,
|
||||
StubScorer,
|
||||
scored_option,
|
||||
selected_types,
|
||||
ventilation_dependency,
|
||||
)
|
||||
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
||||
build_epc,
|
||||
)
|
||||
|
||||
|
||||
def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption:
|
||||
return ScoredOption(
|
||||
option=MeasureOption(
|
||||
measure_type=MeasureType(measure_type),
|
||||
description=measure_type,
|
||||
overlay=EpcSimulation(),
|
||||
cost=Cost(total=cost, contingency_rate=0.0),
|
||||
),
|
||||
sap_gain=gain,
|
||||
)
|
||||
|
||||
|
||||
# Distinguishable overlays so the stub scorer can attribute a true gain per
|
||||
# measure (wall / roof / floor) regardless of the role-1 signal.
|
||||
_WALL_OVERLAY = EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2)
|
||||
}
|
||||
)
|
||||
_ROOF_OVERLAY = EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300)
|
||||
}
|
||||
)
|
||||
_FLOOR_OVERLAY = EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(floor_insulation_thickness=100)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _scored_overlay(
|
||||
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:
|
||||
"""A deterministic stand-in for PackageScorer: the package SAP is a base
|
||||
plus a fixed *true* gain per measure present (by overlay field), decoupled
|
||||
from the role-1 signal — so the repair loop is exercised without the
|
||||
calculator (ADR-0016)."""
|
||||
|
||||
def __init__(self, *, base: float, wall: float, roof: float, floor: float) -> None:
|
||||
self._base = base
|
||||
self._wall = wall
|
||||
self._roof = roof
|
||||
self._floor = floor
|
||||
|
||||
def score(
|
||||
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||||
) -> Score:
|
||||
sap = self._base
|
||||
for sim in simulations:
|
||||
part = sim.building_parts[BuildingPartIdentifier.MAIN]
|
||||
if part.wall_insulation_type is not None:
|
||||
sap += self._wall
|
||||
if part.roof_insulation_thickness is not None:
|
||||
sap += self._roof
|
||||
if part.floor_insulation_thickness is not None:
|
||||
sap += self._floor
|
||||
return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0)
|
||||
|
||||
|
||||
def _selected_types(selection: list[ScoredOption]) -> set[str]:
|
||||
return {scored.option.measure_type for scored in selection}
|
||||
|
||||
|
||||
def test_grouped_knapsack_maximises_gain_within_budget() -> None:
|
||||
# Arrange — wall group has two mutually-exclusive options; roof + floor one
|
||||
# each. EWI has the best gain but is unaffordable alongside the rest.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("external_wall_insulation", gain=10.0, cost=8000.0),
|
||||
_scored("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
scored_option("external_wall_insulation", gain=10.0, cost=8000.0),
|
||||
scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
],
|
||||
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
[_scored("suspended_floor_insulation", gain=3.0, cost=2000.0)],
|
||||
[scored_option("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
[scored_option("suspended_floor_insulation", gain=3.0, cost=2000.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
|
|
@ -128,7 +47,7 @@ def test_grouped_knapsack_maximises_gain_within_budget() -> None:
|
|||
|
||||
# Assert — cavity + loft + floor (cost 4500, gain 13) beats any package
|
||||
# containing the 8000 EWI option within the 5000 budget.
|
||||
assert _selected_types(selection) == {
|
||||
assert selected_types(selection) == {
|
||||
"cavity_wall_insulation",
|
||||
"loft_insulation",
|
||||
"suspended_floor_insulation",
|
||||
|
|
@ -139,8 +58,8 @@ def test_picks_at_most_one_option_per_group() -> None:
|
|||
# Arrange — both wall options are individually affordable.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("external_wall_insulation", gain=10.0, cost=2000.0),
|
||||
_scored("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
scored_option("external_wall_insulation", gain=10.0, cost=2000.0),
|
||||
scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
],
|
||||
]
|
||||
|
||||
|
|
@ -149,24 +68,24 @@ def test_picks_at_most_one_option_per_group() -> None:
|
|||
|
||||
# Assert — never both treatments of the same wall; the higher-gain one wins.
|
||||
assert len(selection) == 1
|
||||
assert _selected_types(selection) == {"external_wall_insulation"}
|
||||
assert selected_types(selection) == {"external_wall_insulation"}
|
||||
|
||||
|
||||
def test_no_budget_picks_the_best_option_in_every_group() -> None:
|
||||
# Arrange
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("external_wall_insulation", gain=10.0, cost=8000.0),
|
||||
_scored("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
scored_option("external_wall_insulation", gain=10.0, cost=8000.0),
|
||||
scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
],
|
||||
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
[scored_option("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act — None budget = unconstrained.
|
||||
selection: list[ScoredOption] = optimise(groups, budget=None)
|
||||
|
||||
# Assert
|
||||
assert _selected_types(selection) == {
|
||||
assert selected_types(selection) == {
|
||||
"external_wall_insulation",
|
||||
"loft_insulation",
|
||||
}
|
||||
|
|
@ -175,8 +94,8 @@ def test_no_budget_picks_the_best_option_in_every_group() -> None:
|
|||
def test_budget_too_small_for_any_option_selects_nothing() -> None:
|
||||
# Arrange
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)],
|
||||
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
[scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)],
|
||||
[scored_option("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
|
|
@ -194,15 +113,15 @@ def test_no_groups_selects_nothing() -> None:
|
|||
def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> None:
|
||||
# Arrange — only one of the two fits the budget; pick the affordable best.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("external_wall_insulation", gain=10.0, cost=8000.0)],
|
||||
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
[scored_option("external_wall_insulation", gain=10.0, cost=8000.0)],
|
||||
[scored_option("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
selection: list[ScoredOption] = optimise(groups, budget=2000.0)
|
||||
|
||||
# Assert — EWI is unaffordable; loft alone is the best within £2000.
|
||||
assert _selected_types(selection) == {"loft_insulation"}
|
||||
assert selected_types(selection) == {"loft_insulation"}
|
||||
|
||||
|
||||
# --- optimise_min_cost: least-cost-to-target selection (ADR-0016 amendment) ---
|
||||
|
|
@ -212,8 +131,8 @@ def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None:
|
|||
# Arrange — two packages both clear the target gain; one is cheaper.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("loft_insulation", gain=10.0, cost=2000.0),
|
||||
_scored("external_wall_insulation", gain=15.0, cost=3000.0),
|
||||
scored_option("loft_insulation", gain=10.0, cost=2000.0),
|
||||
scored_option("external_wall_insulation", gain=15.0, cost=3000.0),
|
||||
],
|
||||
]
|
||||
|
||||
|
|
@ -223,7 +142,7 @@ def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None:
|
|||
# Assert — least-cost-to-target takes the +10 @ £2000, NOT the higher-gain
|
||||
# +15 @ £3000 (no overshoot, surplus budget unspent).
|
||||
assert selection is not None
|
||||
assert _selected_types(selection) == {"loft_insulation"}
|
||||
assert selected_types(selection) == {"loft_insulation"}
|
||||
|
||||
|
||||
def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None:
|
||||
|
|
@ -232,10 +151,10 @@ def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None:
|
|||
# £8000).
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
_scored("external_wall_insulation", gain=10.0, cost=8000.0),
|
||||
scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0),
|
||||
scored_option("external_wall_insulation", gain=10.0, cost=8000.0),
|
||||
],
|
||||
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
[scored_option("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
|
|
@ -243,7 +162,7 @@ def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None:
|
|||
|
||||
# Assert
|
||||
assert selection is not None
|
||||
assert _selected_types(selection) == {
|
||||
assert selected_types(selection) == {
|
||||
"cavity_wall_insulation",
|
||||
"loft_insulation",
|
||||
}
|
||||
|
|
@ -254,8 +173,8 @@ def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None:
|
|||
# one with more headroom ("recommend more" on a tie).
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[
|
||||
_scored("cavity_wall_insulation", gain=10.0, cost=2000.0),
|
||||
_scored("external_wall_insulation", gain=14.0, cost=2000.0),
|
||||
scored_option("cavity_wall_insulation", gain=10.0, cost=2000.0),
|
||||
scored_option("external_wall_insulation", gain=14.0, cost=2000.0),
|
||||
],
|
||||
]
|
||||
|
||||
|
|
@ -264,13 +183,13 @@ def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None:
|
|||
|
||||
# Assert
|
||||
assert selection is not None
|
||||
assert _selected_types(selection) == {"external_wall_insulation"}
|
||||
assert selected_types(selection) == {"external_wall_insulation"}
|
||||
|
||||
|
||||
def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None:
|
||||
# Arrange — the only target-reaching package costs more than the budget.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("external_wall_insulation", gain=10.0, cost=8000.0)],
|
||||
[scored_option("external_wall_insulation", gain=10.0, cost=8000.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
|
|
@ -283,8 +202,8 @@ def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None:
|
|||
def test_min_cost_returns_none_when_no_package_reaches_the_target() -> None:
|
||||
# Arrange — even everything together falls short of the target gain.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)],
|
||||
[_scored("loft_insulation", gain=3.0, cost=1500.0)],
|
||||
[scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)],
|
||||
[scored_option("loft_insulation", gain=3.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
|
|
@ -298,8 +217,8 @@ def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() ->
|
|||
# Arrange — no budget cap, but min-cost still means cheapest-to-target, not
|
||||
# "install everything".
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("cavity_wall_insulation", gain=10.0, cost=1000.0)],
|
||||
[_scored("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0)],
|
||||
[scored_option("loft_insulation", gain=4.0, cost=1500.0)],
|
||||
]
|
||||
|
||||
# Act — cavity alone (+10 @ £1000) already reaches the target.
|
||||
|
|
@ -307,14 +226,14 @@ def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() ->
|
|||
|
||||
# Assert — loft is left off; it would only add cost past the target.
|
||||
assert selection is not None
|
||||
assert _selected_types(selection) == {"cavity_wall_insulation"}
|
||||
assert selected_types(selection) == {"cavity_wall_insulation"}
|
||||
|
||||
|
||||
def test_min_cost_non_positive_target_selects_nothing() -> None:
|
||||
# Arrange — a target already met (gain 0 needed) is reached by the empty
|
||||
# package at zero cost.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)],
|
||||
[scored_option("cavity_wall_insulation", gain=6.0, cost=1000.0)],
|
||||
]
|
||||
|
||||
# Act
|
||||
|
|
@ -328,11 +247,11 @@ def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None
|
|||
# Arrange — role-1 under-counts roof (signal 0 → warm-start skips it), but
|
||||
# its true re-scored gain (+4) is what closes the target.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
|
||||
[_scored_overlay("suspended_floor_insulation", gain=8.0, cost=1000.0, overlay=_FLOOR_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
[scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)],
|
||||
[scored_option("suspended_floor_insulation", gain=8.0, cost=1000.0, overlay=FLOOR_OVERLAY)],
|
||||
]
|
||||
scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0)
|
||||
scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0)
|
||||
|
||||
# Act
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -358,9 +277,9 @@ def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None
|
|||
def test_no_target_returns_the_warm_start_package_without_repair() -> None:
|
||||
# Arrange
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
]
|
||||
scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0)
|
||||
scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0)
|
||||
|
||||
# Act
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -381,10 +300,10 @@ def test_no_target_returns_the_warm_start_package_without_repair() -> None:
|
|||
def test_repair_stops_when_no_affordable_improving_option_remains() -> None:
|
||||
# Arrange — the only untreated-group option costs more than the budget left.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[_scored_overlay("loft_insulation", gain=0.0, cost=5000.0, overlay=_ROOF_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
[scored_option("loft_insulation", gain=0.0, cost=5000.0, overlay=ROOF_OVERLAY)],
|
||||
]
|
||||
scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0)
|
||||
scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0)
|
||||
|
||||
# Act
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -410,11 +329,11 @@ def test_package_stops_at_the_target_and_does_not_overshoot() -> None:
|
|||
# Arrange — wall alone already clears the target; max-gain would add roof +
|
||||
# floor too. Least-cost-to-target must stop at the wall.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
|
||||
[_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
[scored_option("loft_insulation", gain=5.0, cost=1000.0, overlay=ROOF_OVERLAY)],
|
||||
[scored_option("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=FLOOR_OVERLAY)],
|
||||
]
|
||||
scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0)
|
||||
scorer = StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0)
|
||||
|
||||
# Act — target 69 (gain 9); wall (+10 → 70) reaches it for £1000.
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -427,18 +346,18 @@ def test_package_stops_at_the_target_and_does_not_overshoot() -> None:
|
|||
|
||||
# Assert — just the wall; roof + floor (which would reach 80) are left off,
|
||||
# surplus budget unspent.
|
||||
assert _selected_types(package.selected) == {"cavity_wall_insulation"}
|
||||
assert selected_types(package.selected) == {"cavity_wall_insulation"}
|
||||
assert abs(package.score.sap_continuous - 70.0) <= 1e-9
|
||||
|
||||
|
||||
def test_package_falls_back_to_max_gain_when_target_unreachable() -> None:
|
||||
# Arrange — even all three measures (+20 → 80) cannot reach the target.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
|
||||
[_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
[scored_option("loft_insulation", gain=5.0, cost=1000.0, overlay=ROOF_OVERLAY)],
|
||||
[scored_option("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=FLOOR_OVERLAY)],
|
||||
]
|
||||
scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0)
|
||||
scorer = StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0)
|
||||
|
||||
# Act — target 90 is out of reach; best effort is the most SAP budget buys.
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -450,7 +369,7 @@ def test_package_falls_back_to_max_gain_when_target_unreachable() -> None:
|
|||
)
|
||||
|
||||
# Assert — max-gain: all three, SAP 80 (below target, best effort).
|
||||
assert _selected_types(package.selected) == {
|
||||
assert selected_types(package.selected) == {
|
||||
"cavity_wall_insulation",
|
||||
"loft_insulation",
|
||||
"suspended_floor_insulation",
|
||||
|
|
@ -463,10 +382,10 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None:
|
|||
# min-cost warm-start picks it alone; but its true gain is only +5, so the
|
||||
# package undershoots and repair must top it up.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
[scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)],
|
||||
]
|
||||
scorer = _StubScorer(base=60.0, wall=5.0, roof=4.0, floor=0.0)
|
||||
scorer = StubScorer(base=60.0, wall=5.0, roof=4.0, floor=0.0)
|
||||
|
||||
# Act — target 69 (gain 9). Warm-start {wall} (signal 10) → true 65 < 69 →
|
||||
# repair adds the roof (+4) → 69.
|
||||
|
|
@ -479,7 +398,7 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None:
|
|||
)
|
||||
|
||||
# Assert
|
||||
assert _selected_types(package.selected) == {
|
||||
assert selected_types(package.selected) == {
|
||||
"cavity_wall_insulation",
|
||||
"loft_insulation",
|
||||
}
|
||||
|
|
@ -488,57 +407,6 @@ def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None:
|
|||
|
||||
# --- Measure Dependency injection (ADR-0016) -------------------------------
|
||||
|
||||
_VENT_OVERLAY = EpcSimulation(
|
||||
ventilation=VentilationOverlay(
|
||||
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _VentStubScorer:
|
||||
"""A stub that adds a fixed gain per wall overlay present and a fixed
|
||||
(negative) `vent` contribution when a ventilation overlay is present —
|
||||
so the Measure Dependency's effect on the truthful package total and the
|
||||
repair decision is exercised without the calculator."""
|
||||
|
||||
def __init__(self, *, base: float, wall: float, roof: float, vent: float) -> None:
|
||||
self._base = base
|
||||
self._wall = wall
|
||||
self._roof = roof
|
||||
self._vent = vent
|
||||
|
||||
def score(
|
||||
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||||
) -> Score:
|
||||
sap = self._base
|
||||
for sim in simulations:
|
||||
if sim.ventilation is not None:
|
||||
sap += self._vent
|
||||
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 _ventilation_dependency(*, cost: float) -> MeasureDependency:
|
||||
"""A forced 'fabric requires ventilation' edge for the tests."""
|
||||
return MeasureDependency(
|
||||
triggers=frozenset(
|
||||
{MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_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_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -> None:
|
||||
# Arrange — cavity is dirt cheap (£100) and its role-1 signal (+6) alone
|
||||
|
|
@ -547,21 +415,12 @@ def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -
|
|||
# package below target. A ventilation-AWARE warm-start prices that −5 into
|
||||
# the candidate and instead takes the wall-free loft path.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=_WALL_OVERLAY)],
|
||||
[_scored_overlay("loft_insulation", gain=8.0, cost=1500.0, overlay=_ROOF_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=WALL_OVERLAY)],
|
||||
[scored_option("loft_insulation", gain=8.0, cost=1500.0, overlay=ROOF_OVERLAY)],
|
||||
]
|
||||
scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0)
|
||||
dependency = MeasureDependency(
|
||||
triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}),
|
||||
required=ScoredOption(
|
||||
option=MeasureOption(
|
||||
measure_type=MeasureType.MECHANICAL_VENTILATION,
|
||||
description="mechanical_ventilation",
|
||||
overlay=_VENT_OVERLAY,
|
||||
cost=Cost(total=300.0, contingency_rate=0.0),
|
||||
),
|
||||
sap_gain=0.0, # placeholder; optimise_package scores the real signal
|
||||
),
|
||||
scorer = StubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0)
|
||||
dependency = ventilation_dependency(
|
||||
cost=300.0, triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION})
|
||||
)
|
||||
|
||||
# Act — target 66 (gain 6 over the 60 baseline).
|
||||
|
|
@ -576,7 +435,7 @@ def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -
|
|||
|
||||
# Assert — the loft path (true 68, £1500), NOT cavity + forced ventilation:
|
||||
# cavity's signal (+6) is cancelled by ventilation (−5) to +1 < target.
|
||||
assert _selected_types(package.selected) == {"loft_insulation"}
|
||||
assert selected_types(package.selected) == {"loft_insulation"}
|
||||
assert abs(package.score.sap_continuous - 68.0) <= 1e-9
|
||||
|
||||
|
||||
|
|
@ -584,9 +443,9 @@ def test_dependency_injected_when_a_trigger_measure_is_selected() -> None:
|
|||
# Arrange — the wall is selected, so its ventilation dependency must be
|
||||
# injected before the re-score; ventilation never competes in the pool.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
]
|
||||
scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0)
|
||||
scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0)
|
||||
|
||||
# Act
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -595,12 +454,12 @@ def test_dependency_injected_when_a_trigger_measure_is_selected() -> None:
|
|||
baseline_epc=build_epc(),
|
||||
budget=None,
|
||||
target_sap=None,
|
||||
dependencies=[_ventilation_dependency(cost=900.0)],
|
||||
dependencies=[ventilation_dependency(cost=900.0)],
|
||||
)
|
||||
|
||||
# Assert — ventilation is in the package and its negative contribution lands
|
||||
# in the truthful total: 40 base + 5 wall − 2 ventilation = 43.
|
||||
assert _selected_types(package.selected) == {
|
||||
assert selected_types(package.selected) == {
|
||||
"cavity_wall_insulation",
|
||||
"mechanical_ventilation",
|
||||
}
|
||||
|
|
@ -611,9 +470,9 @@ def test_dependency_not_injected_without_a_trigger_measure() -> None:
|
|||
# Arrange — only loft is selected; the wall-triggered ventilation dependency
|
||||
# must not fire.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("loft_insulation", gain=4.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
|
||||
[scored_option("loft_insulation", gain=4.0, cost=1000.0, overlay=ROOF_OVERLAY)],
|
||||
]
|
||||
scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0)
|
||||
scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0)
|
||||
|
||||
# Act
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -622,11 +481,11 @@ def test_dependency_not_injected_without_a_trigger_measure() -> None:
|
|||
baseline_epc=build_epc(),
|
||||
budget=None,
|
||||
target_sap=None,
|
||||
dependencies=[_ventilation_dependency(cost=900.0)],
|
||||
dependencies=[ventilation_dependency(cost=900.0)],
|
||||
)
|
||||
|
||||
# Assert — no trigger, no ventilation; 40 base + 4 roof = 44.
|
||||
assert _selected_types(package.selected) == {"loft_insulation"}
|
||||
assert selected_types(package.selected) == {"loft_insulation"}
|
||||
assert abs(package.score.sap_continuous - 44.0) <= 1e-9
|
||||
|
||||
|
||||
|
|
@ -636,9 +495,9 @@ def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None:
|
|||
# wall we can't afford to ventilate is a wall we can't afford, so it is
|
||||
# dropped (the budget is a hard envelope, ventilation is not forced over it).
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
]
|
||||
scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0)
|
||||
scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0)
|
||||
|
||||
# Act — tight budget; ventilation-aware selection prices the £900 in.
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -647,7 +506,7 @@ def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None:
|
|||
baseline_epc=build_epc(),
|
||||
budget=1000.0,
|
||||
target_sap=None,
|
||||
dependencies=[_ventilation_dependency(cost=900.0)],
|
||||
dependencies=[ventilation_dependency(cost=900.0)],
|
||||
)
|
||||
|
||||
# Assert — nothing recommended; the budget is respected and the wall is
|
||||
|
|
@ -660,10 +519,10 @@ def test_injected_ventilation_penalty_drives_extra_repair() -> None:
|
|||
# Repair adds the roof (true +4) to reach 47, paying for the ventilation
|
||||
# penalty out of the budget the dependency's cost has already eaten into.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)],
|
||||
[_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)],
|
||||
[scored_option("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||||
[scored_option("loft_insulation", gain=0.0, cost=1000.0, overlay=ROOF_OVERLAY)],
|
||||
]
|
||||
scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0)
|
||||
scorer = StubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0)
|
||||
|
||||
# Act
|
||||
package: OptimisedPackage = optimise_package(
|
||||
|
|
@ -672,12 +531,12 @@ def test_injected_ventilation_penalty_drives_extra_repair() -> None:
|
|||
baseline_epc=build_epc(),
|
||||
budget=5000.0,
|
||||
target_sap=46.0,
|
||||
dependencies=[_ventilation_dependency(cost=900.0)],
|
||||
dependencies=[ventilation_dependency(cost=900.0)],
|
||||
)
|
||||
|
||||
# Assert — repair pulled the roof in to clear the target net of ventilation:
|
||||
# 40 + 5 wall − 2 vent + 4 roof = 47.
|
||||
assert _selected_types(package.selected) == {
|
||||
assert selected_types(package.selected) == {
|
||||
"cavity_wall_insulation",
|
||||
"loft_insulation",
|
||||
"mechanical_ventilation",
|
||||
|
|
|
|||
|
|
@ -12,129 +12,67 @@ from __future__ import annotations
|
|||
|
||||
from typing import Sequence
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
)
|
||||
from datatypes.epc.domain.epc_property_data import 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 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,
|
||||
)
|
||||
|
||||
_WALL_OVERLAY = EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2)
|
||||
}
|
||||
_AIRTIGHTNESS_TRIGGERS: frozenset[MeasureType] = frozenset(
|
||||
{MeasureType.CAVITY_WALL_INSULATION, MeasureType.DOUBLE_GLAZING}
|
||||
)
|
||||
_ROOF_OVERLAY = EpcSimulation(
|
||||
building_parts={
|
||||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300)
|
||||
}
|
||||
)
|
||||
_HEATING_OVERLAY = EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=201))
|
||||
_GLAZING_OVERLAY = EpcSimulation(glazing=GlazingOverlay(glazing_type=2))
|
||||
_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
|
||||
# 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("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)],
|
||||
[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=10.0, roof=0.0, heating=30.0)
|
||||
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=10000.0,
|
||||
budget=15000.0,
|
||||
target_sap=69.0,
|
||||
)
|
||||
|
||||
# Assert — fabric only: the wall (true 70 ≥ 69); the heat pump is never
|
||||
# Assert — fabric only: the wall (true 72 ≥ 69); the boiler 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
|
||||
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("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)],
|
||||
[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, roof=0.0, heating=20.0)
|
||||
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(
|
||||
|
|
@ -147,7 +85,7 @@ def test_fabric_short_of_target_is_topped_up_with_non_fabric_measures() -> None:
|
|||
|
||||
# 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) == {
|
||||
assert selected_types(package.selected) == {
|
||||
"cavity_wall_insulation",
|
||||
"air_source_heat_pump",
|
||||
}
|
||||
|
|
@ -159,10 +97,10 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None:
|
|||
# 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)],
|
||||
[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, roof=0.0, heating=20.0)
|
||||
scorer = StubScorer(base=60.0, wall=5.0, heating=20.0)
|
||||
|
||||
# Act — target 78 (gain 18).
|
||||
package: OptimisedPackage = optimise_package_fabric_first(
|
||||
|
|
@ -174,15 +112,10 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None:
|
|||
)
|
||||
|
||||
# Assert — wall only; the target is missed rather than the fabric skipped.
|
||||
assert _selected_types(package) == {"cavity_wall_insulation"}
|
||||
assert selected_types(package.selected) == {"cavity_wall_insulation"}
|
||||
assert abs(package.score.sap_continuous - 65.0) <= 1e-9
|
||||
|
||||
|
||||
_VENT_OVERLAY = EpcSimulation(
|
||||
ventilation=VentilationOverlay(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE")
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -200,44 +133,24 @@ class _AirtightnessScorer:
|
|||
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
|
||||
sap += 5.0 if wall else 0.0
|
||||
sap += (4.0 if wall else 0.0) if glazing else 0.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 _airtightness_ventilation_dependency(*, cost: float) -> MeasureDependency:
|
||||
"""A forced 'airtightness requires ventilation' edge: both the wall and
|
||||
the sealed new glazing trigger the same mechanical ventilation."""
|
||||
return MeasureDependency(
|
||||
triggers=frozenset(
|
||||
{
|
||||
MeasureType.CAVITY_WALL_INSULATION,
|
||||
MeasureType.DOUBLE_GLAZING,
|
||||
}
|
||||
),
|
||||
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 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("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_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()
|
||||
|
||||
|
|
@ -249,7 +162,9 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None:
|
|||
baseline_epc=build_epc(),
|
||||
budget=10000.0,
|
||||
target_sap=68.0,
|
||||
dependencies=[_airtightness_ventilation_dependency(cost=300.0)],
|
||||
dependencies=[
|
||||
ventilation_dependency(cost=300.0, triggers=_AIRTIGHTNESS_TRIGGERS)
|
||||
],
|
||||
)
|
||||
|
||||
# Assert — one ventilation, and the truthful total counts its penalty once:
|
||||
|
|
@ -260,7 +175,7 @@ def test_ventilation_dependency_is_injected_once_across_both_phases() -> None:
|
|||
if scored.option.measure_type == MeasureType.MECHANICAL_VENTILATION
|
||||
)
|
||||
assert ventilation_count == 1
|
||||
assert _selected_types(package) == {
|
||||
assert selected_types(package.selected) == {
|
||||
"cavity_wall_insulation",
|
||||
"double_glazing",
|
||||
"mechanical_ventilation",
|
||||
|
|
@ -273,9 +188,9 @@ def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None:
|
|||
# survives generation); fabric first must not veto the run, it just means
|
||||
# phase 1 has nothing to do.
|
||||
groups: list[list[ScoredOption]] = [
|
||||
[_scored("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=_HEATING_OVERLAY)],
|
||||
[scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)],
|
||||
]
|
||||
scorer = _StubScorer(base=60.0, wall=5.0, roof=0.0, heating=20.0)
|
||||
scorer = StubScorer(base=60.0, heating=20.0)
|
||||
|
||||
# Act — target 75.
|
||||
package: OptimisedPackage = optimise_package_fabric_first(
|
||||
|
|
@ -287,7 +202,7 @@ def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None:
|
|||
)
|
||||
|
||||
# Assert — the heat pump package, exactly as a plain run would produce.
|
||||
assert _selected_types(package) == {"air_source_heat_pump"}
|
||||
assert selected_types(package.selected) == {"air_source_heat_pump"}
|
||||
assert abs(package.score.sap_continuous - 80.0) <= 1e-9
|
||||
|
||||
|
||||
|
|
@ -296,10 +211,10 @@ def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None:
|
|||
# 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("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)],
|
||||
[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, roof=0.0, heating=20.0)
|
||||
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(
|
||||
|
|
@ -311,7 +226,7 @@ def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None:
|
|||
)
|
||||
|
||||
# Assert — wall first; the heat pump no longer fits the leftover £7000.
|
||||
assert _selected_types(package) == {"cavity_wall_insulation"}
|
||||
assert selected_types(package.selected) == {"cavity_wall_insulation"}
|
||||
assert abs(package.score.sap_continuous - 65.0) <= 1e-9
|
||||
|
||||
|
||||
|
|
@ -359,9 +274,12 @@ class _GlazingInteractionScorer:
|
|||
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
|
||||
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
|
||||
)
|
||||
|
|
@ -372,9 +290,9 @@ def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None:
|
|||
# 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)],
|
||||
[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()
|
||||
|
||||
|
|
@ -390,7 +308,7 @@ def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None:
|
|||
|
||||
# Assert — the skipped glazing re-enters on its post-fabric worth: 60 + 5
|
||||
# wall + 4 glazing = 69, target met.
|
||||
assert _selected_types(package) == {
|
||||
assert selected_types(package.selected) == {
|
||||
"cavity_wall_insulation",
|
||||
"double_glazing",
|
||||
}
|
||||
|
|
@ -403,10 +321,10 @@ def test_phase_two_values_candidates_against_the_post_fabric_dwelling() -> None:
|
|||
# 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_option("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),
|
||||
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()
|
||||
|
|
@ -424,7 +342,7 @@ def test_phase_two_values_candidates_against_the_post_fabric_dwelling() -> None:
|
|||
)
|
||||
|
||||
# Assert
|
||||
assert _selected_types(package) == {
|
||||
assert selected_types(package.selected) == {
|
||||
"cavity_wall_insulation",
|
||||
"air_source_heat_pump",
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue