mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Fabric-first scenario stops at fabric when the target is met 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1287cb1cf1
commit
fcf46263bc
3 changed files with 158 additions and 0 deletions
|
|
@ -13,6 +13,7 @@ change. It is also the vocabulary the ``considered_measures`` allowlist speaks
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Final
|
||||
|
||||
|
||||
class MeasureType(StrEnum):
|
||||
|
|
@ -37,3 +38,24 @@ class MeasureType(StrEnum):
|
|||
SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned"
|
||||
SOLAR_PV = "solar_pv"
|
||||
SECONDARY_HEATING_REMOVAL = "secondary_heating_removal"
|
||||
|
||||
|
||||
# The measure types a Fabric First Scenario treats in phase 1 — the building
|
||||
# envelope: wall / roof / floor insulation and glazing. Everything else
|
||||
# (heating, solar, lighting, tune-ups, secondary-heating removal) waits for
|
||||
# phase 2. Mechanical ventilation is deliberately absent: it is never selected,
|
||||
# only injected as a forced Measure Dependency of the fabric that triggers it.
|
||||
FABRIC_MEASURE_TYPES: Final[frozenset[MeasureType]] = frozenset(
|
||||
{
|
||||
MeasureType.CAVITY_WALL_INSULATION,
|
||||
MeasureType.EXTERNAL_WALL_INSULATION,
|
||||
MeasureType.INTERNAL_WALL_INSULATION,
|
||||
MeasureType.LOFT_INSULATION,
|
||||
MeasureType.SLOPING_CEILING_INSULATION,
|
||||
MeasureType.FLAT_ROOF_INSULATION,
|
||||
MeasureType.SUSPENDED_FLOOR_INSULATION,
|
||||
MeasureType.SOLID_FLOOR_INSULATION,
|
||||
MeasureType.DOUBLE_GLAZING,
|
||||
MeasureType.SECONDARY_GLAZING,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -223,6 +223,23 @@ def optimise_package(
|
|||
return _max_gain_package(groups, scorer, baseline_epc, budget, deps)
|
||||
|
||||
|
||||
def optimise_package_fabric_first(
|
||||
*,
|
||||
groups: list[list[ScoredOption]],
|
||||
scorer: Scorer,
|
||||
baseline_epc: EpcPropertyData,
|
||||
budget: Optional[float],
|
||||
target_sap: Optional[float],
|
||||
dependencies: Sequence[MeasureDependency] = (),
|
||||
) -> OptimisedPackage:
|
||||
"""Select the Optimised Package under the Fabric First constraint: optimise
|
||||
the fabric measures (``FABRIC_MEASURE_TYPES``) first with the full budget;
|
||||
if the truthful post-fabric score meets ``target_sap``, stop there. Otherwise
|
||||
optimise the remaining groups on top — the starting point for phase 2 is the
|
||||
dwelling with the phase-1 fabric applied — within the leftover budget."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _with_role1_signals(
|
||||
dependencies: Sequence[MeasureDependency],
|
||||
scorer: Scorer,
|
||||
|
|
|
|||
119
tests/domain/modelling/test_optimiser_fabric_first.py
Normal file
119
tests/domain/modelling/test_optimiser_fabric_first.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""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
|
||||
|
||||
|
||||
Loading…
Add table
Reference in a new issue