diff --git a/recommendations/optimiser/CostOptimiser.py b/recommendations/optimiser/CostOptimiser.py index 8f030123..43e303a7 100644 --- a/recommendations/optimiser/CostOptimiser.py +++ b/recommendations/optimiser/CostOptimiser.py @@ -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) diff --git a/recommendations/optimiser/GainOptimiser.py b/recommendations/optimiser/GainOptimiser.py index 6b757bf1..bd907b4d 100644 --- a/recommendations/optimiser/GainOptimiser.py +++ b/recommendations/optimiser/GainOptimiser.py @@ -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]) diff --git a/recommendations/optimiser/StrategicOptimiser.py b/recommendations/optimiser/StrategicOptimiser.py new file mode 100644 index 00000000..69de4085 --- /dev/null +++ b/recommendations/optimiser/StrategicOptimiser.py @@ -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) diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index 6afe7d78..324e2c74 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -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 ---------------------------------------------------------- diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index c2927790..f0ca6dac 100644 --- a/recommendations/tests/test_optimiser_functions.py +++ b/recommendations/tests/test_optimiser_functions.py @@ -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 diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py index 0c794119..63280907 100644 --- a/recommendations/tests/test_optimisers.py +++ b/recommendations/tests/test_optimisers.py @@ -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}]