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