Forced ventilation is injected once across both fabric-first phases 🟥

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-09 11:41:27 +00:00
parent adf60b82b1
commit 0220bee87c

View file

@ -18,6 +18,7 @@ from datatypes.epc.domain.epc_property_data import (
)
from domain.modelling.measure_type import MeasureType
from domain.modelling.optimisation.optimiser import (
MeasureDependency,
OptimisedPackage,
ScoredOption,
optimise_package_fabric_first,
@ -29,6 +30,7 @@ from domain.modelling.simulation import (
EpcSimulation,
GlazingOverlay,
HeatingOverlay,
VentilationOverlay,
)
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
@ -175,6 +177,104 @@ def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None:
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