diff --git a/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md b/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md new file mode 100644 index 000000000..31f1794c4 --- /dev/null +++ b/docs/adr/0061-fabric-first-is-a-two-phase-optimisation.md @@ -0,0 +1,73 @@ +--- +status: accepted (extends ADR-0016) +--- + +# Fabric First is a two-phase optimisation with strict envelope priority + +Landlords with a fabric-first retrofit policy require the building envelope — +insulation and windows — to be treated before heating systems and renewables +are considered. The legacy engine carried this as `enforce_fabric_first` on +the plan-API request body (`funding_optimiser.optimise_with_scenarios`): an +optimiser pass over fabric-only measures with the full budget, then a second +pass over the remainder with the leftover budget and the residual target, +summing approximate per-measure SAP points. The new engine needed the same +capability on the truthful-re-score Optimiser core (ADR-0016). + +Decided in a grilling session with Khalim, 2026-07-09. + +## Decision + +**`fabric_first` is a Scenario attribute, and the Optimiser honours it as two +sequential `optimise_package` phases in which the envelope has strict first +claim on the budget** (`optimise_package_fabric_first`, ADR-0016 core reused +per phase). + +- **The flag lives on the `scenario` table** (FE-owned Drizzle schema: + `fabric_first boolean NOT NULL DEFAULT false`), mirrored in `ScenarioModel` + and the domain `Scenario`. A Plan's provenance stays derivable from its + Scenario row alone — unlike the legacy request-body flag, the same + scenario_id cannot produce differently-constrained plans. **Deploy order**: + the Drizzle migration must land before this mirror, or every scenario read + crashes on the missing column. +- **Fabric = the building envelope** (`FABRIC_MEASURE_TYPES`): wall (CWI / + EWI / IWI), roof (loft / sloping-ceiling / flat-roof), floor (suspended / + solid) insulation, plus double / secondary glazing — the legacy list + exactly. Lighting, tune-ups, secondary-heating removal, heating and solar + all wait for phase 2. Mechanical ventilation is unclassified: it is never + selected, only injected as a forced Measure Dependency (ADR-0016) of the + fabric that triggers it, in whichever phase that happens — and only once. +- **Phase 1** runs `optimise_package` over the fabric groups with the full + budget: least-cost-to-target, repair, max-gain fallback. If the truthful + post-fabric score meets the Scenario target, the package is fabric-only — + surplus budget is left unspent, per the ADR-0016 no-overshoot doctrine. +- **Phase 2** (target unmet, or no target) optimises every group phase 1 did + not consume — non-fabric, plus fabric groups it left unpicked, which may + re-enter on their post-fabric worth — under the leftover budget. Candidates + are valued **against the fabric-applied dwelling**: warm-start signals are + re-scored and every package re-score is prefixed with the phase-1 overlays + (`_PrefixedScorer`), so a heating system whose worth changes once the + envelope is treated is re-ranked truthfully, and the returned Score remains + the whole-package figure against the true baseline (bills and the role-3 + cascade stay honest). +- **Strict priority, not target-aware compromise**: phase 1 commits the + max-gain fabric package even when a cheaper fabric/heating split would have + reached the target — a £4,000 budget buys floor insulation and leaves the + £3,200 boiler unaffordable, and the target is missed rather than the fabric + skipped. This is the landlord's explicit trade. +- **Every goal honours the flag**, not just Increasing EPC: with no SAP + target the two phases are both max-gain, so fabric still gets first claim + on the budget. +- **No Plan-contract change**: the Scenario row is the provenance; the + best-practice cascade already orders fabric before heating in the persisted + measures. + +## Consequences + +- The Modelling orchestrator branches once, on `scenario.fabric_first`, + between `optimise_package` and `optimise_package_fabric_first`; everything + downstream (attribution, bills, persistence) is unchanged. +- Phase 2 re-scores each remaining Option once against the post-fabric + dwelling — a handful of extra calculator calls per fabric-first Plan. +- A fabric-first Plan can undershoot a target a plain Plan would have reached + within the same budget. This is by design and should be communicated when + scenario results are compared. diff --git a/docs/adr/0062-goal-aligned-optimiser-objectives.md b/docs/adr/0062-goal-aligned-optimiser-objectives.md new file mode 100644 index 000000000..afc87518b --- /dev/null +++ b/docs/adr/0062-goal-aligned-optimiser-objectives.md @@ -0,0 +1,62 @@ +--- +status: accepted (extends ADR-0016; composes with ADR-0061) +--- + +# Goal-aligned Optimiser objectives: each goal maximises its own metric + +Every Scenario goal used to optimise SAP. The legacy engine returned no +target for Energy Savings / Reducing CO2 (`optimiser_functions.calculate_gain` +→ `None`) and maximised SAP gain within budget regardless of the goal, and the +new engine inherited that: the goal label changed nothing but the words on the +brief. The scorer already computes each package's carbon and (via SapResult → +EnergyBreakdown → BillDerivation) its annual bill, so aligning the objective +is a selection change, not a calculator change. + +Decided in a grilling session with Khalim, 2026-07-09. + +## Decision + +**The Optimiser maximises the Scenario goal's own metric, as a pluggable +`objective: Callable[[Score], float]` (higher is better), with no target: +goal-aligned briefs are "reduce as much as possible within this budget".** + +- **Reducing CO2 emissions** maximises annual kg CO2 saved + (`-score.co2_kg_per_yr`). +- **Energy Savings** maximises the annual bill £ saved, priced at the **live + Fuel Rates snapshot** (ADR-0014), not SAP's internal tariff book — that + difference is the point of the goal. SAP is itself a cost-shaped rating, so + the two frequently agree; they diverge exactly when current tariffs disagree + with SAP's assumptions (e.g. the gas/electricity price ratio). +- **Increasing EPC** keeps its SAP objective and band-target semantics + (least-cost-to-target, repair, max-gain fallback) unchanged. +- **Valuation Improvement / None** stay max-SAP-within-budget — SAP is a + defensible valuation proxy and `None` has no semantics to encode. +- **`goal_value` is ignored for the goal-aligned goals** — no percentage or + absolute target exists yet. If targets arrive later they slot into the + existing target machinery on the objective's scale. +- **A budget is mandatory** for the goal-aligned goals: unconstrained + "as much as possible" would recommend every beneficial measure. A + budget-less Energy/CO2 Scenario raises a `ValueError` naming the scenario + and goal — a loud misconfiguration, not a maximal plan. +- **One currency everywhere**: the role-1 group signals + (`independent_option_signals`), the forced Measure Dependency pricing, the + greedy-repair marginals, and Fabric First's phase-2 re-scoring + (ADR-0061) are all measured by the same objective, so a ventilation that + costs SAP but is carbon-neutral cannot sink a carbon-improving wall, and a + fabric-first phase 2 picks its heating on post-fabric carbon, not + post-fabric SAP. + +## Consequences + +- Selection changes; truth-telling does not. The Plan's persisted Scores, + Bills, and role-3 SAP attribution are computed exactly as before — only + *which* package is chosen responds to the goal. +- At a £16,000 budget on the uninsulated solid-brick corpus dwelling + (001431), the SAP objective buys wall + floor + gas boiler (SAP 72.9, + 2,069 kg CO2/yr, £2,088/yr) while the carbon objective buys wall + floor + + storage heaters (SAP 69.2, 1,098 kg CO2/yr, £2,635/yr) — goals now trade + SAP, carbon and bills against each other visibly. +- The Energy Savings objective inherits the Fuel Rates snapshot's staleness + characteristics (quarterly Ofgem-cap cadence, ADR-0014). +- `independent_option_impacts` (role-1 SAP/CO2/kWh triple) is removed — + superseded by `independent_option_signals` in the objective's currency. diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py index a1882853e..9b4fd4818 100644 --- a/domain/modelling/measure_type.py +++ b/domain/modelling/measure_type.py @@ -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, + } +) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 0e5776577..afb3fc4e8 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -21,10 +21,10 @@ from __future__ import annotations import itertools from dataclasses import dataclass -from typing import Optional, Protocol, Sequence +from typing import Callable, Optional, Protocol, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.measure_type import MeasureType +from domain.modelling.measure_type import FABRIC_MEASURE_TYPES, MeasureType from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import MeasureOption from domain.modelling.simulation import EpcSimulation @@ -33,8 +33,9 @@ from domain.modelling.simulation import EpcSimulation @dataclass(frozen=True) class ScoredOption: """A candidate Measure Option paired with its role-1 (independent-vs- - baseline) SAP gain — the optimiser's input signal. Cost is read from the - Option; the gain is supplied by scoring.""" + baseline) gain in the goal objective's currency — SAP points by default, + kg CO2 / £ saved for the goal-aligned Scenarios (ADR-0062). Cost is read + from the Option; the gain is supplied by scoring.""" option: MeasureOption sap_gain: float @@ -171,6 +172,12 @@ class OptimisedPackage: score: Score +def sap_rating(score: Score) -> float: + """The default Optimiser objective: the un-rounded SAP rating (higher is + better) — what every goal optimised before goal-aligned objectives.""" + return score.sap_continuous + + def optimise_package( *, groups: list[list[ScoredOption]], @@ -179,6 +186,7 @@ def optimise_package( budget: Optional[float], target_sap: Optional[float], dependencies: Sequence[MeasureDependency] = (), + objective: Callable[[Score], float] = sap_rating, ) -> OptimisedPackage: """Select the Optimised Package for one Property + Scenario (ADR-0016 + its amendment). @@ -197,48 +205,218 @@ def optimise_package( Without a ``target_sap`` (other goals) it is max-gain-within-budget. Either way forced dependencies are injected on every path and their cost counts toward the spend; the returned `selected` includes them. ``budget`` of None - means unconstrained.""" - baseline_sap: float = _score(scorer, baseline_epc, []).sap_continuous + means unconstrained. + + ``objective`` is the currency every internally-computed figure is measured + in (ADR-0062): the goal's metric, higher is better — SAP by default, CO2 + reduction / bill saving for the goal-aligned Scenarios. The caller must + supply the group signals in the same currency; ``target_sap`` (when given) + is a value on the same scale.""" + baseline_value: float = objective(_score(scorer, baseline_epc, [])) # Score each forced dependency's independent (role-1) impact so the selection # can price the ventilation a wall drags in — negative for ventilation. deps: list[MeasureDependency] = _with_role1_signals( - dependencies, scorer, baseline_epc, baseline_sap + dependencies, scorer, baseline_epc, baseline_value, objective ) if target_sap is None: return _max_gain_package(groups, scorer, baseline_epc, budget, deps) - target_gain: float = target_sap - baseline_sap + target_gain: float = target_sap - baseline_value chosen: Optional[list[ScoredOption]] = optimise_min_cost( groups, budget, target_gain, deps ) if chosen is not None: package: OptimisedPackage = _repair_to_target( - chosen, groups, deps, scorer, baseline_epc, budget, target_sap + chosen, groups, deps, scorer, baseline_epc, budget, target_sap, objective ) - if package.score.sap_continuous >= target_sap: + if objective(package.score) >= target_sap: return package # Target unreachable within budget (warm-start infeasible, or the repaired # package still falls short) → best effort: the most improvement budget buys. 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] = (), + objective: Callable[[Score], float] = sap_rating, +) -> 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.""" + fabric_groups: list[list[ScoredOption]] = [ + group for group in groups if _is_fabric_group(group) + ] + if not fabric_groups: + # Nothing for phase 1 to claim (the envelope is already treated): + # a plain run is identical and skips the phase-2 re-scoring. + return optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=baseline_epc, + budget=budget, + target_sap=target_sap, + dependencies=dependencies, + ) + fabric_package: OptimisedPackage = optimise_package( + groups=fabric_groups, + scorer=scorer, + baseline_epc=baseline_epc, + budget=budget, + target_sap=target_sap, + dependencies=dependencies, + objective=objective, + ) + if ( + target_sap is not None + and objective(fabric_package.score) >= target_sap + ): + return fabric_package + if not fabric_package.selected: + # Phase 1 committed nothing (no fabric affordable or worth having), so + # the phase-2 prefix would be empty and its re-scoring would reproduce + # the signals the groups already carry: a plain run is identical. + return optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=baseline_epc, + budget=budget, + target_sap=target_sap, + dependencies=dependencies, + ) + + # Phase 2 — the upgrade requirement is not met by fabric alone: optimise + # the remaining groups (non-fabric, plus any fabric group phase 1 left + # unpicked) on top of the committed fabric. Every score call is prefixed + # with the phase-1 overlays, so candidates are valued against the + # fabric-applied dwelling and the resulting package score stays the + # truthful whole-package figure against the original baseline. + consumed: set[int] = _used_group_indices(groups, fabric_package.selected) + remaining_groups: list[list[ScoredOption]] = [ + group for index, group in enumerate(groups) if index not in consumed + ] + if not remaining_groups: + return fabric_package + post_fabric_scorer = _PrefixedScorer( + scorer, [scored.option.overlay for scored in fabric_package.selected] + ) + leftover_budget: Optional[float] = ( + None if budget is None else budget - _package_cost(fabric_package.selected) + ) + # Everything phase 1 committed — its picks plus the dependencies it + # injected. A dependency already injected (e.g. the wall's ventilation) is + # satisfied for the whole package: phase 2 must not force it in again. + phase_one_types: set[MeasureType] = { + scored.option.measure_type for scored in fabric_package.selected + } + outstanding_dependencies: list[MeasureDependency] = [ + dependency + for dependency in dependencies + if dependency.required.option.measure_type not in phase_one_types + ] + top_up: OptimisedPackage = optimise_package( + groups=_rescored_groups( + remaining_groups, + post_fabric_scorer, + baseline_epc, + objective=objective, + start_value=objective(fabric_package.score), + ), + scorer=post_fabric_scorer, + baseline_epc=baseline_epc, + budget=leftover_budget, + target_sap=target_sap, + dependencies=outstanding_dependencies, + objective=objective, + ) + return OptimisedPackage( + selected=[*fabric_package.selected, *top_up.selected], + score=top_up.score, + ) + + +def _is_fabric_group(group: list[ScoredOption]) -> bool: + """A group belongs to phase 1 when every Option in it is a fabric measure + (groups are one Recommendation each, so they are homogeneous in kind).""" + return all( + scored.option.measure_type in FABRIC_MEASURE_TYPES for scored in group + ) + + +def _rescored_groups( + groups: list[list[ScoredOption]], + scorer: Scorer, + baseline_epc: EpcPropertyData, + *, + objective: Callable[[Score], float], + start_value: float, +) -> list[list[ScoredOption]]: + """The groups with every Option's role-1 warm-start signal re-scored + through ``scorer`` in the ``objective``'s currency — for phase 2, its + independent gain on the post-fabric dwelling rather than the raw baseline, + so options whose worth changes once the envelope is treated (a boiler on + an insulated home) are re-ranked. ``start_value`` is the objective value of + ``baseline_epc`` through ``scorer`` with no candidate applied — the caller + already has it (the phase-1 package score in the objective's currency), so + it is threaded in rather than re-computed.""" + return [ + [ + ScoredOption( + option=scored.option, + sap_gain=objective( + scorer.score(baseline_epc, [scored.option.overlay]) + ) + - start_value, + ) + for scored in group + ] + for group in groups + ] + + +class _PrefixedScorer: + """A Scorer view of the dwelling with a committed package already applied: + every score call sees the ``prefix`` overlays before the candidate's own. + Phase 2 of Fabric First scores through this, so its candidates are valued + against the post-fabric dwelling while the returned Score remains the + truthful whole-package figure against the true baseline.""" + + def __init__(self, inner: Scorer, prefix: Sequence[EpcSimulation]) -> None: + self._inner = inner + self._prefix = list(prefix) + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + return self._inner.score(baseline, [*self._prefix, *simulations]) + + def _with_role1_signals( dependencies: Sequence[MeasureDependency], scorer: Scorer, baseline_epc: EpcPropertyData, - baseline_sap: float, + baseline_value: float, + objective: Callable[[Score], float], ) -> list[MeasureDependency]: """Replace each dependency's placeholder role-1 signal with its true - independent-vs-baseline SAP impact, so the selectors price what the - dependency really does to the package (ADR-0016 amendment).""" + independent-vs-baseline impact **in the objective's currency**, so the + selectors price what the dependency really does to the package (ADR-0016 + amendment; ADR-0062 for the currency).""" scored: list[MeasureDependency] = [] for dependency in dependencies: signal: float = ( - scorer.score( - baseline_epc, [dependency.required.option.overlay] - ).sap_continuous - - baseline_sap + objective( + scorer.score(baseline_epc, [dependency.required.option.overlay]) + ) + - baseline_value ) scored.append( MeasureDependency( @@ -276,16 +454,17 @@ def _repair_to_target( baseline_epc: EpcPropertyData, budget: Optional[float], target_sap: float, + objective: Callable[[Score], float], ) -> OptimisedPackage: """Inject dependencies onto the warm-start, re-score for the truth, then - greedy-add the untreated-group Option with the best marginal SAP-per-£ (its - own dependency folded in) until the true SAP clears ``target_sap`` or no - affordable improving Option remains.""" + greedy-add the untreated-group Option with the best marginal objective-per-£ + (its own dependency folded in) until the true objective value clears + ``target_sap`` or no affordable improving Option remains.""" selected: list[ScoredOption] = _inject(chosen, dependencies) score: Score = _score(scorer, baseline_epc, selected) - while score.sap_continuous < target_sap: + while objective(score) < target_sap: candidate = _best_repair_candidate( - groups, chosen, dependencies, scorer, baseline_epc, score, budget + groups, chosen, dependencies, scorer, baseline_epc, score, budget, objective ) if candidate is None: break @@ -343,14 +522,16 @@ def _best_repair_candidate( baseline_epc: EpcPropertyData, current: Score, budget: Optional[float], + objective: Callable[[Score], float], ) -> Optional[ScoredOption]: - """The untreated-group Option giving the best **marginal** SAP-per-£ when - added to the current package — re-scored (not the role-1 signal) with any - ventilation dependency it newly triggers folded in, so both its SAP and its - incremental cost are truthful. Affordable when the resulting whole-package + """The untreated-group Option giving the best **marginal** objective-per-£ + when added to the current package — re-scored (not the role-1 signal) with + any ventilation dependency it newly triggers folded in, so both its gain and + its incremental cost are truthful. Affordable when the resulting whole-package cost is within ``budget`` and strictly improving. None if there is none.""" used: set[int] = _used_group_indices(groups, chosen) base_cost: float = _package_cost(_inject(chosen, dependencies)) + current_value: float = objective(current) best: Optional[ScoredOption] = None best_ratio: float = 0.0 for index, group in enumerate(groups): @@ -364,7 +545,7 @@ def _best_repair_candidate( if budget is not None and package_cost > budget: continue trial: Score = _score(scorer, baseline_epc, trial_selected) - marginal: float = trial.sap_continuous - current.sap_continuous + marginal: float = objective(trial) - current_value if marginal <= 0.0: continue incremental: float = package_cost - base_cost diff --git a/domain/modelling/scenario.py b/domain/modelling/scenario.py index 6792e268d..85162d6d0 100644 --- a/domain/modelling/scenario.py +++ b/domain/modelling/scenario.py @@ -26,7 +26,12 @@ class Scenario: `exclusions` are the measure types the brief bars from the run (the only measure-scoping the live ``scenario`` table persists — there is no - inclusions column). Empty means nothing is barred.""" + inclusions column). Empty means nothing is barred. + + `fabric_first` constrains the Optimiser to treat the building envelope + first: fabric measures are optimised with the full budget, and heating / + renewables are only considered on top of the committed fabric when the + fabric alone does not meet the brief's target.""" id: int goal: str @@ -34,6 +39,7 @@ class Scenario: budget: Optional[float] is_default: bool exclusions: frozenset[MeasureType] = _NO_EXCLUSIONS + fabric_first: bool = False def considered_measures(self) -> Optional[frozenset[MeasureType]]: """The measure-type allowlist the Scenario's exclusions imply: every diff --git a/domain/modelling/scoring/scoring.py b/domain/modelling/scoring/scoring.py index ea995380a..9c0766995 100644 --- a/domain/modelling/scoring/scoring.py +++ b/domain/modelling/scoring/scoring.py @@ -15,7 +15,7 @@ truthful. The whole-package re-score (role 2) is `PackageScorer.score` directly. """ from dataclasses import dataclass -from typing import Sequence +from typing import Callable, Optional, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.scoring.package_scorer import PackageScorer, Score @@ -83,33 +83,28 @@ def marginal_impacts( return marginals_from_scores(cascade_scores(scorer, baseline, overlays)) -def independent_option_impacts( +def independent_option_signals( scorer: PackageScorer, baseline: EpcPropertyData, options: Sequence[MeasureOption], -) -> list[MeasureImpact]: - """Score each Option's overlay independently against the baseline (role 1 — - the optimiser's approximate input signal). Each *distinct* Simulation Overlay - is scored once (Options sharing an overlay reuse the result), so the baseline - is scored once plus one score per distinct overlay. Results follow the input - order. These figures are an approximate signal — never surface them as a - measure's true impact.""" - base: Score = scorer.score(baseline, []) - scored: list[tuple[EpcSimulation, MeasureImpact]] = [] - impacts: list[MeasureImpact] = [] + objective: Callable[[Score], float], +) -> list[float]: + """Each Option's independent-vs-baseline gain **in the objective's + currency** (role 1 — the optimiser's approximate input signal, ADR-0062): + SAP points for an Increasing-EPC goal, kg CO2 saved for Reducing CO2, £ + saved for Energy Savings. Each distinct Simulation Overlay is scored once + (Options sharing an overlay reuse the result); results follow the input + order.""" + base_value: float = objective(scorer.score(baseline, [])) + scored: list[tuple[EpcSimulation, float]] = [] + signals: list[float] = [] for option in options: - cached = next( - (impact for overlay, impact in scored if overlay == option.overlay), None + cached: Optional[float] = next( + (signal for overlay, signal in scored if overlay == option.overlay), + None, ) if cached is None: - current: Score = scorer.score(baseline, [option.overlay]) - cached = MeasureImpact( - sap_points=current.sap_continuous - base.sap_continuous, - co2_savings_kg_per_yr=base.co2_kg_per_yr - current.co2_kg_per_yr, - energy_savings_kwh_per_yr=( - base.primary_energy_kwh_per_yr - current.primary_energy_kwh_per_yr - ), - ) + cached = objective(scorer.score(baseline, [option.overlay])) - base_value scored.append((option.overlay, cached)) - impacts.append(cached) - return impacts + signals.append(cached) + return signals diff --git a/harness/console.py b/harness/console.py index a9aa222b5..06c16d81e 100644 --- a/harness/console.py +++ b/harness/console.py @@ -39,6 +39,7 @@ from orchestration.modelling_orchestrator import ( _candidate_recommendations, # pyright: ignore[reportPrivateUsage] ) from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator +from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository from repositories.fuel_rates.fuel_rates_static_file_repository import ( FuelRatesStaticFileRepository, ) @@ -182,6 +183,7 @@ def run_modelling( considered_measures: Optional[frozenset[MeasureType]] = None, products: Optional[ProductRepository] = None, scenario: Optional[Scenario] = None, + fuel_rates: Optional[FuelRatesRepository] = None, print_table: bool = True, ) -> Plan: """Run ONLY the Modelling stage over ``epc`` with no database — skipping @@ -240,7 +242,7 @@ def run_modelling( ModellingOrchestrator( unit_of_work=lambda: unit, calculator=Sap10Calculator(), - fuel_rates=FuelRatesStaticFileRepository(), + fuel_rates=fuel_rates or FuelRatesStaticFileRepository(), ).run( property_ids=[_PROPERTY_ID], scenario_ids=[scenario_id], diff --git a/infrastructure/postgres/modelling/scenario_table.py b/infrastructure/postgres/modelling/scenario_table.py index 5f7197bae..9dbe4feb4 100644 --- a/infrastructure/postgres/modelling/scenario_table.py +++ b/infrastructure/postgres/modelling/scenario_table.py @@ -78,6 +78,9 @@ class ScenarioModel(SQLModel, table=True): exclusions: Optional[str] = Field(default=None) multi_plan: bool = False is_default: bool = False + # Fabric First constraint (owned by the FE Drizzle schema: boolean NOT + # NULL DEFAULT false — do not deploy this mirror before that migration). + fabric_first: bool = False # Portfolio-level aggregates stored against the Scenario. cost: Optional[float] = Field(default=None) @@ -115,4 +118,5 @@ class ScenarioModel(SQLModel, table=True): budget=self.budget, is_default=self.is_default, exclusions=_parse_exclusions(self.exclusions), + fabric_first=self.fabric_first, ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 7a30d7e82..69e88feac 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -19,16 +19,19 @@ from domain.modelling.optimisation.optimiser import ( OptimisedPackage, ScoredOption, optimise_package, + optimise_package_fabric_first, + sap_rating, ) from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.recommendation import MeasureOption, Recommendation from domain.modelling.generators.roof_recommendation import recommend_roof_insulation +from domain.modelling.portfolio_goal import PortfolioGoal from domain.modelling.scenario import Scenario from domain.modelling.scoring.scoring import ( MeasureImpact, cascade_scores, - independent_option_impacts, + independent_option_signals, marginals_from_scores, ) from domain.modelling.generators.wall_recommendation import recommend_cavity_wall @@ -48,12 +51,6 @@ from repositories.product.product_repository import ProductRepository from repositories.solar.solar_repository import SolarRepository from repositories.unit_of_work import UnitOfWork -# The PortfolioGoal value that targets a SAP band (cf. -# backend.app.db.models.portfolio.PortfolioGoal.INCREASING_EPC). Other goals -# (Energy Savings, Reducing CO2 emissions) don't yet set a SAP repair target — -# the optimiser just maximises SAP gain within budget for them (later slice). -_INCREASING_EPC_GOAL: Final[str] = "Increasing EPC" - # Best-practice install sequence for the role-3 attribution cascade (ADR-0016): # walls → roof → ventilation → floor, per the legacy `Recommendations` class. # Ventilation sits after the fabric that triggers it so its (negative) marginal @@ -175,6 +172,11 @@ class ModellingOrchestrator: considered: Optional[frozenset[MeasureType]] = combine_considered_measures( scenario.considered_measures(), considered_measures ) + # The Optimiser speaks the goal's currency (ADR-0062): group signals, + # dependency pricing and repair marginals are all measured by this + # objective — SAP by default, carbon reduction for a Reducing-CO2 goal. + _require_budget_for_goal_aligned(scenario) + objective: Callable[[Score], float] = _objective_for(scenario, bill_derivation) groups: list[list[ScoredOption]] = _scored_candidate_groups( scorer, effective_epc, @@ -182,19 +184,27 @@ class ModellingOrchestrator: planning_restrictions, solar_potential, considered, + objective, ) # Forced Measure Dependencies (ventilation) are excluded from the pool # but injected into the package before the re-score (ADR-0016). dependencies: list[MeasureDependency] = _measure_dependencies( effective_epc, products, considered ) - package: OptimisedPackage = optimise_package( + # A Fabric First brief optimises the envelope with the full budget + # before heating / renewables are considered on top (mirroring the + # legacy engine's enforce_fabric_first). + optimise = ( + optimise_package_fabric_first if scenario.fabric_first else optimise_package + ) + package: OptimisedPackage = optimise( groups=groups, scorer=scorer, baseline_epc=effective_epc, budget=scenario.budget, target_sap=_target_sap(scenario), dependencies=dependencies, + objective=objective, ) # Role-3 attribution: re-apply the *selected* set in best-practice order @@ -388,9 +398,11 @@ def _scored_candidate_groups( planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], considered_measures: Optional[frozenset[MeasureType]], + objective: Callable[[Score], float], ) -> list[list[ScoredOption]]: """One group per Recommendation: each Option scored independently against - the baseline (role-1 warm-start signal, ADR-0016).""" + the baseline (role-1 warm-start signal, ADR-0016), in the goal objective's + currency (ADR-0062).""" # The SAP design heat loss sizes the ASHP to the dwelling (ADR-0049); read it # off a baseline score, which the group scoring computes anyway. baseline_result = scorer.score(effective_epc, []).sap_result @@ -407,22 +419,80 @@ def _scored_candidate_groups( design_heat_loss_kw, ): options = list(recommendation.options) - impacts: list[MeasureImpact] = independent_option_impacts( - scorer, effective_epc, options + signals: list[float] = independent_option_signals( + scorer, effective_epc, options, objective ) groups.append( [ - ScoredOption(option=option, sap_gain=impact.sap_points) - for option, impact in zip(options, impacts, strict=True) + ScoredOption(option=option, sap_gain=signal) + for option, signal in zip(options, signals, strict=True) ] ) return groups +def _carbon_reduction(score: Score) -> float: + """The Reducing-CO2 objective: annual kg CO2 below zero-point, negated so + higher is better (a saved kg scores +1).""" + return -score.co2_kg_per_yr + + +def _bill_saving(bill_derivation: BillDerivation) -> Callable[[Score], float]: + """The Energy-Savings objective: the annual Bill at the current Fuel Rates + snapshot, negated so higher is better (a saved £ scores +1). Priced at the + live snapshot, not SAP's internal tariff book — that difference is the + point of the goal (ADR-0062).""" + + def objective(score: Score) -> float: + return -_bill_for(bill_derivation, score).total_gbp + + return objective + + +# The goal-aligned goals (ADR-0062): each maximises its own metric within the +# Scenario budget and sets no SAP target. One table is the single source of +# "which goals are goal-aligned" — both the objective dispatch and the +# budget-required guard read it, so a new goal-aligned goal cannot be added to +# one without the other. Each entry builds its objective from the plan's +# BillDerivation (the carbon objective ignores it; the bill objective needs it). +# A goal absent from the table optimises SAP, as every goal did before. +_GOAL_ALIGNED_OBJECTIVES: Final[ + dict[str, Callable[[BillDerivation], Callable[[Score], float]]] +] = { + PortfolioGoal.REDUCING_CO2_EMISSIONS.value: lambda _bill_derivation: ( + _carbon_reduction + ), + PortfolioGoal.ENERGY_SAVINGS.value: _bill_saving, +} + + +def _require_budget_for_goal_aligned(scenario: Scenario) -> None: + """A goal-aligned Scenario is 'reduce as much as possible within this + budget' — undefined without one (unconstrained, it would recommend every + beneficial measure). Fail the misconfiguration loudly (ADR-0062).""" + if scenario.budget is None and scenario.goal in _GOAL_ALIGNED_OBJECTIVES: + raise ValueError( + f"scenario {scenario.id} has goal {scenario.goal!r} but no budget; " + "goal-aligned scenarios require a budget" + ) + + +def _objective_for( + scenario: Scenario, bill_derivation: BillDerivation +) -> Callable[[Score], float]: + """The metric the Scenario's goal maximises (ADR-0062), as an Optimiser + objective (higher is better). Goals without an aligned metric optimise + SAP, as every goal did before.""" + build_objective = _GOAL_ALIGNED_OBJECTIVES.get(scenario.goal) + if build_objective is None: + return sap_rating + return build_objective(bill_derivation) + + def _target_sap(scenario: Scenario) -> Optional[float]: """The SAP rating the Optimiser repairs toward — the floor of the goal band for an INCREASING_EPC goal, else None (no SAP target).""" - if scenario.goal != _INCREASING_EPC_GOAL: + if scenario.goal != PortfolioGoal.INCREASING_EPC.value: return None return float(Epc(scenario.goal_value).sap_lower_bound()) diff --git a/tests/domain/modelling/_optimiser_fixtures.py b/tests/domain/modelling/_optimiser_fixtures.py new file mode 100644 index 000000000..01927b481 --- /dev/null +++ b/tests/domain/modelling/_optimiser_fixtures.py @@ -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 + ), + ) diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 80f39d7e1..75bec806e 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -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", diff --git a/tests/domain/modelling/test_optimiser_fabric_first.py b/tests/domain/modelling/test_optimiser_fabric_first.py new file mode 100644 index 000000000..b58e92e12 --- /dev/null +++ b/tests/domain/modelling/test_optimiser_fabric_first.py @@ -0,0 +1,349 @@ +"""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 EpcPropertyData +from domain.modelling.measure_type import MeasureType +from domain.modelling.optimisation.optimiser import ( + OptimisedPackage, + ScoredOption, + optimise_package_fabric_first, +) +from domain.modelling.scoring.package_scorer import Score +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, +) + +_AIRTIGHTNESS_TRIGGERS: frozenset[MeasureType] = frozenset( + {MeasureType.CAVITY_WALL_INSULATION, MeasureType.DOUBLE_GLAZING} +) + + +def test_fabric_reaching_the_target_excludes_non_fabric_measures() -> None: + # 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_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=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=15000.0, + target_sap=69.0, + ) + + # Assert — fabric only: the wall (true 72 ≥ 69); the boiler is never + # considered because the upgrade requirement is already met. + 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_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, 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.selected) == { + "cavity_wall_insulation", + "air_source_heat_pump", + } + assert abs(package.score.sap_continuous - 85.0) <= 1e-9 + + +def test_fabric_spend_comes_out_of_the_shared_budget_before_phase_two() -> None: + # Arrange — the £8000 heat pump alone would fit the £8500 budget and reach + # 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_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, heating=20.0) + + # Act — target 78 (gain 18). + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=8500.0, + target_sap=78.0, + ) + + # Assert — wall only; the target is missed rather than the fabric skipped. + assert selected_types(package.selected) == {"cavity_wall_insulation"} + assert abs(package.score.sap_continuous - 65.0) <= 1e-9 + + +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 + but +4 once the wall is insulated, and every ventilation overlay present + costs −1 — so a double injection is visible in the package score.""" + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + wall = any( + part.wall_insulation_type is not None + for sim in simulations + for part in sim.building_parts.values() + ) + 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 + 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 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_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() + + # Act — target 68: phase 1 gives 60 + 5 − 1 = 64; the glazing'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=[ + ventilation_dependency(cost=300.0, triggers=_AIRTIGHTNESS_TRIGGERS) + ], + ) + + # Assert — one ventilation, and the truthful total counts its penalty once: + # 60 + 5 wall + 4 glazing − 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.selected) == { + "cavity_wall_insulation", + "double_glazing", + "mechanical_ventilation", + } + assert abs(package.score.sap_continuous - 68.0) <= 1e-9 + + +def test_no_fabric_candidates_proceeds_straight_to_the_full_pool() -> None: + # Arrange — the envelope work is already done (no fabric Recommendation + # survives generation); fabric first must not veto the run, it just means + # phase 1 has nothing to do. + groups: list[list[ScoredOption]] = [ + [scored_option("air_source_heat_pump", gain=20.0, cost=8000.0, overlay=ASHP_OVERLAY)], + ] + scorer = StubScorer(base=60.0, heating=20.0) + + # Act — target 75. + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=20000.0, + target_sap=75.0, + ) + + # Assert — the heat pump package, exactly as a plain run would produce. + assert selected_types(package.selected) == {"air_source_heat_pump"} + assert abs(package.score.sap_continuous - 80.0) <= 1e-9 + + +def test_without_a_target_fabric_still_gets_first_claim_on_the_budget() -> None: + # Arrange — a max-gain goal (no SAP target). Plain max-gain would spend the + # 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_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, heating=20.0) + + # Act — no target: the flag applies to every goal, not just Increasing EPC. + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=8000.0, + target_sap=None, + ) + + # Assert — wall first; the heat pump no longer fits the leftover £7000. + assert selected_types(package.selected) == {"cavity_wall_insulation"} + assert abs(package.score.sap_continuous - 65.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 + ) + + +class _GlazingInteractionScorer: + """A stub where glazing is worthless on the raw dwelling (+0) but worth +4 + once the wall is insulated — so phase 1's max-gain fabric pass leaves it + out, and only a phase 2 that re-admits unpicked fabric can close the + target with it.""" + + 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() + ) + 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 + 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 + ) + + +def test_fabric_unpicked_in_phase_one_can_reenter_phase_two() -> None: + # Arrange — glazing loses phase 1 on merit (it scores nothing on the raw + # 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_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() + + # Act — target 69 (gain 9); budget £5000 keeps the heat pump out of reach + # after the wall's £1000. + package: OptimisedPackage = optimise_package_fabric_first( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=5000.0, + target_sap=69.0, + ) + + # Assert — the skipped glazing re-enters on its post-fabric worth: 60 + 5 + # wall + 4 glazing = 69, target met. + assert selected_types(package.selected) == { + "cavity_wall_insulation", + "double_glazing", + } + assert abs(package.score.sap_continuous - 69.0) <= 1e-9 + + +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_option("cavity_wall_insulation", gain=5.0, cost=1000.0, overlay=WALL_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() + + # 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.selected) == { + "cavity_wall_insulation", + "air_source_heat_pump", + } + assert abs(package.score.sap_continuous - 73.0) <= 1e-9 diff --git a/tests/domain/modelling/test_optimiser_goal_objective.py b/tests/domain/modelling/test_optimiser_goal_objective.py new file mode 100644 index 000000000..acd14b3ec --- /dev/null +++ b/tests/domain/modelling/test_optimiser_goal_objective.py @@ -0,0 +1,162 @@ +"""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 diff --git a/tests/domain/modelling/test_scoring.py b/tests/domain/modelling/test_scoring.py index 61cfb4544..35541ccc6 100644 --- a/tests/domain/modelling/test_scoring.py +++ b/tests/domain/modelling/test_scoring.py @@ -16,7 +16,7 @@ from domain.modelling.recommendation import MeasureOption from domain.modelling.scoring.scoring import ( MeasureImpact, cascade_scores, - independent_option_impacts, + independent_option_signals, marginal_impacts, marginals_from_scores, ) @@ -64,7 +64,7 @@ def _option(overlay: EpcSimulation) -> MeasureOption: ) -def test_independent_option_impacts_score_each_distinct_overlay_once() -> None: +def test_independent_option_signals_score_each_distinct_overlay_once() -> None: # Arrange baseline: EpcPropertyData = build_epc() scorer = _CountingScorer() @@ -86,15 +86,15 @@ def test_independent_option_impacts_score_each_distinct_overlay_once() -> None: options = [_option(overlay_a), _option(overlay_a_dup), _option(overlay_b)] # Act - impacts: list[MeasureImpact] = independent_option_impacts( - scorer, baseline, options + signals: list[float] = independent_option_signals( + scorer, baseline, options, lambda score: score.sap_continuous ) # Assert # baseline scored once + one score per DISTINCT overlay (a, b) = 3, not 4 assert scorer.calls == 3 - assert impacts[0].sap_points == impacts[1].sap_points == 2.0 - assert impacts[2].sap_points == 3.0 + assert signals[0] == signals[1] == 2.0 + assert signals[2] == 3.0 def test_single_overlay_marginal_is_its_improvement_over_baseline() -> None: diff --git a/tests/orchestration/test_modelling_fabric_first.py b/tests/orchestration/test_modelling_fabric_first.py new file mode 100644 index 000000000..86d5e7746 --- /dev/null +++ b/tests/orchestration/test_modelling_fabric_first.py @@ -0,0 +1,60 @@ +"""The ModellingOrchestrator honours a Fabric First Scenario: when +``scenario.fabric_first`` is set, the Optimiser treats the building envelope +with the full budget before heating / renewables are considered, so a budget +the plain optimiser would spend on a heating system is spent on fabric +instead. End-to-end through ``run_modelling`` (no database) with the real +calculator, against the uninsulated solid-brick 001431 dwelling whose plain +plan is heating-led. +""" + +from __future__ import annotations + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.measure_type import FABRIC_MEASURE_TYPES, MeasureType +from domain.modelling.plan import Plan +from domain.modelling.scenario import Scenario +from harness.console import run_modelling +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + +_HEATING_MEASURES: frozenset[MeasureType] = frozenset( + { + MeasureType.GAS_BOILER_UPGRADE, + MeasureType.AIR_SOURCE_HEAT_PUMP, + MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, + MeasureType.SOLAR_PV, + } +) + + +def _fabric_first_scenario(*, budget: float) -> Scenario: + return Scenario( + id=999, + goal="Increasing EPC", + goal_value="C", + budget=budget, + is_default=True, + fabric_first=True, + ) + + +def test_fabric_first_scenario_spends_the_budget_on_fabric_before_heating() -> None: + # Arrange — uninsulated solid brick: at a £4000 budget the plain optimiser + # buys the £3200 gas boiler (the cheapest route toward band C). Fabric + # first must commit the envelope work first, after which the boiler no + # longer fits the leftover budget. + epc: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Act + plan: Plan = run_modelling( + epc, scenario=_fabric_first_scenario(budget=4000.0), print_table=False + ) + + # Assert — the plan is fabric-led: at least one envelope measure, and none + # of the budget leaked to a heating system or renewables. + selected = {measure.measure_type for measure in plan.measures} + assert selected & FABRIC_MEASURE_TYPES + assert not selected & _HEATING_MEASURES diff --git a/tests/orchestration/test_modelling_goal_objectives.py b/tests/orchestration/test_modelling_goal_objectives.py new file mode 100644 index 000000000..be007ac80 --- /dev/null +++ b/tests/orchestration/test_modelling_goal_objectives.py @@ -0,0 +1,133 @@ +"""The ModellingOrchestrator aligns the Optimiser's objective with the +Scenario's goal (ADR-0062): Reducing CO2 emissions maximises the carbon +reduction the budget buys, Energy Savings maximises the annual bill saving, +and Increasing EPC keeps its SAP target semantics. End-to-end through +``run_modelling`` (no database) with the real calculator, against the +uninsulated solid-brick 001431 dwelling where the SAP-optimal and +carbon-optimal packages diverge at a £16,000 budget. +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.fuel_rates.fuel import Fuel +from domain.fuel_rates.fuel_rates import FuelRate, FuelRates +from domain.modelling.measure_type import MeasureType +from domain.modelling.plan import Plan +from domain.modelling.scenario import Scenario +from harness.console import run_modelling +from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository +from repositories.fuel_rates.fuel_rates_static_file_repository import ( + FuelRatesStaticFileRepository, +) +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + + +def _solid_brick_dwelling() -> EpcPropertyData: + return parse_recommendation_summary("solid_brick_ewi_001431_before.pdf") + + +def _scenario(goal: str, *, budget: float) -> Scenario: + return Scenario( + id=999, goal=goal, goal_value="", budget=budget, is_default=True + ) + + +def test_reducing_co2_scenario_buys_carbon_not_sap() -> None: + # Arrange — at £16,000 the SAP objective buys the wall + floor + £3,200 + # gas boiler package (~2,069 kg CO2/yr, SAP 72.9). The carbon objective + # swaps the boiler for electric storage heaters (~1,098 kg/yr) — a lower + # SAP, but ~970 kg/yr less carbon on the low-carbon grid. + epc = _solid_brick_dwelling() + + # Act — the same dwelling and budget under each goal. + sap_led: Plan = run_modelling( + epc, + scenario=_scenario("Valuation Improvement", budget=16000.0), + print_table=False, + ) + carbon_led: Plan = run_modelling( + epc, + scenario=_scenario("Reducing CO2 emissions", budget=16000.0), + print_table=False, + ) + + # Assert — the goal changes the outcome in the goal's favour: the carbon + # plan cuts materially more CO2 than the SAP plan buys with the same + # money, and the gas boiler that wins on SAP-per-£ is rejected. + assert ( + carbon_led.post_retrofit.co2_kg_per_yr + < sap_led.post_retrofit.co2_kg_per_yr - 500.0 + ) + selected = {measure.measure_type for measure in carbon_led.measures} + assert MeasureType.GAS_BOILER_UPGRADE not in selected + + +def test_a_goal_aligned_scenario_without_a_budget_fails_loudly() -> None: + # Arrange — 'reduce as much as possible within this budget' is undefined + # without a budget: unconstrained it would recommend every beneficial + # measure. A budget-less goal-aligned Scenario is a misconfiguration and + # must fail visibly, not produce a maximal plan. + epc = _solid_brick_dwelling() + budgetless = Scenario( + id=999, + goal="Reducing CO2 emissions", + goal_value="", + budget=None, + is_default=True, + ) + + # Act / Assert + with pytest.raises(ValueError, match="budget"): + run_modelling(epc, scenario=budgetless, print_table=False) + + +class _FixedFuelRates(FuelRatesRepository): + def __init__(self, rates: FuelRates) -> None: + self._rates = rates + + def get_current(self) -> FuelRates: + return self._rates + + +def _cheap_electricity_snapshot() -> FuelRates: + """The committed snapshot with electricity at 1p/kWh — a world where any + electric heating out-bills gas, while SAP's internal price book (which the + calculator rates against) is unmoved.""" + base = FuelRatesStaticFileRepository().get_current() + rates = dict(base.rates) + rates[Fuel.ELECTRICITY] = FuelRate( + unit_rate_p_per_kwh=1.0, + standing_charge_p_per_day=rates[Fuel.ELECTRICITY].standing_charge_p_per_day, + ) + return dataclasses.replace(base, rates=rates) + + +def test_energy_savings_scenario_prices_packages_at_the_live_fuel_rates() -> None: + # Arrange — SAP is itself a cost metric, but it prices energy from its + # internal tariff book. The Energy Savings goal must price at the *live* + # Fuel Rates snapshot: with 1p/kWh electricity, electric heating slashes + # the bill even though SAP still scores the gas boiler package higher. + epc = _solid_brick_dwelling() + + # Act + plan: Plan = run_modelling( + epc, + scenario=_scenario("Energy Savings", budget=16000.0), + fuel_rates=_FixedFuelRates(_cheap_electricity_snapshot()), + print_table=False, + ) + + # Assert — the bill objective abandons the boiler for electric heating. + selected = {measure.measure_type for measure in plan.measures} + assert MeasureType.GAS_BOILER_UPGRADE not in selected + assert selected & { + MeasureType.AIR_SOURCE_HEAT_PUMP, + MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, + } diff --git a/tests/repositories/scenario/test_scenario_postgres_repository.py b/tests/repositories/scenario/test_scenario_postgres_repository.py index 8b5804c7d..6fc537fe1 100644 --- a/tests/repositories/scenario/test_scenario_postgres_repository.py +++ b/tests/repositories/scenario/test_scenario_postgres_repository.py @@ -142,6 +142,28 @@ def test_get_many_raises_on_an_exclusion_that_is_not_a_measure_type( ScenarioPostgresRepository(session).get_many([7]) +def test_get_many_maps_the_fabric_first_flag(db_engine: Engine) -> None: + # Arrange — a Fabric First brief created in the scenario-builder. + with Session(db_engine) as session: + session.add( + ScenarioModel( + id=7, + goal=PortfolioGoal.INCREASING_EPC, + goal_value="C", + is_default=True, + fabric_first=True, + ) + ) + session.commit() + + # Act + with Session(db_engine) as session: + scenario: Scenario = ScenarioPostgresRepository(session).get_many([7])[0] + + # Assert + assert scenario.fabric_first is True + + def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> None: # Arrange with Session(db_engine) as session: