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/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index a1e45a58f..afb3fc4e8 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -21,7 +21,7 @@ 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 FABRIC_MEASURE_TYPES, MeasureType @@ -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,26 +205,32 @@ 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. @@ -231,6 +245,7 @@ def optimise_package_fabric_first( 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; @@ -258,10 +273,11 @@ def optimise_package_fabric_first( budget=budget, target_sap=target_sap, dependencies=dependencies, + objective=objective, ) if ( target_sap is not None - and fabric_package.score.sap_continuous >= target_sap + and objective(fabric_package.score) >= target_sap ): return fabric_package if not fabric_package.selected: @@ -311,13 +327,15 @@ def optimise_package_fabric_first( remaining_groups, post_fabric_scorer, baseline_epc, - start_sap=fabric_package.score.sap_continuous, + 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], @@ -338,23 +356,25 @@ def _rescored_groups( scorer: Scorer, baseline_epc: EpcPropertyData, *, - start_sap: float, + 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`` — 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_sap`` is the score of ``baseline_epc`` through ``scorer`` with no - candidate applied — the caller already has it (the phase-1 package score), - so it is threaded in rather than re-computed.""" + 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=scorer.score( - baseline_epc, [scored.option.overlay] - ).sap_continuous - - start_sap, + sap_gain=objective( + scorer.score(baseline_epc, [scored.option.overlay]) + ) + - start_value, ) for scored in group ] @@ -383,18 +403,20 @@ 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( @@ -432,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 @@ -499,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): @@ -520,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/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/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 23ac42941..69e88feac 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -20,16 +20,18 @@ from domain.modelling.optimisation.optimiser import ( 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 @@ -49,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 @@ -176,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, @@ -183,6 +184,7 @@ 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). @@ -202,6 +204,7 @@ class ModellingOrchestrator: budget=scenario.budget, target_sap=_target_sap(scenario), dependencies=dependencies, + objective=objective, ) # Role-3 attribution: re-apply the *selected* set in best-practice order @@ -395,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 @@ -414,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/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_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, + }