From 8e22ced679b2f940c2dfe98f5815b2cd1673fa49 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Feb 2026 12:03:37 +0000 Subject: [PATCH 1/8] intial impelmentation of strategic optimiser with tests --- asset_list/app.py | 69 +- backend/engine/engine.py | 22 + recommendations/optimiser/CostOptimiser.py | 34 +- recommendations/optimiser/GainOptimiser.py | 9 +- .../optimiser/StrategicOptimiser.py | 175 +++++ .../optimiser/funding_optimiser.py | 1 + .../tests/test_optimiser_functions.py | 726 ++++++++++++++++++ 7 files changed, 975 insertions(+), 61 deletions(-) create mode 100644 recommendations/optimiser/StrategicOptimiser.py diff --git a/asset_list/app.py b/asset_list/app.py index b9c6bcf0..773c07b0 100644 --- a/asset_list/app.py +++ b/asset_list/app.py @@ -73,61 +73,24 @@ def app(): Property UPRN """ - data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/West Kent" - data_filename = "West Kent Asset List.xlsx" + data_folder = "/Users/khalimconn-kowlessar/Downloads" + data_filename = "assests.xlsx" sheet_name = "Sheet1" - postcode_column = "POSTCODE" - address1_column = None + postcode_column = "Postcode" + address1_column = "Address" address1_method = "house_number_extraction" - fulladdress_column = "ADDRESS" - address_cols_to_concat = [] + fulladdress_column = None + address_cols_to_concat = ["Address"] missing_postcodes_method = None landlord_year_built = None - landlord_os_uprn = None - landlord_property_type = "PROPERTY TYPE" - landlord_built_form = None - landlord_wall_construction = "wall combined" - landlord_roof_construction = "HEATING SYSTEM" - landlord_heating_system = None + landlord_os_uprn = "UPRN" + landlord_property_type = "Archetype" + landlord_built_form = "Bedroom Count" + landlord_wall_construction = "Wall Insulation Type" + landlord_roof_construction = "Roof Type" + landlord_heating_system = "Boiler Type" landlord_existing_pv = None - landlord_property_id = "UPRN" - landlord_sap = None - outcomes_filename = None - outcomes_sheetname = None - outcomes_postcode = None - outcomes_houseno = None - outcomes_id = None - outcomes_address = None - master_filepaths = [] - master_id_colnames = [] - master_to_asset_list_filepath = None - phase = False - ecosurv_landlords = None - asset_list_header = 0 - landlord_block_reference = None - - # Peabody data for cleaning - data_folder = ( - "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting " - "Project/data_validation" - ) - data_filename = "to_standardise_uprns.xlsx" - sheet_name = "Sheet1" - postcode_column = "POSTCODE" - address1_column = None - address1_method = "house_number_extraction" - fulladdress_column = "ADDRESS" - address_cols_to_concat = [] - missing_postcodes_method = None - landlord_year_built = None - landlord_os_uprn = None - landlord_property_type = "PROPERTY TYPE" - landlord_built_form = None # Skipped as empty - landlord_wall_construction = "wall combined" # combin F + G - landlord_roof_construction = "HEATING SYSTEM" # Combine I + J - landlord_heating_system = None # Check with Khalim - landlord_existing_pv = None - landlord_property_id = "UPRN" + landlord_property_id = "Tab" landlord_sap = None outcomes_filename = None outcomes_sheetname = None @@ -279,7 +242,7 @@ def app(): if skip is not None and not force_retrieve_data: if i <= skip: continue - chunk = asset_list.standardised_asset_list[i : i + chunk_size] + chunk = asset_list.standardised_asset_list[i: i + chunk_size] epc_data_chunk, errors_chunk, no_epc_chunk = get_data( df=chunk, row_id_name=asset_list.DOMNA_PROPERTY_ID, @@ -422,7 +385,7 @@ def app(): # Retrieve just the data we need epc_df = epc_df[ [asset_list.DOMNA_PROPERTY_ID] + list(asset_list.EPC_API_DATA_NAMES.keys()) - ].rename(columns=asset_list.EPC_API_DATA_NAMES) + ].rename(columns=asset_list.EPC_API_DATA_NAMES) # Look for columns not in the find my EPC data, which will have happened if we didn't # retrieve it in the first place @@ -439,7 +402,7 @@ def app(): find_my_epc_data[ [asset_list.DOMNA_PROPERTY_ID, "epc_has_floor_recommendation"] + list(asset_list.FIND_EPC_DATA_NAMES.keys()) - ].rename(columns=asset_list.FIND_EPC_DATA_NAMES), + ].rename(columns=asset_list.FIND_EPC_DATA_NAMES), how="left", on=asset_list.DOMNA_PROPERTY_ID, ) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 80d6d078..f8b25352 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -1118,6 +1118,28 @@ async def model_engine(body: PlanTriggerRequest): # When the goal is Increasing EPC, we can run the funding optimiser if body.goal == "Increasing EPC": + solutions_no_budget = optimise_with_scenarios( + p=p, + input_measures=input_measures, + budget=None, + target_gain=gain, + enforce_heat_pump_insulation=True, + enforce_fabric_first=body.enforce_fabric_first, + already_installed_sap=already_installed_sap, # To be passed to output + ) + solutions_no_budget["total_cost"] + + solutions_with_budget = optimise_with_scenarios( + p=p, + input_measures=input_measures, + budget=5000, + target_gain=gain, + enforce_heat_pump_insulation=True, + enforce_fabric_first=body.enforce_fabric_first, + already_installed_sap=already_installed_sap, # To be passed to output + ) + solutions_with_budget["total_cost"] + solutions = optimise_with_scenarios( p=p, input_measures=input_measures, diff --git a/recommendations/optimiser/CostOptimiser.py b/recommendations/optimiser/CostOptimiser.py index 8f030123..32a869b2 100644 --- a/recommendations/optimiser/CostOptimiser.py +++ b/recommendations/optimiser/CostOptimiser.py @@ -12,13 +12,16 @@ 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, min_gain, verbose=False, allow_slack=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 +84,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 +126,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..5dbf1dc5 100644 --- a/recommendations/optimiser/GainOptimiser.py +++ b/recommendations/optimiser/GainOptimiser.py @@ -21,8 +21,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 @@ -86,6 +86,9 @@ class GainOptimiser: for group_vars in self.variables: self.m += xsum(var for var in group_vars) <= 1 + self.m.max_gap = 0 + self.m.integer_tol = 1e-9 + def setup_slack(self): # Remove the original cost constraint self.m.remove(self.cost_constraint) @@ -148,5 +151,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..b75268eb --- /dev/null +++ b/recommendations/optimiser/StrategicOptimiser.py @@ -0,0 +1,175 @@ +from enum import Enum +from mip import OptimizationStatus +from typing import Sequence, 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: Sequence[Sequence[Measure]], + budget: Optional[float] = None, + target_gain: Optional[float] = None, + 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.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 >= 𝐺 + # 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=False + ) + + 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=False, + 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..aaf97226 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -1119,6 +1119,7 @@ def run_optimizer( 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 diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index c2927790..ca2a0dcb 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,728 @@ 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: + def test_budget_and_target_gain(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} + ] + ] + 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_2(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} + ] + ] + 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(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} + ] + ] + 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_gain2(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} + ] + ] + 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(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} + ] + ] + budget = 10000 + target_gain = None + + 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_2_solve_max_gain_under_budget" + assert opt.solution_cost == 7787.068 + assert opt.solution_gain == 28.8 From c08ab7a76765c476290fe7d70cd0d7d3bff07c28 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Feb 2026 12:11:14 +0000 Subject: [PATCH 2/8] passing around allow slack --- .../optimiser/StrategicOptimiser.py | 10 +++++---- .../optimiser/funding_optimiser.py | 22 ++++++++----------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/recommendations/optimiser/StrategicOptimiser.py b/recommendations/optimiser/StrategicOptimiser.py index b75268eb..8ffc307c 100644 --- a/recommendations/optimiser/StrategicOptimiser.py +++ b/recommendations/optimiser/StrategicOptimiser.py @@ -1,6 +1,6 @@ from enum import Enum from mip import OptimizationStatus -from typing import Sequence, Optional, TypedDict, List +from typing import Mapping, Optional, TypedDict, List from recommendations.optimiser.CostOptimiser import CostOptimiser from recommendations.optimiser.GainOptimiser import GainOptimiser @@ -41,9 +41,10 @@ class StrategicOptimiser: def __init__( self, - components: Sequence[Sequence[Measure]], + 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: @@ -57,6 +58,7 @@ class StrategicOptimiser: 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 @@ -128,7 +130,7 @@ class StrategicOptimiser: self.components, min_gain=self.target_gain, verbose=self.verbose, - allow_slack=False + allow_slack=self.allow_slack ) opt.setup() @@ -147,7 +149,7 @@ class StrategicOptimiser: self.components, max_cost=self.budget, max_gain=None, - allow_slack=False, + allow_slack=self.allow_slack, verbose=self.verbose ) diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index aaf97226..80ba02fd 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 @@ -1118,22 +1119,17 @@ def run_optimizer( if not input_measures: return None, 0.0, 0.0 - if budget is not None: + opt = StrategicOptimiser( + components=input_measures, + budget=budget, + target_gain=sub_target_gain, + allow_slack=allow_slack, + verbose=False, + ) - 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.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 ---------------------------------------------------------- From f4db5389f5f226e5610fbd91c53596f3a8944984 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Feb 2026 12:30:20 +0000 Subject: [PATCH 3/8] getting rid of test code --- backend/engine/engine.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index f8b25352..80d6d078 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -1118,28 +1118,6 @@ async def model_engine(body: PlanTriggerRequest): # When the goal is Increasing EPC, we can run the funding optimiser if body.goal == "Increasing EPC": - solutions_no_budget = optimise_with_scenarios( - p=p, - input_measures=input_measures, - budget=None, - target_gain=gain, - enforce_heat_pump_insulation=True, - enforce_fabric_first=body.enforce_fabric_first, - already_installed_sap=already_installed_sap, # To be passed to output - ) - solutions_no_budget["total_cost"] - - solutions_with_budget = optimise_with_scenarios( - p=p, - input_measures=input_measures, - budget=5000, - target_gain=gain, - enforce_heat_pump_insulation=True, - enforce_fabric_first=body.enforce_fabric_first, - already_installed_sap=already_installed_sap, # To be passed to output - ) - solutions_with_budget["total_cost"] - solutions = optimise_with_scenarios( p=p, input_measures=input_measures, From ecaf742a18b07cab6899988a22cbef4e1a437bfe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Feb 2026 12:38:45 +0000 Subject: [PATCH 4/8] added catch if budget is not set --- recommendations/optimiser/funding_optimiser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index 80ba02fd..787af8e0 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -714,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, From e0f897bf4466bb476b99cef34972c17eefae4be8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Feb 2026 12:46:25 +0000 Subject: [PATCH 5/8] minor stying and typing --- recommendations/optimiser/CostOptimiser.py | 7 ++++++- recommendations/optimiser/GainOptimiser.py | 10 +++++++++- recommendations/optimiser/StrategicOptimiser.py | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/recommendations/optimiser/CostOptimiser.py b/recommendations/optimiser/CostOptimiser.py index 32a869b2..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() @@ -13,7 +14,11 @@ class CostOptimiser: BUFFER = 0.2 def __init__( - self, components, min_gain, verbose=False, allow_slack=True + 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 diff --git a/recommendations/optimiser/GainOptimiser.py b/recommendations/optimiser/GainOptimiser.py index 5dbf1dc5..94e022da 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, + 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 diff --git a/recommendations/optimiser/StrategicOptimiser.py b/recommendations/optimiser/StrategicOptimiser.py index 8ffc307c..69de4085 100644 --- a/recommendations/optimiser/StrategicOptimiser.py +++ b/recommendations/optimiser/StrategicOptimiser.py @@ -85,7 +85,7 @@ class StrategicOptimiser: # min cost # subject to # - # gain >= 𝐺 + # gain >= G # cost <= B # multiple-choice constraints # From ffbfe4992aea2c1a6a7bebb18c197f47c2a57f81 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Feb 2026 17:17:52 +0000 Subject: [PATCH 6/8] fixed tests --- recommendations/optimiser/GainOptimiser.py | 2 +- recommendations/tests/test_optimisers.py | 242 ++++++++++----------- 2 files changed, 113 insertions(+), 131 deletions(-) diff --git a/recommendations/optimiser/GainOptimiser.py b/recommendations/optimiser/GainOptimiser.py index 94e022da..9c291313 100644 --- a/recommendations/optimiser/GainOptimiser.py +++ b/recommendations/optimiser/GainOptimiser.py @@ -14,7 +14,7 @@ class GainOptimiser: self, components: list[list[Mapping[str, int | float | str]]], max_cost: float | int, - max_gain: float | int, + max_gain: float | int | None, allow_slack: bool = True, verbose: bool = False ): diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py index 0c794119..5a4df160 100644 --- a/recommendations/tests/test_optimisers.py +++ b/recommendations/tests/test_optimisers.py @@ -1,76 +1,34 @@ import pytest -from recommendations.optimiser.funding_optimiser import build_heat_pump_paths -from recommendations.optimiser.funding_optimiser import run_optimizer +from recommendations.optimiser.funding_optimiser import ( + build_heat_pump_paths, + 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() - +# --------------------------------------------------------------------- +# Heat pump path tests (unchanged – these are fine) +# --------------------------------------------------------------------- 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']}, + ] +# --------------------------------------------------------------------- +# run_optimizer tests +# --------------------------------------------------------------------- + def test_run_optimizer_empty_input(): solution, cost, gain = run_optimizer([]) assert solution is None @@ -78,134 +36,158 @@ def test_run_optimizer_empty_input(): assert gain == 0.0 -def test_uses_gain_optimiser_when_budget_provided(monkeypatch): - captured_args = {} +# --------------------------------------------------------------------- +# StrategicOptimiser mocking boundary +# --------------------------------------------------------------------- - 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}] +def test_budget_and_target_are_passed_correctly(monkeypatch): + captured = {} + + 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}] From 378eb055089e4a88b6c2b0ae6f4746cd9f4069e3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Feb 2026 17:18:29 +0000 Subject: [PATCH 7/8] aesthetics --- recommendations/tests/test_optimisers.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py index 5a4df160..63280907 100644 --- a/recommendations/tests/test_optimisers.py +++ b/recommendations/tests/test_optimisers.py @@ -6,10 +6,6 @@ from recommendations.optimiser.funding_optimiser import ( ) -# --------------------------------------------------------------------- -# Heat pump path tests (unchanged – these are fine) -# --------------------------------------------------------------------- - def test_build_heat_pump_paths(): eg1 = build_heat_pump_paths([], ["loft_insulation"]) assert eg1 == [{'AND': ['loft_insulation', 'air_source_heat_pump']}] @@ -25,10 +21,6 @@ def test_build_heat_pump_paths(): ] -# --------------------------------------------------------------------- -# run_optimizer tests -# --------------------------------------------------------------------- - def test_run_optimizer_empty_input(): solution, cost, gain = run_optimizer([]) assert solution is None @@ -36,10 +28,6 @@ def test_run_optimizer_empty_input(): assert gain == 0.0 -# --------------------------------------------------------------------- -# StrategicOptimiser mocking boundary -# --------------------------------------------------------------------- - def test_budget_and_target_are_passed_correctly(monkeypatch): captured = {} From 0e10923353e1f9175bda5b9c9833d99a722bb727 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Feb 2026 17:39:40 +0000 Subject: [PATCH 8/8] addressing feedback --- recommendations/optimiser/GainOptimiser.py | 3 - .../optimiser/funding_optimiser.py | 10 +- .../tests/test_optimiser_functions.py | 525 +----------------- 3 files changed, 19 insertions(+), 519 deletions(-) diff --git a/recommendations/optimiser/GainOptimiser.py b/recommendations/optimiser/GainOptimiser.py index 9c291313..bd907b4d 100644 --- a/recommendations/optimiser/GainOptimiser.py +++ b/recommendations/optimiser/GainOptimiser.py @@ -94,9 +94,6 @@ class GainOptimiser: for group_vars in self.variables: self.m += xsum(var for var in group_vars) <= 1 - self.m.max_gap = 0 - self.m.integer_tol = 1e-9 - def setup_slack(self): # Remove the original cost constraint self.m.remove(self.cost_constraint) diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index 787af8e0..324e2c74 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -1114,8 +1114,14 @@ 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: diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index ca2a0dcb..f0ca6dac 100644 --- a/recommendations/tests/test_optimiser_functions.py +++ b/recommendations/tests/test_optimiser_functions.py @@ -291,7 +291,9 @@ class TestIncreasingEpcE2e: class TestStrategicOptimiser: - def test_budget_and_target_gain(self): + + @pytest.fixture + def components(self): components = [ [ {'id': '0_phase=0', 'cost': 819.0, 'gain': 5.6, 'type': 'loft_insulation', 'innovation_uplift': 0, @@ -419,6 +421,9 @@ class TestStrategicOptimiser: '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 @@ -436,134 +441,7 @@ class TestStrategicOptimiser: assert opt.solution_cost == 4398.75 assert opt.solution_gain == 12 - def test_budget_and_target_gain_2(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} - ] - ] + def test_budget_and_target_gain_expecting_case_1_solve_max_gain_under_budget_strategy(self, components): budget = 4000 target_gain = 11.5 @@ -582,134 +460,7 @@ class TestStrategicOptimiser: assert opt.solution_cost == 1477.0680000000002 assert opt.solution_gain == 10.8 - def test_just_gain(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} - ] - ] + def test_just_gain_expecting_case_3_solve_min_cost_for_target_strategy(self, components): budget = None target_gain = 11.5 @@ -726,134 +477,7 @@ class TestStrategicOptimiser: assert opt.solution_cost == 4398.75 assert opt.solution_gain == 12 - def test_just_gain2(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} - ] - ] + def test_just_gain_of_20_expecting_case_3_solve_min_cost_for_target_strategy(self, components): budget = None target_gain = 20 @@ -870,134 +494,7 @@ class TestStrategicOptimiser: assert opt.solution_cost == 5962.5 assert opt.solution_gain == 20.2 - def test_just_budget(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} - ] - ] + def test_just_budget_expecting_case_2_solve_max_gain_under_budget_strategy(self, components): budget = 10000 target_gain = None @@ -1009,7 +506,7 @@ class TestStrategicOptimiser: opt.solve() - # Should be case 3 - minimise cost for target gain + # 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