mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
A Reducing-CO2 scenario maximises carbon reduction, not SAP 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
aac35327f7
commit
48d54675c3
3 changed files with 65 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue