From f27447bed8c77b960bd8d989b945da1e61beac96 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 12 Aug 2025 17:44:19 +0100 Subject: [PATCH] working on defining the optimisation sub problems --- backend/Funding.py | 106 ++- backend/engine/engine.py | 10 + .../optimiser/optimiser_functions.py | 31 +- recommendations/tests/test_optimisers.py | 833 ++++++++++++++++++ 4 files changed, 954 insertions(+), 26 deletions(-) create mode 100644 recommendations/tests/test_optimisers.py diff --git a/backend/Funding.py b/backend/Funding.py index 59547058..d95117c5 100644 --- a/backend/Funding.py +++ b/backend/Funding.py @@ -3,7 +3,8 @@ from typing import List import pandas as pd from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes -from backend.app.plan.schemas import HousingType, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, MEASURE_MAP +from backend.app.plan.schemas import VALID_HOUSING_TYPES, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, \ + MEASURE_MAP class EligibilityCaveats(Enum): @@ -11,7 +12,7 @@ class EligibilityCaveats(Enum): TENANT_ON_BENEFITS_OR_LOW_INCOME = "tenant_on_benefits_or_low_income" INNOVATION_REQUIRED = "innovation_required" SOLAR_NEEDS_HEATING = "solar_needs_heating" - MINIMUM_INSULATION_PRECONgiDITIONS_NOT_MET = "minimum_insulation_preconditions_not_met" + MINIMUM_INSULATION_PRECONDITIONS_NOT_MET = "minimum_insulation_preconditions_not_met" class Funding: @@ -39,7 +40,7 @@ class Funding: partial_project_scores_matrix, whlg_eligible_postcodes ): - if tenure not in [HousingType.PRIVATE, HousingType.SOCIAL]: + if tenure not in VALID_HOUSING_TYPES: raise ValueError("Invalid tenure type. Must be 'Private' or 'Social'.") self.tenure = tenure self.eco4_social_cavity_abs_rate = eco4_social_cavity_abs_rate @@ -342,6 +343,8 @@ class Funding: starting_str = "2" elif closest_starting == 2.00: starting_str = "2.0" + elif closest_starting == 1.70: + starting_str = "1.7" else: starting_str = f"{closest_starting:.2f}" @@ -495,15 +498,11 @@ class Funding: def calculate_partial_project_abs( self, measure_type: str, - mainheating: dict, - main_fuel: dict, - mainheat_energy_eff: str, filtered_pps_matrix: pd.DataFrame, pre_heating_system: str, current_wall_uvalue: float = None, is_partial: bool = False, existing_li_thickness: float = None, - # is_roof_insulated: bool = False ): """ Calculate the partial project ABS score for a single measure. @@ -615,6 +614,18 @@ class Funding: return 0 + if measure_type == "time_temperature_zone_control": + pps = filtered_pps_matrix[ + filtered_pps_matrix["Measure_Type"] == "TTZC" + ] + if pre_heating_system in pps["Pre_Main_Heating_Source"].values: + pps = pps[pps["Pre_Main_Heating_Source"] == pre_heating_system] + if pps.shape[0] != 1: + raise ValueError("something went wrong, more than one pps for TTZC") + return pps.squeeze()["Cost Savings"] + # If we don't have a pre heating system, we assume the measure is not applicable + return 0 + raise ValueError(f"Invalid measure type for partial project ABS calculation: {measure_type}") # ----------------------- @@ -704,6 +715,28 @@ class Funding: - all other measures are insulation (can be non-innovation) """ + raise ValueError( + "THis isnt quite right. Band D homes must be pre-insulated OR it should include one of the" + ) + # The condition is: + # one of the following insulation measures must be installed as part of the + # same ECO4 project: + # o roof insulation (flat roof, pitched roof, room-in-roof) + # o exterior facing wall insulation (cavity wall, solid wall) + # o party cavity wall insulation + # or, + # • all measures listed above must already be installed + # + # All Band E, F and G homes receiving any heating measure and Band D homes + # receiving FTCH or a DHC must have all exterior facing cavity walls and loft + # (including rafters) / roof (including flat and pitched roof or room-in-roof) area + # insulated (except where insulation is not possible and exemptions are lodged, + # see 5.87). The insulation of these areas can be: + # • installed as part of the same ECO4 project, + # • pre-existing insulation, + # • subject to exemptions or + # • a combination of the above + if not (55 <= starting_sap <= 68): return True # Only EPC D requires innovation check @@ -802,9 +835,6 @@ class Funding: continue pps = self.calculate_partial_project_abs( measure_type=measure, - mainheating=mainheating, - main_fuel=main_fuel, - mainheat_energy_eff=mainheat_energy_eff, current_wall_uvalue=current_wall_uvalue, is_partial=is_partial, existing_li_thickness=existing_li_thickness, @@ -922,9 +952,6 @@ class Funding: if self.gbis_eligible: self.partial_project_abs = self.calculate_partial_project_abs( measure_type=measure_types[0], - mainheating=mainheating, - main_fuel=main_fuel, - mainheat_energy_eff=mainheat_energy_eff, current_wall_uvalue=current_wall_uvalue, is_partial=is_partial, existing_li_thickness=existing_li_thickness, @@ -971,9 +998,6 @@ class Funding: # Calculate the partial project score - this is dependent on the measure self.partial_project_abs = self.calculate_partial_project_abs( measure_type=measure_types[0], - mainheating=mainheating, - main_fuel=main_fuel, - mainheat_energy_eff=mainheat_energy_eff, current_wall_uvalue=current_wall_uvalue, is_partial=is_partial, existing_li_thickness=existing_li_thickness, @@ -987,3 +1011,53 @@ class Funding: else: raise NotImplementedError("Only 'Private' and 'Social' tenures are supported.") + + def get_innovation_uplift( + self, measure, starting_sap, floor_area, current_wall_uvalue, mainheating, main_fuel, mainheat_energy_eff, + is_partial, is_cavity, existing_li_thickness=None + ): + """ + Helper function to calculate the innovation uplift for a measure based on the PPS + :param measure: + :param current_wall_uvalue: + :return: + """ + + self.starting_sap_band = self.get_sap_band(starting_sap) + self.floor_area_band = self.get_floor_area_band(floor_area) + + filtered_pps_matrix = self.partial_project_scores_matrix[ + (self.partial_project_scores_matrix["Total Floor Area Band"] == self.floor_area_band) & + (self.partial_project_scores_matrix["Starting Band"] == self.starting_sap_band) + ].copy() + + pre_heating_system = self._map_to_pre_main_heating(mainheating, main_fuel, mainheat_energy_eff) + + measure_type = measure["measure_type"] + + pps = self.calculate_partial_project_abs( + measure_type=measure_type, + current_wall_uvalue=current_wall_uvalue, + is_partial=is_partial, + existing_li_thickness=existing_li_thickness, + filtered_pps_matrix=filtered_pps_matrix, + pre_heating_system=pre_heating_system + ) + + innovation_uplift = pps * measure["uplift"] + + if self.tenure == "Private": + # We return ECO4 rates + return innovation_uplift * ( + self.eco4_private_cavity_abs_rate if is_cavity + else self.eco4_private_solid_abs_rate + ) + + if self.tenure == "Social": + # We return ECO4 rates + return innovation_uplift * ( + self.eco4_social_cavity_abs_rate if is_cavity + else self.eco4_social_solid_abs_rate + ) + + raise ValueError("Invalid tenure type for innovation uplift calculation: {}".format(self.tenure)) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 032ca0b0..7bc083d8 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -818,6 +818,16 @@ async def model_engine(body: PlanTriggerRequest): ) continue + # We layer funding on top of the recommendations + # We take one of these options + funding_paths = [ + [["internal_wall_insulation", "external_wall_insulation"]], + ["air_source_heat_pump"], + # We must have both of these options (though we check if the property doesn't already have HHRSH and + # is recommended it + [["solar_pv"], ["high_heat_retention_storage_heaters"]] + ] + fixed_gain = optimiser_functions.calculate_fixed_gain( property_required_measures, recommendations, p, needs_ventilation ) diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index 94190bdd..a4f32c41 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -6,7 +6,7 @@ from backend.app.utils import epc_to_sap_lower_bound from recommendations.optimiser.CostOptimiser import CostOptimiser -def prepare_input_measures(property_recommendations, goal, needs_ventilation): +def prepare_input_measures(property_recommendations, goal, needs_ventilation, funding=False): """ Prepares a nested list of measure options for optimisation. @@ -34,6 +34,9 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation): Optimisation goal, one of: "Increasing EPC", "Energy Savings", "Reducing CO2 emissions". needs_ventilation : bool Whether the property requires mechanical ventilation to accompany certain measures. + funding: bool, optional + If true, the function will include the innovation uplift in the total cost calculation. If false, this is + excluded, since innovation uplift cannot be claimed where funding is not available. Returns ------- @@ -75,20 +78,28 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation): # Build enriched measure data to_append = [] for rec in recs: - total = ( - rec["total"] + ventilation_recommendation["total"] - if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation - else rec["total"] - ) + if funding: + total = ( + rec["total"] - rec["innovation_uplift"] + ventilation_recommendation["total"] + if rec["measure_type"] in assumptions.measures_needing_ventilation and needs_ventilation + else rec["total"] - rec["innovation_uplift"] + ) + else: + total = ( + rec["total"] + ventilation_recommendation["total"] + if rec["measure_type"] in assumptions.measures_needing_ventilation and needs_ventilation + else rec["total"] + ) + total = 0 if total < 0 else total gain = ( rec[goal_key] + ventilation_recommendation[goal_key] - if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation + if rec["measure_type"] in assumptions.measures_needing_ventilation and needs_ventilation else rec[goal_key] ) rec_type = ( - f"{rec['type']}+{ventilation_recommendation['type']}" - if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation - else rec["type"] + f"{rec['measure_type']}+{ventilation_recommendation['measure_type']}" + if rec["measure_type"] in assumptions.measures_needing_ventilation and needs_ventilation + else rec["measure_type"] ) to_append.append( diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py new file mode 100644 index 00000000..762ab71e --- /dev/null +++ b/recommendations/tests/test_optimisers.py @@ -0,0 +1,833 @@ +import numpy as np +import pandas as pd +from pandas import Timestamp +from numpy import nan +import datetime +from copy import deepcopy + +from recommendations.optimiser.CostOptimiser import CostOptimiser +from recommendations.optimiser.GainOptimiser import GainOptimiser +from backend.Funding import Funding + +project_scores_matrix = pd.read_csv("/Users/khalimconn-kowlessar/Downloads/ECO4 Full Project Scores Matrix.csv") +partial_project_scores_matrix = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv") +partial_project_scores_matrix.columns = ['Measure category', 'Measure_Type', 'Pre_Main_Heating_Source', + 'Post_Main_Heating_Source', 'Total Floor Area Band', 'Starting Band', + 'Average Treatable Factor', 'Cost Savings', 'SAP Savings'] +whlg_eligible_postcodes = pd.DataFrame([{"Postcode": "ab12cd"}]) + +funding = Funding( + project_scores_matrix=project_scores_matrix, + partial_project_scores_matrix=partial_project_scores_matrix, + whlg_eligible_postcodes=whlg_eligible_postcodes, + eco4_social_cavity_abs_rate=13.5, + eco4_social_solid_abs_rate=17, + eco4_private_cavity_abs_rate=13.5, + eco4_private_solid_abs_rate=17, + gbis_social_cavity_abs_rate=21, + gbis_social_solid_abs_rate=25, + gbis_private_cavity_abs_rate=22, + gbis_private_solid_abs_rate=28, + tenure="Social" +) + +# Assume these costs have been adjusted +property_recommendations = [ + [{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation', + 'description': 'EWI Pro EPS external wall insulation system with ' + 'Brick Slip finish', + 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, + 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'SCIS', + 'created_at': Timestamp('2025-03-16 15:26:22.379496'), + 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, + 'total_cost': 298.35, + 'notes': 'This is the quoted value from SCIS', + 'is_installer_quote': True, 'quantity': 63.98796761892035, + 'quantity_unit': 'm2', 'total': 19090.810139104888, + 'labour_hours': 0.0, 'labour_days': 0.0}], + 'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation', + 'description': 'Install 150mm EWI Pro EPS external wall insulation system with Brick ' + 'Slip finish on external walls', + 'starting_u_value': 1.7, 'new_u_value': 0.32, 'already_installed': False, + 'sap_points': np.float64(9.6), + 'simulation_config': {'is_as_built_ending': False, 'walls_is_assumed_ending': False, + 'walls_insulation_thickness_ending': 'average', + 'external_insulation_ending': True, + 'walls_energy_eff_ending': 'Good', + 'walls_thermal_transmittance_ending': 0.23}, + 'description_simulation': {'walls-description': 'Solid brick, with external insulation', + 'walls-energy-eff': 'Good'}, 'total': 19090.810139104888, + 'labour_hours': 0.0, 'labour_days': 0.0, 'survey': False, + 'recommendation_id': '0_phase=0', 'efficiency': 11229.568317120522, + 'co2_equivalent_savings': np.float64(0.5), 'heat_demand': np.float64(37.099999999999994), + 'kwh_savings': np.float64(1827.8999999999996), + 'energy_cost_savings': np.float64(136.1247882352941)}, {'phase': 0, 'parts': [ + {'id': 2373, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish', + 'depth': 95.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032, + 'thermal_conductivity_unit': None, + 'link': 'SCIS', 'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1, + 'plant_cost': 0.0, 'total_cost': 89.0, 'notes': None, 'is_installer_quote': True, + 'quantity': 63.98796761892035, + 'quantity_unit': 'm2', 'total': 5694.929118083911, 'labour_hours': 134.37473199973275, + 'labour_days': 4.199210374991648}], 'type': 'internal_wall_insulation', + 'measure_type': 'internal_wall_insulation', + 'description': 'Install 95mm ' + 'SWIP EcoBatt & ' + 'Plastered ' + 'finish on ' + 'internal walls', + 'starting_u_value': 1.7, + 'new_u_value': 0.32, + 'already_installed': False, + 'sap_points': 6, + 'simulation_config': { + 'is_as_built_ending': False, + 'walls_is_assumed_ending': + False, + 'walls_insulation_thickness_ending': 'average', + 'internal_insulation_ending': True, + 'walls_energy_eff_ending': + 'Good', + 'walls_thermal_transmittance_ending': 0.29}, + 'description_simulation': { + 'walls-description': 'Solid ' + 'brick, with internal ' + 'insulation', + 'walls-energy-eff': 'Good'}, + 'total': 5694.929118083911, + 'labour_hours': 134.37473199973275, + 'labour_days': 4.199210374991648, + 'survey': True, + 'recommendation_id': '1_phase=0', + 'efficiency': 3349.6383047552417, + 'co2_equivalent_savings': np.float64( + 0.5), + 'heat_demand': np.float64( + 35.30000000000001), + 'kwh_savings': np.float64( + 1432.3999999999996), + 'energy_cost_savings': np.float64( + 106.67167058823532)}], [ + {'phase': 1, 'parts': [{'id': 2351, 'type': 'loft_insulation', + 'description': 'Knauf Loft Roll 44 glass fibre roll', + 'depth': 300.0, 'depth_unit': 'mm', 'cost': None, + 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'SCIS', + 'created_at': Timestamp('2025-03-16 15:26:22.379496'), + 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, + 'total_cost': 15.0, + 'notes': 'This is the cost if there is less than 100mm ' + 'existing insulation', + 'is_installer_quote': True, 'quantity': 63.98796761892035, + 'quantity_unit': 'm2', 'total': 645.0, 'labour_hours': 8, + 'labour_days': 1}], 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'description': 'Install 300mm of Knauf Loft Roll 44 glass fibre roll in your loft', + 'starting_u_value': 2.3, 'new_u_value': 2.3, 'sap_points': np.float64(2.4), + 'already_installed': False, + 'simulation_config': {'is_loft_ending': True, 'roof_is_assumed_ending': False, + 'roof_insulation_thickness_ending': '300', + 'roof_thermal_transmittance_ending': 2.3, + 'roof_energy_eff_ending': 'Very Good'}, + 'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation', + 'roof-energy-eff': 'Very Good'}, 'total': 645.0, + 'labour_hours': 8, 'labour_days': 1, 'survey': False, 'recommendation_id': '2_phase=1', + 'efficiency': 278.1347826086957, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(1.5), 'kwh_savings': np.float64(566.1499999999996), + 'energy_cost_savings': np.float64(42.16152352941185)}], [{'phase': 2, 'parts': [ + {'id': 2329, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, + 'link': 'SCIS', 'created_at': datetime.datetime(2025, 3, 16, 15, 26, 22, 379496), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 350.0, 'notes': None, 'is_installer_quote': True, 'total': 700.0, + 'quantity': 2, + 'quantity_unit': 'part'}], 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', + 'description': 'Install 2 ' + 'Mechanical ' + 'Extract ' + 'Ventilation units', + 'starting_u_value': None, + 'new_u_value': None, + 'already_installed': False, + 'sap_points': np.float64( + -0.10000000000000142), + 'heat_demand': np.float64( + -3.3999999999999773), + 'kwh_savings': np.float64( + -53.80000000000018), + 'co2_equivalent_savings': np.float64( + 0.0), + 'energy_cost_savings': np.float64( + -4.0065176470588995), + 'total': 700.0, + 'labour_hours': 8, + 'labour_days': 1.0, + 'simulation_config': { + 'mechanical_ventilation_ending': 'mechanical, ' + 'extract only'}, + 'description_simulation': { + 'mechanical-ventilation': 'mechanical, ' + 'extract only'}, + 'recommendation_id': '3_phase=2', + 'efficiency': 0}], [ + {'phase': 3, 'parts': [{'id': 2409, 'type': 'suspended_floor_insulation', + 'description': 'Q-bot underfloor insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', + 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'SCIS', + 'created_at': Timestamp('2025-03-16 15:26:22.379496'), + 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, + 'total_cost': 93.75, + 'notes': 'Linearly interpolated based on Qbot costs', + 'is_installer_quote': True, 'quantity': 43.0, + 'quantity_unit': 'm2', 'total': 4031.25, + 'labour_hours': 70.08999999999999, + 'labour_days': 2.920416666666666}], + 'type': 'suspended_floor_insulation', 'measure_type': 'suspended_floor_insulation', + 'description': 'Install 75mm Q-bot underfloor insulation insulation in suspended ' + 'floor', + 'starting_u_value': 0.83, 'new_u_value': 0.22, 'sap_points': 2, 'survey': True, + 'already_installed': False, 'simulation_config': {'floor_is_assumed_ending': False, + 'floor_insulation_thickness_ending': 'average', + 'floor_thermal_transmittance_ending': 0.685593}, + 'description_simulation': {'floor-description': 'Suspended, insulated'}, + 'total': 4031.25, 'labour_hours': 70.08999999999999, 'labour_days': 2.920416666666666, + 'recommendation_id': '4_phase=3', 'efficiency': 4856.707710843373, + 'co2_equivalent_savings': np.float64(0.20000000000000018), + 'heat_demand': np.float64(33.5), 'kwh_savings': np.float64(1021.1999999999998), + 'energy_cost_savings': np.float64(76.04936470588231)}], [ + {'phase': 4, 'parts': [], 'type': 'low_energy_lighting', + 'measure_type': 'low_energy_lighting', + 'description': 'Install low energy lighting in -886 outlets', 'starting_u_value': None, + 'new_u_value': None, 'already_installed': False, 'sap_points': 2, + 'kwh_savings': -48508.5, 'energy_cost_savings': -12481.237049999998, + 'co2_equivalent_savings': -7.858377, + 'description_simulation': {'lighting-energy-eff': 'Very Good', + 'lighting-description': 'Low energy lighting in all fixed' + ' outlets', + 'low-energy-lighting': 100}, 'total': -3411.1000000000004, + 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'recommendation_id': '5_phase=4', 'efficiency': -1705.5500000000002, + 'heat_demand': np.float64(5.099999999999994)}], [ + {'type': 'heating', 'phase': 5, 'measure_type': 'time_temperature_zone_control', + 'parts': [], + 'description': 'Upgrade heating controls to Smart Thermostats, room sensors and ' + 'smart radiator valves (time & temperature zone control)', + 'total': 739.576, 'subtotal': 700.48, 'vat': 39.096000000000004, + 'labour_hours': 3.6199999999999997, 'labour_days': np.float64(1.0), + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(2.9), + 'already_installed': False, 'simulation_config': { + 'thermostatic_control_ending': 'time and temperature zone control', + 'switch_system_ending': None, 'trvs_ending': None, + 'mainheatc_energy_eff_ending': 'Very Good'}, 'description_simulation': { + 'mainheatcont-description': 'Time and temperature zone control', + 'mainheatc-energy-eff': 'Very Good'}, 'recommendation_id': '6_phase=5', + 'efficiency': 739.576, 'co2_equivalent_savings': np.float64(0.30000000000000027), + 'heat_demand': np.float64(6.599999999999994), + 'kwh_savings': np.float64(876.8000000000002), + 'energy_cost_savings': np.float64(65.29581176470589)}], [ + {'phase': 6, 'parts': [], 'type': 'secondary_heating', + 'measure_type': 'secondary_heating', + 'description': 'Remove the secondary heating system', 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(3.6), 'already_installed': False, + 'total': 30.0, 'subtotal': 25.0, 'vat': 5.0, 'labour_hours': 3.0, + 'labour_days': np.float64(1.0), + 'simulation_config': {'secondheat_description_ending': 'None'}, + 'description_simulation': {'secondheat-description': 'None'}, + 'recommendation_id': '7_phase=6', 'efficiency': 30.0, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(15.400000000000006), + 'kwh_savings': np.float64(196.29999999999927), + 'energy_cost_savings': np.float64(14.61857647058821)}], [ + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), + 'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0), + 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996), + 'description_simulation': {'photo-supply': np.float64(65.0)}, + 'recommendation_id': '8_phase=7', 'efficiency': np.float64(462.54923076923075), + 'co2_equivalent_savings': np.float64(0.47347873833399995), + 'heat_demand': np.float64(88.69999999999999), + 'kwh_savings': np.float64(2040.8566307499998), + 'energy_cost_savings': np.float64(525.1124110919749)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), + 'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0), + 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996), + 'description_simulation': {'photo-supply': np.float64(65.0)}, + 'recommendation_id': '9_phase=7', 'efficiency': np.float64(810.5390769230769), + 'co2_equivalent_savings': np.float64(0.6628702336675999), + 'heat_demand': np.float64(88.69999999999999), + 'kwh_savings': np.float64(2857.1992830499994), + 'energy_cost_savings': np.float64(735.1573755287648)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), + 'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0), + 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3692.66794), + 'description_simulation': {'photo-supply': np.float64(60.0)}, + 'recommendation_id': '10_phase=7', 'efficiency': np.float64(485.54099999999994), + 'co2_equivalent_savings': np.float64(0.42834948104), + 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397), + 'energy_cost_savings': np.float64(475.0617304809999)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), + 'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0), + 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3692.66794), + 'description_simulation': {'photo-supply': np.float64(60.0)}, + 'recommendation_id': '11_phase=7', 'efficiency': np.float64(862.5299999999999), + 'co2_equivalent_savings': np.float64(0.599689273456), + 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558), + 'energy_cost_savings': np.float64(665.0864226734)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), + 'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0), + 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3300.5416548), + 'description_simulation': {'photo-supply': np.float64(55.0)}, + 'recommendation_id': '12_phase=7', 'efficiency': np.float64(512.964), + 'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3), + 'kwh_savings': np.float64(1650.2708274), + 'energy_cost_savings': np.float64(424.61468389001993)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), + 'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0), + 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3300.5416548), + 'description_simulation': {'photo-supply': np.float64(55.0)}, + 'recommendation_id': '13_phase=7', 'efficiency': np.float64(924.2247272727273), + 'co2_equivalent_savings': np.float64(0.53600796473952), + 'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996), + 'energy_cost_savings': np.float64(594.4605574460278)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), + 'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0), + 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2907.1867812), + 'description_simulation': {'photo-supply': np.float64(45.0)}, + 'recommendation_id': '14_phase=7', 'efficiency': np.float64(606.5253333333333), + 'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0), + 'kwh_savings': np.float64(1453.5933906), + 'energy_cost_savings': np.float64(374.00957940138)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), + 'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0), + 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2907.1867812), + 'description_simulation': {'photo-supply': np.float64(45.0)}, + 'recommendation_id': '15_phase=7', 'efficiency': np.float64(1109.1773333333333), + 'co2_equivalent_savings': np.float64(0.47212713326688), + 'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684), + 'energy_cost_savings': np.float64(523.6134111619319)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), + 'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0), + 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2510.25188), + 'description_simulation': {'photo-supply': np.float64(40.0)}, + 'recommendation_id': '16_phase=7', 'efficiency': np.float64(659.3565), + 'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3), + 'kwh_savings': np.float64(1255.12594), + 'energy_cost_savings': np.float64(322.94390436199996)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), + 'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0), + 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2510.25188), + 'description_simulation': {'photo-supply': np.float64(40.0)}, + 'recommendation_id': '17_phase=7', 'efficiency': np.float64(1224.84), + 'co2_equivalent_savings': np.float64(0.40766490531199995), + 'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998), + 'energy_cost_savings': np.float64(452.1214661067999)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), + 'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0), + 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2096.682636), + 'description_simulation': {'photo-supply': np.float64(35.0)}, + 'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856), + 'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5), + 'kwh_savings': np.float64(1048.341318), + 'energy_cost_savings': np.float64(269.7382211214)}, + {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), + 'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0, + 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0), + 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2096.682636), + 'description_simulation': {'photo-supply': np.float64(35.0)}, + 'recommendation_id': '19_phase=7', 'efficiency': np.float64(1373.5491428571427), + 'co2_equivalent_savings': np.float64(0.3405012600864), 'heat_demand': np.float64(48.5), + 'kwh_savings': np.float64(1467.6778451999999), + 'energy_cost_savings': np.float64(377.6335095699599)}] +] + +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 +} + +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 +} + +# Insert the funding uplifts +for recs in property_recommendations: + for r in recs: + # Insert randomly + # Select one of 0, 0.25 or 0.45 + r["uplift"] = np.random.choice([0, 0.25, 0.45]) + +# We calculate the innovation uplift against each measure +for recs in property_recommendations: + for r in recs: + if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]: + r["innovation_uplift"] = 0 + continue + r["innovation_uplift"] = funding.get_innovation_uplift( + measure=r, + starting_sap=p.data["current-energy-efficiency"], + floor_area=p.floor_area, + is_cavity=False, + current_wall_uvalue=1.7, + is_partial=False, + existing_li_thickness=150, + mainheating=p.main_heating, + main_fuel=p.main_fuel, + mainheat_energy_eff=p.data["mainheat-energy-eff"], + ) + print(r["innovation_uplift"]) + +property_measure_types = {rec["type"] for recs in property_recommendations for rec in recs} +property_required_measures = [m for m in property_recommendations if m[0]["type"] in []] +measures_to_optimise = [m for m in property_recommendations if m[0]["type"] not in []] + +# If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore +# its inclusion +needs_ventilation = any( + x in property_measure_types for x in assumptions.measures_needing_ventilation +) and not p.has_ventilation + +input_measures = optimiser_functions.prepare_input_measures( + measures_to_optimise, "Increasing EPC", needs_ventilation +) + +# ---- rule definitions you can tweak ------------------------------------- + +HEATING_TYPES = {"air_source_heat_pump", "high_heat_retention_storage_heater", "solar_pv"} +MIN_INSULATION_OR = [{"loft_insulation"}, {"cavity_wall_insulation"}] # extend if needed + +# “Funding paths”: each is a list of elements; each element is: +# - {"OR": {"types": {..}}} means choose one option from any group whose type is in that set +# - {"AND": [{"types": {..}}, {"types": {..}}]} means choose one from each of those +FUNDING_PATHS = [ + # Path A: IWI OR EWI + [ + { + "OR": { + "types": {"internal_wall_insulation", "external_wall_insulation"} + } + } + ], + # Path B: Solar PV AND HHRSH + [{"AND": [{"types": {"solar_pv"}}, {"types": {"high_heat_retention_storage_heater"}}]}], + # Path C: ASHP alone (may still trigger min insulation rule below) + [{"OR": {"types": {"air_source_heat_pump"}}}], + # +] + + +def _find_measure(input_measures, measure_type): + for measures in input_measures: + for m in measures: + if measure_type in m["type"]: + return True + return False + + +def make_funding_paths(input_measures, tenure): + """ + Given the tenure of the property and the available measures, this function will construct the funding paths + :return: + """ + + funding_paths = [] + + if tenure == "Social": + raise NotImplementedError("Implement me!") + + if tenure == "Private": + # We cover off the main funding paths + # 1) The package must include EWI or IWI + # We check if we have any EWI or IWI measures available + ewi_or_iwi = [{"OR": []}] + # If we have EWI we add it in + if _find_measure(input_measures, "external_wall_insulation"): + ewi_or_iwi[0]["OR"].append("external_wall_insulation") + + if _find_measure(input_measures, "internal_wall_insulation"): + ewi_or_iwi[0]["OR"].append("internal_wall_insulation") + + if ewi_or_iwi[0]["OR"]: + funding_paths.append(ewi_or_iwi) + + # 2) The package must include a renewable heating system like an ASHP + ashp = [{"OR": []}] + if _find_measure(input_measures, "air_source_heat_pump"): + ashp[0]["OR"].append("air_source_heat_pump") + funding_paths.append(ashp) + + # 3) The package must have an existing eligible heating system. We test this with the funding checker + # If we have any remaining insulation measure to be applied to the property, we also need to include that in + # the package + single_solar_template = [{"OR": []}] + has_eligible_heating_system = funding.check_solar_eligible_heating_system( + mainheat_description=p.main_heating["clean_description"], + heating_control_description=p.main_heating_controls["clean_description"] + ) + + if has_eligible_heating_system: + single_solar_template[0]["OR"].append("solar_pv") + # We now look to pair this with any lingering insulation measures + wall_insulation_measures = [ + "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", + "extension_cavity_wall_insulation" + ] + roof_insulation_measures = [ + "loft_insulation", "flat_roof_insulation", "room_roof_insulation" + ] + # We search for these + solar_paths_with_insulation = [] + for insulation_measure in wall_insulation_measures + roof_insulation_measures: + if _find_measure(input_measures, insulation_measure): + new_solar_path = deepcopy(single_solar_template) + new_solar_path[0]["OR"].append(insulation_measure) + solar_paths_with_insulation.append(new_solar_path) + + if not solar_paths_with_insulation: + # If we have no insulation measures, we're good with just single solar + solar_paths_with_insulation.append(single_solar_template) + + funding_paths.extend(solar_paths_with_insulation) + + +# ---- main wrapper around your optimiser ---------------------------------- + +def optimise_with_funding_paths(input_measures, budget=None, target_gain=None, social=False): + """ + run_optimizer(sub_measures, budget, target_gain) -> (picked_options, sub_cost, sub_gain) + """ + # TODO: Should be EPC D only that we require only innovation measures + # Social housing: filter to innovation-only before doing anything else + # if social: + # filtered = [] + # for group in input_measures: + # opts = [o for o in group if o.get("is_innovation", False)] + # if opts: + # filtered.append(opts) + # input_measures = filtered + + # Always include a "no funding path" baseline (empty fixed) + all_paths = FUNDING_PATHS + [[]] + + solutions = [] + for path_spec in all_paths: + # 1) expand fixed selections for this path + fixed_selections = expand_funding_path(input_measures, path_spec) if path_spec else [[]] + if not fixed_selections: + continue + + for fixed in fixed_selections: + + # 2) min insulation if heating is already in fixed + fixed_variants = expand_min_insulation_if_needed(input_measures, fixed) + if not fixed_variants: + continue + + for fixed2 in fixed_variants: + # 3) compute fixed cost/gain, and strip those groups from subproblem + fixed_items = [opt for (_, _, opt) in fixed2] + fixed_ids = [opt['id'] for opt in fixed_items] + fixed_cost, fixed_gain = sum_cost_gain(fixed_items) + fixed_groups = {gi for (gi, _, _) in fixed2} + + sub_measures = strip_groups(input_measures, fixed_groups) + + # 4) run your existing optimiser for the remaining groups + # If we have a budget, we need to ensure the subproblem respects it so we remove the fixed cost (which + # may already be over budget) and the fixed gain (which may not be achievable) + picked, sub_cost, sub_gain = run_optimizer( + sub_measures, + budget - fixed_cost if budget is not None else None, + sub_target_gain=target_gain - fixed_gain if target_gain is not None else None + ) + + if picked is None: + continue + + total_cost = fixed_cost + sub_cost + total_gain = fixed_gain + sub_gain + total_picks = fixed_items + picked + + # you can change the objective here; I’ll use max gain under budget + if budget is not None and total_cost > budget + 1e-9: + continue + + solutions.append({ + "fixed_ids": fixed_ids, + "items": total_picks, + "total_cost": total_cost, + "total_gain": total_gain, + "path": path_spec, + }) + + solutions = pd.DataFrame(solutions) + + return solutions + + +# Run inputs: +target_gain = 18.5 + +from itertools import product +import math + + +# ---- helpers ------------------------------------------------------------- + +def split_types(t): + # supports "external_wall_insulation+mechanical_ventilation" + return set(part.strip() for part in str(t).split('+')) + + +def group_has_type(group, want): + # group is a list[option], all same 'type' pattern + return any(want in split_types(opt['type']) for opt in group) + + +def find_groups(input_measures, type_name): + return [(gi, g) for gi, g in enumerate(input_measures) if group_has_type(g, type_name)] + + +def strip_groups(input_measures, taken_group_indices): + return [g for gi, g in enumerate(input_measures) if gi not in taken_group_indices] + + +def sum_cost_gain(items): + c = sum(float(x['cost']) for x in items) + g = sum(float(x['gain']) for x in items) + return c, g + + +# ---- candidate expansion ------------------------------------------------- + +def iter_or_candidates(input_measures, type_set): + # collect all groups that match ANY type in type_set + matching = [(gi, group) for gi, group in enumerate(input_measures) + if any(group_has_type(group, t) for t in type_set)] + if not matching: + return # nothing to yield + # choose ONE option from ANY one of these groups + for gi, group in matching: + for oi, opt in enumerate(group): + yield {"fixed": [(gi, oi, opt)]} + + +def iter_and_candidates(input_measures, type_vec): + # type_vec is like [{"types": {"solar_pv"}}, {"types": {"high_heat_retention_storage_heater"}}] + per_leg = [] + for leg in type_vec: + leg_types = leg["types"] + leg_groups = [(gi, group) for gi, group in enumerate(input_measures) + if any(group_has_type(group, t) for t in leg_types)] + if not leg_groups: + return # this AND path isn’t available in this property; skip + # options for this leg: (gi, oi, opt) + options = [] + for gi, group in leg_groups: + for oi, opt in enumerate(group): + options.append((gi, oi, opt)) + per_leg.append(options) + for combo in product(*per_leg): + yield {"fixed": list(combo)} + + +def expand_funding_path(input_measures, path_spec): + # path_spec is a list of elements; combine all elements (they’re all required) + # Start with one empty selection; then cross-product accumulate + selections = [[]] + for elem in path_spec: + new_selections = [] + if "OR" in elem: + for cand in iter_or_candidates(input_measures, elem["OR"]["types"]): + for base in selections: + new_selections.append(base + cand["fixed"]) + elif "AND" in elem: + for cand in iter_and_candidates(input_measures, elem["AND"]): + for base in selections: + new_selections.append(base + cand["fixed"]) + else: + raise ValueError("unknown path element") + selections = new_selections + if not selections: + break + # selections are lists of (gi, oi, opt) + # dedupe by group index (if users set a weird path that hits same group twice) + deduped = [] + for sel in selections: + seen = set() + clean = [] + ok = True + for gi, oi, opt in sel: + if gi in seen: + ok = False + break + seen.add(gi) + clean.append((gi, oi, opt)) + if ok: + deduped.append(clean) + return deduped + + +# ---- minimum insulation handling ---------------------------------------- + +def expand_min_insulation_if_needed(input_measures, fixed_selection): + # If fixed contains any HEATING_TYPES, we must also include at least one of MIN_INSULATION_OR groups. + fixed_types = set() + fixed_group_idx = {gi for gi, _, _ in fixed_selection} + for _, _, opt in fixed_selection: + fixed_types |= split_types(opt['type']) + + if not (fixed_types & HEATING_TYPES): + # BUT: heating might later be picked by optimiser… If you want to be strict, + # you can also add a *feasibility check* after optimisation and reject combos + # that pick heating without min insulation. For now we enforce only when + # already in fixed set. + return [fixed_selection] + + # Build OR candidates for required insulation, but exclude groups already fixed + or_pool = [] + for alt in MIN_INSULATION_OR: + types = alt + matches = [] + for gi, group in enumerate(input_measures): + if gi in fixed_group_idx: + continue + if any(group_has_type(group, t) for t in types): + for oi, opt in enumerate(group): + matches.append((gi, oi, opt)) + if not matches: + # No feasible insulation to satisfy the rule -> invalidate this branch + return [] + or_pool.append(matches) + + # choose one from any of the alt sets (if you have more than one OR bucket, pick one from at least one; + # simplest: union first OR bucket only — or take the union and pick one) + # Here we’ll take the union across all buckets then pick exactly one. + union = {(gi, oi): (gi, oi, opt) + for matches in or_pool for (gi, oi, opt) in matches}.values() + + expanded = [] + for gi, oi, opt in union: + # avoid duplicating the same group as fixed + if gi in fixed_group_idx: + continue + expanded.append(fixed_selection + [(gi, oi, opt)]) + return expanded + + +from copy import deepcopy + + +# ---- tiny utilities ---------------------------------------------------------- + +def parse_types(t): + # e.g. "external_wall_insulation+mechanical_ventilation" -> {"external_wall_insulation","mechanical_ventilation"} + return set(map(str.strip, t.split("+"))) if isinstance(t, str) else set() + + +def includes_heating(opt_types): + return any(x in opt_types for x in { + "air_source_heat_pump", + "high_heat_retention_storage_heater", + "time_temperature_zone_control", # controls count as a heating measure in your pipeline + "solar_pv" # you treat PV as heating for funding logic + }) + + +def contributes_min_insulation(opt_types): + # MIR satisfiers you mentioned (extend as needed) + return any(x in opt_types for x in { + "external_wall_insulation", + "internal_wall_insulation", + "loft_insulation", + "cavity_wall_insulation", + }) + + +def run_optimizer(input_measures, budget=None, sub_target_gain=None, allow_slack=False): + """ + Thin wrapper over your optimisers. + Returns: list[dict] selected_options + """ + if budget is not None: + opt = GainOptimiser( + input_measures, max_cost=budget, max_gain=(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() + return opt.solution, opt.solution_cost, opt.solution_gain