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, True ) # ---- 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_generic_eco4_funding_paths(p, input_measures, funding_paths, remaining_insulation_type): # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Solar PV with existing eligible heating system # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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 and _find_measure(input_measures, "solar_pv"): single_solar_template = [{"AND": ["solar_pv"], "reference": None}] # We now look to pair this with any lingering insulation measures solar_paths = [] for insulation_measure in remaining_insulation_type: new_solar_path = deepcopy(single_solar_template) new_solar_path[0]["AND"].append(insulation_measure) # Make a specific reference for this path new_solar_path[0]["reference"] = "solar_pv+" + insulation_measure + ":eco4" solar_paths.append(new_solar_path) if solar_paths: funding_paths.extend(solar_paths) else: # If we have no insulation measures, we just add the solar PV path funding_paths.append([{"AND": ["solar_pv"], "reference": "solar_pv:eco4"}]) # For each of these, because there is a heating measure begin implemented, we check for minimum insulation # requirements. # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Solar PV + Heating Upgrade combos # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # We don't include electric boilers as they are not eligible for ECO4 funding solar_heating_combos = [ ("high_heat_retention_storage_heater", "solar_pv+hhrsh:eco4"), ("air_source_heat_pump", "solar_pv+ashp:eco4"), ] if _find_measure(input_measures, "solar_pv"): for heat_type, ref in solar_heating_combos: if _find_measure(input_measures, heat_type): if remaining_insulation_type: for insulation_measure in remaining_insulation_type: funding_paths.append( [{"AND": ["solar_pv", heat_type, insulation_measure], "reference": f"{ref[:-5]}+{insulation_measure}:eco4"}] # keeps naming consistent ) else: funding_paths.append([{"AND": ["solar_pv", heat_type], "reference": ref}]) # We've actually covered all possible options where solar PV can be included in a funded package, so where # solar PV is not in a reference, we can exclude it # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Heating Upgrades # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Must have an existing eligible heating system measure_references = { "boiler_upgrade": "boiler_upgrade", "high_heat_retention_storage_heater": "hhrsh", "air_source_heat_pump": "ashp" } for heating_upgrade in ["boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump"]: if _find_measure(input_measures, heating_upgrade): if remaining_insulation_type: for insulation_measure in remaining_insulation_type: path = [ { "AND": [heating_upgrade, insulation_measure], "reference": f"{measure_references[heating_upgrade]}+{insulation_measure}:eco4" } ] funding_paths.append(path) else: funding_paths.append( [{"AND": [heating_upgrade], "reference": f"{measure_references[heating_upgrade]}:eco4"}] ) return funding_paths def _make_generic_gbis_funding_paths(input_gbis_measures, funding_paths): """ For GBIS, the packages are single insulation measure. We also have potential GBIS packages that allow heating controls as a secondary measure, however this is not currently implemented in the optimiser due to not being certain about the heating controls pre conditions :param input_gbis_measures: :param funding_paths: :return: """ gbis_funding_paths = [] for input_measure in input_gbis_measures: for measure in input_measure: # We create a path for each measure gbis_funding_paths.append([{"AND": [measure["type"]], "reference": measure["type"] + ":gbis"}]) return funding_paths + gbis_funding_paths def make_funding_paths(p, input_measures, tenure): """ This function generates funding paths based on the input measures and the tenure of the property. It checks for the presence of specific measures and creates paths that include necessary insulation measures to meet minimum insulation requirements, particularly when a heating system is recommended. Remaining measures that are not fixed as part of the package are then optimised :param p: The property object containing details about the property, including main heating and controls. :param input_measures: :param tenure: :return: """ # We handle the case of minimum insulation requirements. Whenever we have a heating system recommendation, # we *must* include an additional insulation measure, unless the property already has sufficient insulation. # We determine which insulation measures need to be included 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" ] other_gbis_insulation_measures = [ "suspended_floor_insulation", "solid_floor_insulation", ] # These are the insulation measures that the property still needs and so will be considered for # filling the minimum insulation requirements remaining_insulation_type = [] for insulation_measure in wall_insulation_measures + roof_insulation_measures: if _find_measure(input_measures, insulation_measure): remaining_insulation_type.append(insulation_measure) remaining_insulation_type = list(set(remaining_insulation_type)) funding_paths = [] if tenure == "Social" and p.data["current-energy-rating"] == "D": # If the property is currently EPC D, we can only include innovation measures or measures to meet the # minimum insulation requirements input_measures_innovation = [] input_gbis_measures_innovation = [] for measures in input_measures: for measure in measures: if measure["innovation_uplift"] or measure["type"] in remaining_insulation_type: input_measures_innovation.append([measure]) if measure["innovation_uplift"] and measure["type"] in ( remaining_insulation_type + other_gbis_insulation_measures ): input_gbis_measures_innovation.append([measure]) funding_paths = _make_generic_eco4_funding_paths( p, input_measures_innovation, funding_paths, remaining_insulation_type ) # Can only be innovation GBIS measures funding_paths = _make_generic_gbis_funding_paths(input_gbis_measures_innovation, funding_paths) return funding_paths if tenure == "Private": # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # EWI or IWI # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # 1) The package must include EWI or IWI if the property is private rental sector # We check if we have any EWI or IWI measures available ewi_or_iwi = [{"OR": []}] reference_measures = [] # 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") reference_measures.append("ewi") if _find_measure(input_measures, "internal_wall_insulation"): ewi_or_iwi[0]["OR"].append("internal_wall_insulation") reference_measures.append("iwi") if ewi_or_iwi[0]["OR"]: ewi_or_iwi[0]["reference"] = "+".join(reference_measures) + ":eco4" funding_paths.append(ewi_or_iwi) funding_paths = _make_generic_eco4_funding_paths( p, input_measures, funding_paths, remaining_insulation_type ) # If we have any remaining insulation measures, we add them to the funding paths input_gbis_measures = [] for measures in input_measures: for measure in measures: if measure["type"] in remaining_insulation_type + other_gbis_insulation_measures: input_gbis_measures.append([measure]) funding_paths = _make_generic_gbis_funding_paths(input_gbis_measures, funding_paths) return funding_paths # ---- main wrapper around your optimiser ---------------------------------- # Run inputs: target_gain = 18.5 from itertools import product import math def violates_min_insulation(fixed): """Return True if fixed selection includes a heating/PV measure but no required insulation.""" picked_types = {opt["type"] for (_, _, opt) in fixed} def has_any(substrs): return any(any(s in t for s in substrs) for t in picked_types) # heating (incl. PV) flags is_heating = has_any([ "air_source_heat_pump", "high_heat_retention_storage_heater", "boiler_upgrade", "electric_boiler", "time_temperature_zone_control", "secondary_heating", "solar_pv", # PV treated as heating for MIR ]) # MIR insulation (the ones you’re using in path construction) has_insul = has_any([ "external_wall_insulation", "internal_wall_insulation", "cavity_wall_insulation", "extension_cavity_wall_insulation", "loft_insulation", "flat_roof_insulation", "room_roof_insulation", ]) return is_heating and not has_insul 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) """ funding_paths = make_funding_paths(p, input_measures, body.housing_type) solutions = [] for path_spec in funding_paths: # TODO: If the path spec is GBIS, need to handle this differently. There is no funding associated # with the other measures we're optimising. Instead, we fix the GBIS measure (which is funded) # and then run the optimiser on the remaining measures which are NOT funded. The key change is all # measures in input_measures right now have costs adjusted with innovation uplift, which we don't want # to apply to the GBIS measures. So we need to strip the innovation uplift from the GBIS measures # 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: if violates_min_insulation(fixed): # We log an error and skip this - we should not see any errors but we can probably get a reasonable # outcome for the end user without a complete termination of the process logger.error("Skipping fixed selection due to minimum insulation violation: %s", fixed) continue # 3) compute fixed cost/gain, and strip those groups from subproblem fixed_items = [opt for (_, _, opt) in fixed] fixed_ids = [opt['id'] for opt in fixed_items] fixed_cost, fixed_gain = sum_cost_gain(fixed_items) fixed_groups = {gi for (gi, _, _) in fixed} sub_measures = [grp for gi, grp in enumerate(input_measures) if gi not in 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 # ---- helpers ------------------------------------------------------------- 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 type_matches(option_type: str, required: str) -> bool: # substring match so "external_wall_insulation+mechanical_ventilation" satisfies "external_wall_insulation" return required in option_type def candidates_for_type(input_measures, required_type): """ Return a list of (gi, oi, opt) where opt['type'] contains required_type. gi = group index, oi = option index inside that group. """ cands = [] for gi, group in enumerate(input_measures): for oi, opt in enumerate(group): if type_matches(opt["type"], required_type): cands.append((gi, oi, opt)) return cands def iter_or_candidates(input_measures, types_list): """ For OR: pick exactly ONE candidate whose type matches ANY in types_list. Return a list of dicts: {"fixed": [(gi, oi, opt)]} """ union = [] seen_ids = set() for t in types_list: for tup in candidates_for_type(input_measures, t): # de-dupe by the option id so the same physical option (with multi-type name) isn’t repeated if tup[2]["id"] not in seen_ids: seen_ids.add(tup[2]["id"]) union.append(tup) return [{"fixed": [t]} for t in union] def iter_and_candidates(input_measures, types_list): """ For AND: we must cover ALL required types. We allow a single option to satisfy multiple types. We build a simple product but collapse duplicates by (gi, oi). """ # Build candidate pools per required type pools = [candidates_for_type(input_measures, t) for t in types_list] if any(len(pool) == 0 for pool in pools): return [] # impossible to satisfy AND # Start with one empty selection; accumulate per pool selections = [[]] # each selection is a list of (gi, oi, opt) for pool in pools: new_selections = [] for sel in selections: for cand in pool: # Try adding cand; collapse duplicates by (gi,oi) gi, oi, opt = cand replaced = False conflict = False merged = [] for (sgi, soi, sopt) in sel: if (sgi, soi) == (gi, oi): # same exact option already in selection (satisfies another required type) – keep one replaced = True # keep the existing one (identical) merged.append((sgi, soi, sopt)) else: merged.append((sgi, soi, sopt)) if not replaced: merged.append(cand) if not conflict: new_selections.append(merged) selections = new_selections if not selections: return [] # After accumulation, we may still have duplicate groups with different options (conflict). Drop those. cleaned = [] for sel in selections: seen_by_group = {} ok = True for gi, oi, opt in sel: if gi in seen_by_group and seen_by_group[gi] != oi: # same group, different option -> conflict for AND; invalid selection ok = False break seen_by_group[gi] = oi if ok: # ensure stable order and unique by (gi,oi) uniq = {} for gi, oi, opt in sel: uniq[(gi, oi)] = opt cleaned.append([(gi, oi, opt) for (gi, oi), opt in uniq.items()]) return [{"fixed": c} for c in cleaned] def expand_funding_path(input_measures, path_spec): """ path_spec is a list of elements; each element is either: {"OR": [type1, type2, ...], "reference": "..."} or {"AND": [type1, type2, ...], "reference": "..."} We cross-product across elements (all required), and produce selections as lists of (gi, oi, opt). """ selections = [[]] # list[list[(gi,oi,opt)]] for elem in path_spec: if "OR" in elem: cands = iter_or_candidates(input_measures, elem["OR"]) elif "AND" in elem: cands = iter_and_candidates(input_measures, elem["AND"]) else: raise ValueError("unknown path element; expected 'OR' or 'AND'") if not cands: return [] new_selections = [] for base in selections: for cand in cands: # merge base + cand["fixed"], collapsing duplicate same-option picks combined = list(base) # reject if combined picks two different options from the same group groups_to_oi = {(gi,): oi for gi, oi, _ in combined} # temporary; we’ll refactor below conflict = False # simpler: build a dict by group -> (oi, opt), conflict if group exists with different oi gmap = {gi: (oi, opt) for gi, oi, opt in combined} for gi, oi, opt in cand["fixed"]: if gi in gmap: prev_oi, _ = gmap[gi] if prev_oi != oi: conflict = True break gmap[gi] = (oi, opt) if conflict: continue # back to list merged = [(gi, oi, opt) for gi, (oi, opt) in gmap.items()] new_selections.append(merged) selections = new_selections if not selections: return [] # Final tidy: ensure no duplicate groups with different options (already protected), keep stable ordering deduped = [] for sel in selections: gmap = {} for gi, oi, opt in sel: # keep the first occurrence if gi not in gmap: gmap[gi] = (oi, opt) else: # same group, different oi would have been filtered; if same oi, ignore duplicate pass deduped.append([(gi, oi, opt) for gi, (oi, opt) in gmap.items()]) return deduped # ---- 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