mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Review findings on PR #1527: - The overlay constants, ScoredOption builder, ventilation dependency and selected_types helper come from the shared _optimiser_fixtures module (landed on the fabric-first base) instead of local copies; the boiler overlay is the shared BOILER_OVERLAY (SAP Table 4a code 104, a mains-gas combi) rather than code 201, which is neither a boiler nor a heat pump. _IWI_OVERLAY (solid-wall internal, type 3) stays local — no shared equivalent — and the carbon stubs stay bespoke (the shared StubScorer has no CO2 knob). - The optimise_package_fabric_first import is lifted to module scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
6 KiB
Python
162 lines
6 KiB
Python
"""Behaviour of the Optimiser under a goal-aligned objective (ADR-0062): a
|
||
Scenario whose goal is Reducing CO2 emissions / Energy Savings optimises its
|
||
own metric, not SAP. The caller supplies group signals already measured in the
|
||
objective's currency; the optimiser must price everything *it* computes — the
|
||
forced Measure Dependency signals — in the same currency, so a ventilation
|
||
that costs SAP but is carbon-neutral cannot sink a carbon-improving wall.
|
||
"""
|
||
|
||
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,
|
||
optimise_package_fabric_first,
|
||
)
|
||
from domain.modelling.scoring.package_scorer import Score
|
||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||
from tests.domain.modelling._optimiser_fixtures import (
|
||
ASHP_OVERLAY,
|
||
BOILER_OVERLAY,
|
||
WALL_OVERLAY,
|
||
scored_option,
|
||
selected_types,
|
||
ventilation_dependency,
|
||
)
|
||
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
||
build_epc,
|
||
)
|
||
|
||
|
||
class _CarbonScorer:
|
||
"""A stub where the wall is a small carbon win (−20 kg/yr) and a large SAP
|
||
win (+6), while its forced ventilation is carbon-neutral but SAP-ruinous
|
||
(−30): SAP-priced dependency signals sink the wall; carbon-priced ones
|
||
keep it."""
|
||
|
||
def score(
|
||
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||
) -> Score:
|
||
sap, co2 = 60.0, 500.0
|
||
for sim in simulations:
|
||
if sim.ventilation is not None:
|
||
sap -= 30.0
|
||
for part in sim.building_parts.values():
|
||
if part.wall_insulation_type is not None:
|
||
sap += 6.0
|
||
co2 -= 20.0
|
||
return Score(
|
||
sap_continuous=sap, co2_kg_per_yr=co2, primary_energy_kwh_per_yr=0.0
|
||
)
|
||
|
||
|
||
def _carbon_reduction(score: Score) -> float:
|
||
return -score.co2_kg_per_yr
|
||
|
||
|
||
def test_dependency_signals_are_priced_in_the_objective_currency() -> None:
|
||
# Arrange — the wall's signal (supplied by the caller, +20 kg CO2 saved)
|
||
# and the ventilation it forces in (carbon-neutral). Under legacy SAP
|
||
# pricing the ventilation's −30 SAP would outweigh the wall's +20 signal
|
||
# and the package would collapse to nothing.
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("cavity_wall_insulation", gain=20.0, cost=1000.0, overlay=WALL_OVERLAY)],
|
||
]
|
||
dependency = ventilation_dependency(
|
||
cost=300.0, triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION})
|
||
)
|
||
|
||
# Act — a Reducing-CO2 brief: maximise carbon reduction within budget.
|
||
package: OptimisedPackage = optimise_package(
|
||
groups=groups,
|
||
scorer=_CarbonScorer(),
|
||
baseline_epc=build_epc(),
|
||
budget=5000.0,
|
||
target_sap=None,
|
||
dependencies=[dependency],
|
||
objective=_carbon_reduction,
|
||
)
|
||
|
||
# Assert — the wall survives with its ventilation: the dependency is worth
|
||
# 0 kg CO2, not −30 SAP, so the package is a net +20 kg saving.
|
||
assert selected_types(package.selected) == {
|
||
"cavity_wall_insulation",
|
||
"mechanical_ventilation",
|
||
}
|
||
assert abs(package.score.co2_kg_per_yr - 480.0) <= 1e-9
|
||
|
||
|
||
# Internal wall insulation — a distinct fabric overlay so the fabric-first
|
||
# phase-1 pick is unambiguous. No shared fixture (the shared WALL_OVERLAY is a
|
||
# cavity fill, type 2); this is a solid-wall internal treatment, type 3.
|
||
_IWI_OVERLAY = EpcSimulation(
|
||
building_parts={
|
||
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3)
|
||
}
|
||
)
|
||
|
||
|
||
class _CarbonHeatingScorer:
|
||
"""A stub where the boiler wins on SAP (+10 vs +2) but the heat pump wins
|
||
on carbon (−50 vs −5 kg/yr): a fabric-first phase 2 that re-scores its
|
||
candidates in SAP picks the wrong heating for a Reducing-CO2 brief."""
|
||
|
||
def score(
|
||
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||
) -> Score:
|
||
sap, co2 = 60.0, 500.0
|
||
for sim in simulations:
|
||
for part in sim.building_parts.values():
|
||
if part.wall_insulation_type is not None:
|
||
sap += 5.0
|
||
co2 -= 10.0
|
||
if sim.heating is None:
|
||
continue
|
||
if sim.heating.sap_main_heating_code is not None:
|
||
sap += 10.0
|
||
co2 -= 5.0
|
||
if sim.heating.main_heating_index_number is not None:
|
||
sap += 2.0
|
||
co2 -= 50.0
|
||
return Score(
|
||
sap_continuous=sap, co2_kg_per_yr=co2, primary_energy_kwh_per_yr=0.0
|
||
)
|
||
|
||
|
||
def test_fabric_first_phase_two_rescores_in_the_objective_currency() -> None:
|
||
# Arrange — a fabric-first Reducing-CO2 brief. Phase 1 commits the wall;
|
||
# phase 2 must choose the heating on its post-fabric *carbon* worth, not
|
||
# its SAP worth. Signals are supplied in kg CO2 saved (the caller's job).
|
||
groups: list[list[ScoredOption]] = [
|
||
[scored_option("internal_wall_insulation", gain=10.0, cost=1000.0, overlay=_IWI_OVERLAY)],
|
||
[
|
||
scored_option("gas_boiler_upgrade", gain=5.0, cost=2000.0, overlay=BOILER_OVERLAY),
|
||
scored_option("air_source_heat_pump", gain=50.0, cost=6000.0, overlay=ASHP_OVERLAY),
|
||
],
|
||
]
|
||
|
||
# Act — no target (goal-aligned briefs have none), generous budget.
|
||
package: OptimisedPackage = optimise_package_fabric_first(
|
||
groups=groups,
|
||
scorer=_CarbonHeatingScorer(),
|
||
baseline_epc=build_epc(),
|
||
budget=10000.0,
|
||
target_sap=None,
|
||
objective=_carbon_reduction,
|
||
)
|
||
|
||
# Assert — the wall plus the heat pump (−50 kg), not the SAP-favoured
|
||
# boiler; the truthful package carbon is 500 − 10 − 50 = 440.
|
||
assert selected_types(package.selected) == {
|
||
"internal_wall_insulation",
|
||
"air_source_heat_pump",
|
||
}
|
||
assert abs(package.score.co2_kg_per_yr - 440.0) <= 1e-9
|