A Reducing-CO2 scenario maximises carbon reduction, not SAP 🟩

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-09 12:13:06 +00:00
parent aac35327f7
commit 48d54675c3
3 changed files with 65 additions and 10 deletions

View file

@ -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

View file

@ -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

View file

@ -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)."""