From 48d54675c3b585c015fa8dda4c7625dc0b08be9f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 12:13:06 +0000 Subject: [PATCH] =?UTF-8?q?A=20Reducing-CO2=20scenario=20maximises=20carbo?= =?UTF-8?q?n=20reduction,=20not=20SAP=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/optimisation/optimiser.py | 2 + domain/modelling/scoring/scoring.py | 29 +++++++++++++- orchestration/modelling_orchestrator.py | 44 +++++++++++++++++----- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index f4ce97a2f..2f556e2a2 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -244,6 +244,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; @@ -271,6 +272,7 @@ def optimise_package_fabric_first( budget=budget, target_sap=target_sap, dependencies=dependencies, + objective=objective, ) if ( target_sap is not None diff --git a/domain/modelling/scoring/scoring.py b/domain/modelling/scoring/scoring.py index ea995380a..8ac0701c9 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, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.scoring.package_scorer import PackageScorer, Score @@ -113,3 +113,30 @@ def independent_option_impacts( scored.append((option.overlay, cached)) impacts.append(cached) return impacts + + +def independent_option_signals( + scorer: PackageScorer, + baseline: EpcPropertyData, + options: Sequence[MeasureOption], + 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: float | None = next( + (signal for overlay, signal in scored if overlay == option.overlay), + None, + ) + if cached is None: + cached = objective(scorer.score(baseline, [option.overlay])) - base_value + scored.append((option.overlay, cached)) + signals.append(cached) + return signals diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 23ac42941..5a50c12c9 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -20,6 +20,7 @@ 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 @@ -29,7 +30,7 @@ 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 @@ -50,10 +51,12 @@ 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). +# backend.app.db.models.portfolio.PortfolioGoal.INCREASING_EPC). The +# goal-aligned goals (ADR-0062) set no target: they maximise their own metric +# within the Scenario budget. _INCREASING_EPC_GOAL: Final[str] = "Increasing EPC" +_REDUCING_CO2_GOAL: Final[str] = "Reducing CO2 emissions" +_ENERGY_SAVINGS_GOAL: Final[str] = "Energy Savings" # Best-practice install sequence for the role-3 attribution cascade (ADR-0016): # walls → roof → ventilation → floor, per the legacy `Recommendations` class. @@ -176,6 +179,10 @@ 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. + objective: Callable[[Score], float] = _objective_for(scenario) groups: list[list[ScoredOption]] = _scored_candidate_groups( scorer, effective_epc, @@ -183,6 +190,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 +210,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 +404,11 @@ def _scored_candidate_groups( planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], considered_measures: Optional[frozenset[MeasureType]], + objective: Callable[[Score], float] = sap_rating, ) -> 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,18 +425,33 @@ 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 _objective_for(scenario: Scenario) -> 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.""" + if scenario.goal == _REDUCING_CO2_GOAL: + return _carbon_reduction + return sap_rating + + 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)."""