mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Merge pull request #728 from Hestia-Homes/bug/plan-with-budget-more-expensive
Bug/plan with budget more expensive
This commit is contained in:
commit
61e9fb28be
6 changed files with 567 additions and 155 deletions
|
|
@ -1,4 +1,5 @@
|
|||
from mip import Model, xsum, minimize, BINARY, OptimizationStatus
|
||||
from typing import Mapping
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
|
@ -12,13 +13,20 @@ class CostOptimiser:
|
|||
# We add an optional buffer to the minimum gain to allow for slack in the optimisation
|
||||
BUFFER = 0.2
|
||||
|
||||
def __init__(self, components, min_gain, verbose=False):
|
||||
def __init__(
|
||||
self,
|
||||
components: list[list[Mapping[str, int | float | str]]],
|
||||
min_gain: float | int,
|
||||
verbose: bool = False,
|
||||
allow_slack: bool = True
|
||||
):
|
||||
self.components = components
|
||||
self.min_gain = min_gain
|
||||
self.gain_constraint = None
|
||||
self.m = None
|
||||
self.variables = []
|
||||
self.solution = []
|
||||
self.allow_slack = allow_slack
|
||||
|
||||
self.solution_cost = None
|
||||
self.solution_gain = None
|
||||
|
|
@ -81,6 +89,20 @@ class CostOptimiser:
|
|||
for group_vars in self.variables:
|
||||
self.m += xsum(var for var in group_vars) <= 1
|
||||
|
||||
def add_budget_constraint(self, budget: int | float) -> None:
|
||||
# Inject budget constraint, which ensures that sum of cost_ig * x_ig <= budget, where cost_ig represents the
|
||||
# cost for the ith component in group g, and x_ig is the binary decision variable for the ith component in
|
||||
# group g
|
||||
|
||||
self.m += (
|
||||
xsum(
|
||||
item["cost"] * var
|
||||
for group, group_vars in zip(self.components, self.variables)
|
||||
for item, var in zip(group, group_vars)
|
||||
)
|
||||
<= budget
|
||||
)
|
||||
|
||||
def setup_slack(self):
|
||||
|
||||
# Remove the original gain constraint
|
||||
|
|
@ -109,10 +131,17 @@ class CostOptimiser:
|
|||
self.m.optimize()
|
||||
|
||||
if self.m.status == OptimizationStatus.INFEASIBLE:
|
||||
# Turn off logging - too noisy
|
||||
# logger.info("We have an infeasible model, setting up slack model")
|
||||
self.setup_slack()
|
||||
self.m.optimize()
|
||||
if self.allow_slack:
|
||||
self.setup_slack()
|
||||
self.m.optimize()
|
||||
else:
|
||||
# Explicity return an empty solution
|
||||
self.solution = []
|
||||
self.solution_cost = 0
|
||||
self.solution_gain = 0
|
||||
return
|
||||
|
||||
# If we still have an infeasible solution, we return an empty solution
|
||||
|
||||
self.solution = [
|
||||
item for group, group_vars in zip(self.components, self.variables) for item, var in zip(group, group_vars)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from mip import Model, xsum, maximize, BINARY, OptimizationStatus
|
||||
from utils.logger import setup_logger
|
||||
from typing import Mapping
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
|
@ -9,7 +10,14 @@ class GainOptimiser:
|
|||
This class is used to maximise gain, given a constrained cost
|
||||
"""
|
||||
|
||||
def __init__(self, components, max_cost, max_gain, allow_slack=True, verbose=False):
|
||||
def __init__(
|
||||
self,
|
||||
components: list[list[Mapping[str, int | float | str]]],
|
||||
max_cost: float | int,
|
||||
max_gain: float | int | None,
|
||||
allow_slack: bool = True,
|
||||
verbose: bool = False
|
||||
):
|
||||
"""
|
||||
This function will try and maximise the gain, given a constrained cost. If we specific a max_gain, then the
|
||||
optimisation routine is constained to try not to exceed a maximum increase
|
||||
|
|
@ -21,8 +29,8 @@ class GainOptimiser:
|
|||
:param components: List of components, where each component is a dictionary with keys "id", "cost" and "gain"
|
||||
:param max_cost: Maximum cost constraint
|
||||
:param max_gain: Maximum gain constraint
|
||||
:param allow_slack: If True, allows the model to use slack variables to relax the cost constraint if the model
|
||||
is infeasible. Defaults to True.
|
||||
:param allow_slack: If True, and the solution is infeasible, allows the model to use slack variables to relax
|
||||
the cost constraint if the model. Defaults to True.
|
||||
:param verbose: If True, enables verbose logging
|
||||
"""
|
||||
self.components = components
|
||||
|
|
@ -148,5 +156,5 @@ class GainOptimiser:
|
|||
|
||||
self.solution = solution
|
||||
|
||||
self.solution_gain = self.m.objective.x
|
||||
self.solution_gain = sum(component['gain'] for component in self.solution)
|
||||
self.solution_cost = sum([component['cost'] for component in self.solution])
|
||||
|
|
|
|||
177
recommendations/optimiser/StrategicOptimiser.py
Normal file
177
recommendations/optimiser/StrategicOptimiser.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
from enum import Enum
|
||||
from mip import OptimizationStatus
|
||||
from typing import Mapping, Optional, TypedDict, List
|
||||
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
||||
|
||||
|
||||
class Measure(TypedDict):
|
||||
id: str
|
||||
cost: float
|
||||
gain: float
|
||||
|
||||
|
||||
class Strategies(Enum):
|
||||
CASE_1_TRY_MIN_COST_WITH_CONSTRAINTS = "case_1_try_min_cost_with_constraints"
|
||||
CASE_1_SOLVE_MAX_GAIN_UNDER_BUDGET = "case_1_solve_max_gain_under_budget"
|
||||
CASE_2_SOLVE_MAX_GAIN_UNDER_BUDGET = "case_2_solve_max_gain_under_budget"
|
||||
CASE_3_SOLVE_MIN_COST_FOR_TARGET = "case_3_solve_min_cost_for_target"
|
||||
|
||||
|
||||
class StrategicOptimiser:
|
||||
"""
|
||||
Domain-level optimiser implementing logical optimisation logic.
|
||||
|
||||
Behaviour:
|
||||
|
||||
1) If both budget and target_gain are provided:
|
||||
- Minimise cost subject to:
|
||||
gain >= target_gain
|
||||
cost <= budget
|
||||
- If infeasible:
|
||||
maximise gain subject to cost <= budget
|
||||
|
||||
2) If only budget is provided:
|
||||
- Maximise gain under budget
|
||||
|
||||
3) If only target_gain is provided:
|
||||
- Minimise cost to achieve gain
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
components: list[list[Mapping[str, int | float | str]]],
|
||||
budget: Optional[float] = None,
|
||||
target_gain: Optional[float] = None,
|
||||
allow_slack: bool = False,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
|
||||
if not components:
|
||||
raise ValueError("Components cannot be empty.")
|
||||
|
||||
if budget is None and target_gain is None:
|
||||
raise ValueError("At least one of budget or target_gain must be provided.")
|
||||
|
||||
self.components = components
|
||||
self.budget = budget
|
||||
self.target_gain = target_gain
|
||||
self.verbose = verbose
|
||||
self.allow_slack = allow_slack
|
||||
|
||||
self.solution: List[Measure] = []
|
||||
self.solution_cost: float = 0.0
|
||||
self.solution_gain: float = 0.0
|
||||
|
||||
# For debugging purposes, we keep a record of which option was selected
|
||||
self.strategy_used: Optional[Strategies] = None
|
||||
|
||||
def solve(self) -> None:
|
||||
"""
|
||||
Primary entry point for solving the optimisation problem based on the provided budget and target gain.
|
||||
:return:
|
||||
"""
|
||||
|
||||
# Case 1: budget + target
|
||||
if self.budget is not None and self.target_gain is not None:
|
||||
# Given:
|
||||
# Budget B
|
||||
# Target gain G
|
||||
#
|
||||
# We want the solution to:
|
||||
#
|
||||
# Primary problem (P1)
|
||||
# min cost
|
||||
# subject to
|
||||
#
|
||||
# gain >= G
|
||||
# cost <= B
|
||||
# multiple-choice constraints
|
||||
#
|
||||
# If (P1) is feasible → that solution is exactly what you want.
|
||||
# If (P1) is infeasible → solve the following problem (P2):
|
||||
#
|
||||
# max gain
|
||||
# subject to
|
||||
#
|
||||
# cost <= B
|
||||
if self._try_min_cost_with_constraints():
|
||||
# Keep a record of the strategy used to solve the problem, for debugging purposes
|
||||
self.strategy_used = Strategies.CASE_1_TRY_MIN_COST_WITH_CONSTRAINTS
|
||||
return
|
||||
self._solve_max_gain_under_budget()
|
||||
self.strategy_used = Strategies.CASE_1_SOLVE_MAX_GAIN_UNDER_BUDGET
|
||||
return
|
||||
|
||||
# Case 2: budget only
|
||||
if self.budget is not None:
|
||||
self._solve_max_gain_under_budget()
|
||||
self.strategy_used = Strategies.CASE_2_SOLVE_MAX_GAIN_UNDER_BUDGET
|
||||
return
|
||||
|
||||
# Case 3: target only
|
||||
self._solve_min_cost_for_target()
|
||||
self.strategy_used = Strategies.CASE_3_SOLVE_MIN_COST_FOR_TARGET
|
||||
return
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Internal Functions
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _try_min_cost_with_constraints(self) -> bool:
|
||||
"""
|
||||
Try to minimise cost while satisfying:
|
||||
gain >= target_gain
|
||||
cost <= budget
|
||||
"""
|
||||
|
||||
opt = CostOptimiser(
|
||||
self.components,
|
||||
min_gain=self.target_gain,
|
||||
verbose=self.verbose,
|
||||
allow_slack=self.allow_slack
|
||||
)
|
||||
|
||||
opt.setup()
|
||||
opt.add_budget_constraint(self.budget)
|
||||
opt.solve()
|
||||
|
||||
if opt.m.status == OptimizationStatus.INFEASIBLE:
|
||||
return False
|
||||
|
||||
self._store_solution(opt.solution)
|
||||
return True
|
||||
|
||||
def _solve_max_gain_under_budget(self) -> None:
|
||||
|
||||
opt = GainOptimiser(
|
||||
self.components,
|
||||
max_cost=self.budget,
|
||||
max_gain=None,
|
||||
allow_slack=self.allow_slack,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
opt.setup()
|
||||
opt.solve()
|
||||
|
||||
self._store_solution(opt.solution)
|
||||
|
||||
def _solve_min_cost_for_target(self) -> None:
|
||||
|
||||
opt = CostOptimiser(
|
||||
self.components,
|
||||
min_gain=self.target_gain,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
opt.setup()
|
||||
opt.solve()
|
||||
|
||||
self._store_solution(opt.solution)
|
||||
|
||||
def _store_solution(self, solution: List[Measure]) -> None:
|
||||
self.solution = solution
|
||||
self.solution_cost = sum(m["cost"] for m in solution)
|
||||
self.solution_gain = sum(m["gain"] for m in solution)
|
||||
|
|
@ -18,6 +18,7 @@ from backend.app.plan.schemas import (
|
|||
)
|
||||
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
||||
from recommendations.optimiser.StrategicOptimiser import StrategicOptimiser
|
||||
from utils.logger import setup_logger
|
||||
from backend.Funding import Funding
|
||||
from backend.app.BatterySapScorer import BatterySAPScorer
|
||||
|
|
@ -713,7 +714,9 @@ def optimise_with_scenarios(
|
|||
remaining_measures.append(kept)
|
||||
|
||||
remaining_budget = budget - fabric_cost if budget is not None else None
|
||||
remaining_budget = 0 if remaining_budget < 0 else remaining_budget
|
||||
|
||||
if remaining_budget is not None:
|
||||
remaining_budget = 0 if remaining_budget < 0 else remaining_budget
|
||||
|
||||
picked_extra, extra_cost, extra_gain = run_optimizer(
|
||||
remaining_measures,
|
||||
|
|
@ -1111,28 +1114,30 @@ def run_optimizer(
|
|||
allow_slack: bool = False
|
||||
):
|
||||
"""
|
||||
Thin wrapper over your optimisers.
|
||||
Returns: list[dict] selected_options
|
||||
Thin wrapper around the StrategicOptimiser to run it on a subset of measures with an optional budget and target
|
||||
gain. Handles the cases of no input measures, and extracts the outputs for ease of use.
|
||||
:param input_measures: list of groups of measures (each group is a list of measure dicts)
|
||||
:param budget: optional budget to constrain the optimisation
|
||||
:param sub_target_gain: optional target gain to achieve from this optimisation run
|
||||
:param allow_slack: whether to allow solutions that exceed the target gain (True) or only solutions that meet it
|
||||
exactly (False)
|
||||
:return: tuple of (picked measures, total cost, total gain) where picked measures is a list of measure dicts
|
||||
"""
|
||||
|
||||
if not input_measures:
|
||||
return None, 0.0, 0.0
|
||||
|
||||
if budget is not None:
|
||||
opt = GainOptimiser(
|
||||
input_measures, max_cost=budget, max_gain=0 if sub_target_gain == 0 else (sub_target_gain or float("inf")),
|
||||
allow_slack=allow_slack
|
||||
)
|
||||
else:
|
||||
if sub_target_gain is None:
|
||||
raise ValueError("Either budget or target_gain must be provided.")
|
||||
opt = CostOptimiser(input_measures, min_gain=sub_target_gain)
|
||||
opt = StrategicOptimiser(
|
||||
components=input_measures,
|
||||
budget=budget,
|
||||
target_gain=sub_target_gain,
|
||||
allow_slack=allow_slack,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
opt.setup()
|
||||
opt.solve()
|
||||
cost = sum([x["cost"] for x in opt.solution])
|
||||
|
||||
return opt.solution, cost, opt.solution_gain
|
||||
return opt.solution, opt.solution_cost, opt.solution_gain
|
||||
|
||||
|
||||
# ---- Define optimisation paths ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from recommendations.tests.test_data.measures_to_optimise import measures_to_opt
|
|||
from recommendations.optimiser import optimiser_functions
|
||||
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
||||
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||
from recommendations.optimiser.StrategicOptimiser import StrategicOptimiser, Strategies
|
||||
|
||||
|
||||
class TestPrepareInputMeasures:
|
||||
|
|
@ -287,3 +288,225 @@ class TestIncreasingEpcE2e:
|
|||
# We don't add ventilation as major insulation work isn't done
|
||||
ventilation_added = any(rec["recommendation_id"] == "3_phase=2" and rec["default"] for rec in flattened)
|
||||
assert not ventilation_added, "Ventilation should not be added without major insulation work"
|
||||
|
||||
|
||||
class TestStrategicOptimiser:
|
||||
|
||||
@pytest.fixture
|
||||
def components(self):
|
||||
components = [
|
||||
[
|
||||
{'id': '0_phase=0', 'cost': 819.0, 'gain': 5.6, 'type': 'loft_insulation', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 819.0, 'raw_cost': 819.0, 'partial_project_funding': 0,
|
||||
'partial_project_score': 0, 'uplift_project_score': 0, 'already_installed': False,
|
||||
'has_battery': False, 'array_size': 0},
|
||||
{'id': '1_phase=0', 'cost': 702.0, 'gain': 5.6, 'type': 'loft_insulation', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 702.0, 'raw_cost': 702.0, 'partial_project_funding': 0,
|
||||
'partial_project_score': 0, 'uplift_project_score': 0, 'already_installed': False,
|
||||
'has_battery': False, 'array_size': 0},
|
||||
{'id': '2_phase=0', 'cost': 585.0, 'gain': 5.6, 'type': 'loft_insulation', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 585.0, 'raw_cost': 585.0, 'partial_project_funding': 0,
|
||||
'partial_project_score': 0, 'uplift_project_score': 0, 'already_installed': False,
|
||||
'has_battery': False, 'array_size': 0}],
|
||||
[{'id': '4_phase=2', 'cost': 3656.25, 'gain': 2.0, 'type': 'suspended_floor_insulation',
|
||||
'innovation_uplift': 0, 'cost_minus_uplift': 3656.25, 'raw_cost': 3656.25, 'partial_project_funding': 0,
|
||||
'partial_project_score': 0, 'uplift_project_score': 0, 'already_installed': False, 'has_battery': False,
|
||||
'array_size': 0}],
|
||||
[{'id': '5_phase=3', 'cost': 17.5, 'gain': 1.0, 'type': 'low_energy_lighting', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 17.5, 'raw_cost': 17.5, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 0}],
|
||||
[{'id': '6_phase=4', 'cost': 140, 'gain': 3.4, 'type': 'roomstat_programmer_trvs', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 140, 'raw_cost': 140, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 0},
|
||||
{'id': '7_phase=4', 'cost': 874.5680000000001, 'gain': 4.2, 'type': 'time_temperature_zone_control',
|
||||
'innovation_uplift': 0, 'cost_minus_uplift': 874.5680000000001, 'raw_cost': 874.5680000000001,
|
||||
'partial_project_funding': 0, 'partial_project_score': 0, 'uplift_project_score': 0,
|
||||
'already_installed': False, 'has_battery': False, 'array_size': 0}],
|
||||
[{'id': '9_phase=6', 'cost': 5420.0, 'gain': 13.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5420.0, 'raw_cost': 5420.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.6},
|
||||
{'id': '10_phase=6', 'cost': 6210.0, 'gain': 16.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6210.0, 'raw_cost': 6210.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.6,
|
||||
'battery_gain': 3},
|
||||
{'id': '11_phase=6', 'cost': 6820.0, 'gain': 16.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6820.0, 'raw_cost': 6820.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.6,
|
||||
'battery_gain': 3},
|
||||
{'id': '12_phase=6', 'cost': 7202.0, 'gain': 14.5, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 7202.0, 'raw_cost': 7202.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.915},
|
||||
{'id': '13_phase=6', 'cost': 6495.0, 'gain': 14.5, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6495.0, 'raw_cost': 6495.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.92},
|
||||
{'id': '14_phase=6', 'cost': 7285.0, 'gain': 17.5, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 7285.0, 'raw_cost': 7285.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.92,
|
||||
'battery_gain': 3},
|
||||
{'id': '15_phase=6', 'cost': 7895.0, 'gain': 17.5, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 7895.0, 'raw_cost': 7895.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.92,
|
||||
'battery_gain': 3},
|
||||
{'id': '16_phase=6', 'cost': 5520.0, 'gain': 15.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5520.0, 'raw_cost': 5520.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 4.0},
|
||||
{'id': '17_phase=6', 'cost': 6310.0, 'gain': 18.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6310.0, 'raw_cost': 6310.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 4.0,
|
||||
'battery_gain': 3},
|
||||
{'id': '18_phase=6', 'cost': 6920.0, 'gain': 18.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6920.0, 'raw_cost': 6920.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 4.0,
|
||||
'battery_gain': 3},
|
||||
{'id': '19_phase=6', 'cost': 5320.0, 'gain': 12.1, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5320.0, 'raw_cost': 5320.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.2},
|
||||
{'id': '20_phase=6', 'cost': 6110.0, 'gain': 14.1, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6110.0, 'raw_cost': 6110.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.2,
|
||||
'battery_gain': 2},
|
||||
{'id': '21_phase=6', 'cost': 6720.0, 'gain': 14.1, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6720.0, 'raw_cost': 6720.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.2,
|
||||
'battery_gain': 2},
|
||||
{'id': '22_phase=6', 'cost': 6932.0, 'gain': 13.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6932.0, 'raw_cost': 6932.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.48},
|
||||
{'id': '23_phase=6', 'cost': 6295.0, 'gain': 13.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6295.0, 'raw_cost': 6295.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.48},
|
||||
{'id': '24_phase=6', 'cost': 7085.0, 'gain': 16.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 7085.0, 'raw_cost': 7085.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.48,
|
||||
'battery_gain': 3},
|
||||
{'id': '25_phase=6', 'cost': 7695.0, 'gain': 16.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 7695.0, 'raw_cost': 7695.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.48,
|
||||
'battery_gain': 3},
|
||||
{'id': '26_phase=6', 'cost': 5220.0, 'gain': 10.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5220.0, 'raw_cost': 5220.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.8},
|
||||
{'id': '27_phase=6', 'cost': 6662.0, 'gain': 12.3, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6662.0, 'raw_cost': 6662.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.045},
|
||||
{'id': '28_phase=6', 'cost': 6095.0, 'gain': 12.3, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6095.0, 'raw_cost': 6095.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.05},
|
||||
{'id': '29_phase=6', 'cost': 5160.0, 'gain': 9.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5160.0, 'raw_cost': 5160.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.4},
|
||||
{'id': '30_phase=6', 'cost': 6392.0, 'gain': 10.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6392.0, 'raw_cost': 6392.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.61},
|
||||
{'id': '31_phase=6', 'cost': 5910.0, 'gain': 10.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5910.0, 'raw_cost': 5910.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.61},
|
||||
{'id': '32_phase=6', 'cost': 5100.0, 'gain': 8.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5100.0, 'raw_cost': 5100.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.0},
|
||||
{'id': '33_phase=6', 'cost': 6098.0, 'gain': 8.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 6098.0, 'raw_cost': 6098.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.175},
|
||||
{'id': '34_phase=6', 'cost': 5725.0, 'gain': 8.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5725.0, 'raw_cost': 5725.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.18},
|
||||
{'id': '35_phase=6', 'cost': 5040.0, 'gain': 6.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5040.0, 'raw_cost': 5040.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 1.6},
|
||||
{'id': '36_phase=6', 'cost': 5828.0, 'gain': 7.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5828.0, 'raw_cost': 5828.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 1.74},
|
||||
{'id': '37_phase=6', 'cost': 5540.0, 'gain': 7.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
||||
'cost_minus_uplift': 5540.0, 'raw_cost': 5540.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
||||
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 1.74}
|
||||
]
|
||||
]
|
||||
return components
|
||||
|
||||
def test_budget_and_target_gain_strategy_case_1_try_min_cost_with_constraints(self, components):
|
||||
budget = 5000
|
||||
target_gain = 11.5
|
||||
|
||||
opt = StrategicOptimiser(
|
||||
components=components,
|
||||
target_gain=target_gain,
|
||||
budget=budget,
|
||||
)
|
||||
|
||||
opt.solve()
|
||||
|
||||
# check strategy used
|
||||
assert opt.strategy_used.value == "case_1_try_min_cost_with_constraints"
|
||||
# Check the solution values
|
||||
assert opt.solution_cost == 4398.75
|
||||
assert opt.solution_gain == 12
|
||||
|
||||
def test_budget_and_target_gain_expecting_case_1_solve_max_gain_under_budget_strategy(self, components):
|
||||
budget = 4000
|
||||
target_gain = 11.5
|
||||
|
||||
opt = StrategicOptimiser(
|
||||
components=components,
|
||||
target_gain=target_gain,
|
||||
budget=budget,
|
||||
)
|
||||
|
||||
opt.solve()
|
||||
|
||||
# We expect to use case 1, but we won't be able to meet the target gain, so we should get the best solution
|
||||
# possible within the budget. We end up with an infeasible solution when we try
|
||||
# case_1_try_min_cost_with_constraints
|
||||
assert opt.strategy_used.value == "case_1_solve_max_gain_under_budget"
|
||||
assert opt.solution_cost == 1477.0680000000002
|
||||
assert opt.solution_gain == 10.8
|
||||
|
||||
def test_just_gain_expecting_case_3_solve_min_cost_for_target_strategy(self, components):
|
||||
budget = None
|
||||
target_gain = 11.5
|
||||
|
||||
opt = StrategicOptimiser(
|
||||
components=components,
|
||||
target_gain=target_gain,
|
||||
budget=budget,
|
||||
)
|
||||
|
||||
opt.solve()
|
||||
|
||||
# Should be case 3 - minimise cost for target gain
|
||||
assert opt.strategy_used.value == "case_3_solve_min_cost_for_target"
|
||||
assert opt.solution_cost == 4398.75
|
||||
assert opt.solution_gain == 12
|
||||
|
||||
def test_just_gain_of_20_expecting_case_3_solve_min_cost_for_target_strategy(self, components):
|
||||
budget = None
|
||||
target_gain = 20
|
||||
|
||||
opt = StrategicOptimiser(
|
||||
components=components,
|
||||
target_gain=target_gain,
|
||||
budget=budget,
|
||||
)
|
||||
|
||||
opt.solve()
|
||||
|
||||
# Should be case 3 - minimise cost for target gain
|
||||
assert opt.strategy_used.value == "case_3_solve_min_cost_for_target"
|
||||
assert opt.solution_cost == 5962.5
|
||||
assert opt.solution_gain == 20.2
|
||||
|
||||
def test_just_budget_expecting_case_2_solve_max_gain_under_budget_strategy(self, components):
|
||||
budget = 10000
|
||||
target_gain = None
|
||||
|
||||
opt = StrategicOptimiser(
|
||||
components=components,
|
||||
target_gain=target_gain,
|
||||
budget=budget,
|
||||
)
|
||||
|
||||
opt.solve()
|
||||
|
||||
# Should be case 2 - minimise cost for target gain
|
||||
assert opt.strategy_used.value == "case_2_solve_max_gain_under_budget"
|
||||
assert opt.solution_cost == 7787.068
|
||||
assert opt.solution_gain == 28.8
|
||||
|
|
|
|||
|
|
@ -1,74 +1,24 @@
|
|||
import pytest
|
||||
|
||||
from recommendations.optimiser.funding_optimiser import build_heat_pump_paths
|
||||
from recommendations.optimiser.funding_optimiser import run_optimizer
|
||||
|
||||
|
||||
class DummyProp:
|
||||
"""Minimal property stub exposing just what your code reads."""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {
|
||||
"current-energy-rating": "E", # or "D" for the special Social+D path
|
||||
"current-energy-efficiency": 55, # numeric SAP points used in eligibility calc
|
||||
"mainheat-energy-eff": "Very Good",
|
||||
}
|
||||
self.has_ventilation = False
|
||||
self.floor_area = 70.0
|
||||
self.main_heating_controls = {"clean_description": "time and temperature zone control"}
|
||||
self.walls = {'original_description': 'Solid brick, as built, no insulation (assumed)',
|
||||
'thermal_transmittance': None,
|
||||
'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False,
|
||||
'is_solid_brick': True,
|
||||
'is_system_built': False, 'is_timber_frame': False, 'is_granite_or_whinstone': False,
|
||||
'is_as_built': True,
|
||||
'is_cob': False, 'is_assumed': True, 'is_sandstone_or_limestone': False,
|
||||
'insulation_thickness': 'none',
|
||||
'external_insulation': False, 'internal_insulation': False}
|
||||
|
||||
self.main_heating = {
|
||||
'original_description': 'Boiler and radiators, mains gas',
|
||||
'clean_description': 'Boiler and radiators, mains gas',
|
||||
'has_radiators': True, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False,
|
||||
'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': True,
|
||||
'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False,
|
||||
'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False,
|
||||
'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False,
|
||||
'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, 'has_electric_heat_pump':
|
||||
False,
|
||||
'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, 'has_exhaust_source_heat_pump':
|
||||
False,
|
||||
'has_community_heat_pump': False, 'has_hot-water-only': False, 'has_electric': False, 'has_mains_gas':
|
||||
True,
|
||||
'has_wood_logs': False, 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False,
|
||||
'has_anthracite': False,
|
||||
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
|
||||
'has_mineral_and_wood': False, 'has_dual_fuel_appliance': False, 'has_assumed': False,
|
||||
'has_electricaire': False,
|
||||
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False
|
||||
}
|
||||
|
||||
self.main_fuel = {
|
||||
'original_description': 'mains gas (not community)', 'clean_description': 'Mains gas not community',
|
||||
'fuel_type': 'mains gas', 'tariff_type': None, 'is_community': False,
|
||||
'no_individual_heating_or_community_network': False, 'complex_fuel_type': None
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def p():
|
||||
return DummyProp()
|
||||
from recommendations.optimiser.funding_optimiser import (
|
||||
build_heat_pump_paths,
|
||||
run_optimizer,
|
||||
)
|
||||
|
||||
|
||||
def test_build_heat_pump_paths():
|
||||
eg1 = build_heat_pump_paths([], ["loft_insulation"])
|
||||
|
||||
assert eg1 == [{'AND': ['loft_insulation', 'air_source_heat_pump']}]
|
||||
|
||||
eg2 = build_heat_pump_paths(["internal_wall_insulation", "external_wall_insulation"], ["loft_insulation"])
|
||||
eg2 = build_heat_pump_paths(
|
||||
["internal_wall_insulation", "external_wall_insulation"],
|
||||
["loft_insulation"],
|
||||
)
|
||||
|
||||
assert eg2 == [{'AND': ['internal_wall_insulation', 'loft_insulation', 'air_source_heat_pump']},
|
||||
{'AND': ['external_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}]
|
||||
assert eg2 == [
|
||||
{'AND': ['internal_wall_insulation', 'loft_insulation', 'air_source_heat_pump']},
|
||||
{'AND': ['external_wall_insulation', 'loft_insulation', 'air_source_heat_pump']},
|
||||
]
|
||||
|
||||
|
||||
def test_run_optimizer_empty_input():
|
||||
|
|
@ -78,134 +28,154 @@ def test_run_optimizer_empty_input():
|
|||
assert gain == 0.0
|
||||
|
||||
|
||||
def test_uses_gain_optimiser_when_budget_provided(monkeypatch):
|
||||
captured_args = {}
|
||||
def test_budget_and_target_are_passed_correctly(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeGainOptimiser:
|
||||
def __init__(self, measures, max_cost, max_gain, allow_slack):
|
||||
captured_args["measures"] = measures
|
||||
captured_args["max_cost"] = max_cost
|
||||
captured_args["max_gain"] = max_gain
|
||||
captured_args["allow_slack"] = allow_slack
|
||||
self.solution = [{"cost": 100}]
|
||||
class FakeStrategicOptimiser:
|
||||
def __init__(
|
||||
self,
|
||||
components,
|
||||
budget=None,
|
||||
target_gain=None,
|
||||
allow_slack=False,
|
||||
verbose=False,
|
||||
):
|
||||
captured["components"] = components
|
||||
captured["budget"] = budget
|
||||
captured["target_gain"] = target_gain
|
||||
captured["allow_slack"] = allow_slack
|
||||
|
||||
self.solution = [{"cost": 100, "gain": 5}]
|
||||
self.solution_cost = 100
|
||||
self.solution_gain = 5
|
||||
|
||||
def setup(self):
|
||||
pass
|
||||
|
||||
def solve(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"recommendations.optimiser.funding_optimiser.GainOptimiser",
|
||||
FakeGainOptimiser
|
||||
"recommendations.optimiser.funding_optimiser.StrategicOptimiser",
|
||||
FakeStrategicOptimiser,
|
||||
)
|
||||
|
||||
measures = [[{"cost": 100, "gain": 5}]]
|
||||
|
||||
solution, cost, gain = run_optimizer(
|
||||
measures,
|
||||
[[{"cost": 100, "gain": 5}]],
|
||||
budget=500,
|
||||
sub_target_gain=10,
|
||||
allow_slack=True
|
||||
allow_slack=True,
|
||||
)
|
||||
|
||||
assert captured_args["max_cost"] == 500
|
||||
assert captured_args["max_gain"] == 10
|
||||
assert captured_args["allow_slack"] is True
|
||||
assert captured["budget"] == 500
|
||||
assert captured["target_gain"] == 10
|
||||
assert captured["allow_slack"] is True
|
||||
|
||||
assert cost == 100
|
||||
assert gain == 5
|
||||
assert solution == [{"cost": 100, "gain": 5}]
|
||||
|
||||
|
||||
def test_sub_target_gain_zero_sets_max_gain_zero(monkeypatch):
|
||||
captured_args = {}
|
||||
def test_sub_target_gain_zero_is_passed_as_zero(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeGainOptimiser:
|
||||
def __init__(self, measures, max_cost, max_gain, allow_slack):
|
||||
captured_args["max_gain"] = max_gain
|
||||
class FakeStrategicOptimiser:
|
||||
def __init__(
|
||||
self,
|
||||
components,
|
||||
budget=None,
|
||||
target_gain=None,
|
||||
allow_slack=False,
|
||||
verbose=False,
|
||||
):
|
||||
captured["target_gain"] = target_gain
|
||||
self.solution = []
|
||||
self.solution_gain = 0
|
||||
|
||||
def setup(self):
|
||||
pass
|
||||
self.solution_cost = 0.0
|
||||
self.solution_gain = 0.0
|
||||
|
||||
def solve(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"recommendations.optimiser.funding_optimiser.GainOptimiser",
|
||||
FakeGainOptimiser
|
||||
"recommendations.optimiser.funding_optimiser.StrategicOptimiser",
|
||||
FakeStrategicOptimiser,
|
||||
)
|
||||
|
||||
measures = [[{"cost": 100, "gain": 5}]]
|
||||
|
||||
run_optimizer(
|
||||
measures,
|
||||
[[{"cost": 100, "gain": 5}]],
|
||||
budget=500,
|
||||
sub_target_gain=0
|
||||
sub_target_gain=0,
|
||||
)
|
||||
|
||||
assert captured_args["max_gain"] == 0
|
||||
assert captured["target_gain"] == 0
|
||||
|
||||
|
||||
def test_sub_target_gain_none_sets_max_gain_infinity(monkeypatch):
|
||||
captured_args = {}
|
||||
def test_sub_target_gain_none_becomes_infinity(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeGainOptimiser:
|
||||
def __init__(self, measures, max_cost, max_gain, allow_slack):
|
||||
captured_args["max_gain"] = max_gain
|
||||
class FakeStrategicOptimiser:
|
||||
def __init__(
|
||||
self,
|
||||
components,
|
||||
budget=None,
|
||||
target_gain=None,
|
||||
allow_slack=False,
|
||||
verbose=False,
|
||||
):
|
||||
captured["target_gain"] = target_gain
|
||||
self.solution = []
|
||||
self.solution_gain = 0
|
||||
|
||||
def setup(self):
|
||||
pass
|
||||
self.solution_cost = 0.0
|
||||
self.solution_gain = 0.0
|
||||
|
||||
def solve(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"recommendations.optimiser.funding_optimiser.GainOptimiser",
|
||||
FakeGainOptimiser
|
||||
"recommendations.optimiser.funding_optimiser.StrategicOptimiser",
|
||||
FakeStrategicOptimiser,
|
||||
)
|
||||
|
||||
measures = [[{"cost": 100, "gain": 5}]]
|
||||
|
||||
run_optimizer(
|
||||
measures,
|
||||
[[{"cost": 100, "gain": 5}]],
|
||||
budget=500,
|
||||
sub_target_gain=None
|
||||
sub_target_gain=None,
|
||||
)
|
||||
|
||||
assert captured_args["max_gain"] == float("inf")
|
||||
assert captured["target_gain"] == None
|
||||
|
||||
|
||||
def test_uses_cost_optimiser_when_no_budget(monkeypatch):
|
||||
captured_args = {}
|
||||
def test_target_only_case(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeCostOptimiser:
|
||||
def __init__(self, measures, min_gain):
|
||||
captured_args["min_gain"] = min_gain
|
||||
self.solution = [{"cost": 50}]
|
||||
class FakeStrategicOptimiser:
|
||||
def __init__(
|
||||
self,
|
||||
components,
|
||||
budget=None,
|
||||
target_gain=None,
|
||||
allow_slack=False,
|
||||
verbose=False,
|
||||
):
|
||||
captured["budget"] = budget
|
||||
captured["target_gain"] = target_gain
|
||||
|
||||
self.solution = [{"cost": 50, "gain": 10}]
|
||||
self.solution_cost = 50
|
||||
self.solution_gain = 10
|
||||
|
||||
def setup(self):
|
||||
pass
|
||||
|
||||
def solve(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"recommendations.optimiser.funding_optimiser.CostOptimiser",
|
||||
FakeCostOptimiser
|
||||
"recommendations.optimiser.funding_optimiser.StrategicOptimiser",
|
||||
FakeStrategicOptimiser,
|
||||
)
|
||||
|
||||
measures = [[{"cost": 50, "gain": 10}]]
|
||||
|
||||
solution, cost, gain = run_optimizer(
|
||||
measures,
|
||||
sub_target_gain=10
|
||||
[[{"cost": 50, "gain": 10}]],
|
||||
sub_target_gain=10,
|
||||
)
|
||||
|
||||
assert captured_args["min_gain"] == 10
|
||||
assert captured["budget"] is None
|
||||
assert captured["target_gain"] == 10
|
||||
|
||||
assert cost == 50
|
||||
assert gain == 10
|
||||
assert solution == [{"cost": 50, "gain": 10}]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue