diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index 5fd6e0ee..94190bdd 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -1,7 +1,7 @@ import pandas as pd import backend.app.assumptions as assumptions -from Property import Property -from app.plan.schemas import PlanTriggerRequest +from backend.Property import Property +from backend.app.plan.schemas import PlanTriggerRequest from backend.app.utils import epc_to_sap_lower_bound from recommendations.optimiser.CostOptimiser import CostOptimiser diff --git a/recommendations/tests/test_data/measures_to_optimise.py b/recommendations/tests/test_data/measures_to_optimise.py new file mode 100644 index 00000000..cefd36e4 --- /dev/null +++ b/recommendations/tests/test_data/measures_to_optimise.py @@ -0,0 +1,350 @@ +import datetime +import numpy as np +from numpy import nan +from pandas import Timestamp + +measures_to_optimise = [ + [{'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(1813.199999999999), + 'energy_cost_savings': np.float64(135.03007058823516)}, {'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(1424.699999999999), 'energy_cost_savings': np.float64( + 106.09824705882352)}], [{'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(572.5500000000002), + 'energy_cost_savings': np.float64(42.638135294117774)}], [{'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( + -45.899999999999636), + 'co2_equivalent_savings': np.float64( + 0.0), + 'energy_cost_savings': np.float64( + -3.4181999999999846), + '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(1018.0999999999995), + 'energy_cost_savings': np.float64(75.8185058823529)}], [ + {'phase': 4, 'parts': [], 'type': 'low_energy_lighting', + 'measure_type': 'low_energy_lighting', + 'description': 'Install low energy lighting in 14 outlets', 'starting_u_value': None, + 'new_u_value': None, 'already_installed': False, 'sap_points': 2, 'kwh_savings': 766.5, + 'energy_cost_savings': 197.22044999999997, + 'co2_equivalent_savings': np.float64(0.09999999999999964), + 'description_simulation': {'lighting-energy-eff': 'Very Good', + 'lighting-description': 'Low energy lighting in all fixed ' + 'outlets', + 'low-energy-lighting': 100}, 'total': 58.8, 'labour_hours': 1, + 'labour_days': 0.125, 'survey': True, 'recommendation_id': '5_phase=4', 'efficiency': 29.4, + '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(853.6999999999998), + 'energy_cost_savings': np.float64(63.57554117647055)}], [ + {'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(202.30000000000018), + 'energy_cost_savings': np.float64(15.065400000000011)}], [ + {'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)}] +] diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index 197b3263..b8146c6b 100644 --- a/recommendations/tests/test_optimiser_functions.py +++ b/recommendations/tests/test_optimiser_functions.py @@ -1,6 +1,10 @@ import pytest +import numpy as np from types import SimpleNamespace +from recommendations.tests.test_data.measures_to_optimise import measures_to_optimise from recommendations.optimiser import optimiser_functions +from recommendations.optimiser.GainOptimiser import GainOptimiser +from recommendations.optimiser.CostOptimiser import CostOptimiser class TestPrepareInputMeasures: @@ -133,3 +137,129 @@ class TestFlattenRecommendationsWithDefaults: assert all("default" in rec for rec in result) assert next(r for r in result if r["recommendation_id"] == "b")["default"] is True assert next(r for r in result if r["recommendation_id"] == "a")["default"] is False + + +class TestIncreasingEpcE2e: + """ + Test out the classic increasing EPC optimisation flow end-to-end. + We have a goal (Increasing EPC), no budget, and we expect the optimiser to choose + the best set of measures and include best-practice ventilation. + """ + + @pytest.fixture + def setup_case(self): + # ✅ Dummy property object + p = SimpleNamespace( + id="P1", + has_ventilation=False, + data={"current-energy-efficiency": "52"}, + ) + + # ✅ Dummy request body + body = SimpleNamespace( + goal="Increasing EPC", + goal_value="C", + optimise=True, + budget=None, + simulate_sap_10=False, + required_measures=[] + ) + + # ✅ Use your massive measures_to_optimise list + + recommendations = {"P1": measures_to_optimise} + + return p, body, recommendations + + def test_end_to_end_increasing_epc(self, setup_case, monkeypatch): + p, body, recommendations = setup_case + + # ✅ Patch assumptions to simplify behaviour + monkeypatch.setattr(optimiser_functions.assumptions, "measures_needing_ventilation", + ["external_wall_insulation", "internal_wall_insulation"]) + + # ✅ Patch CostOptimiser.calculate_sap_gain_with_slack so we don't need SAP logic + monkeypatch.setattr(CostOptimiser, "calculate_sap_gain_with_slack", staticmethod(lambda x: x)) + + # ✅ Patch epc_to_sap_lower_bound for a known SAP target + monkeypatch.setattr(optimiser_functions, "epc_to_sap_lower_bound", lambda goal: 69) # EPC C lower bound ~69 SAP + + # --------------------- + # RUN THE OPTIMISATION LOOP + # --------------------- + + property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} + property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] + measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures] + + # ventilation flag + needs_ventilation = any( + x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation + ) and not p.has_ventilation + + assert needs_ventilation + + input_measures = optimiser_functions.prepare_input_measures(measures_to_optimise, body.goal, needs_ventilation) + + assert input_measures, "Expected measures to optimise" + assert len(input_measures) == 7 + + fixed_gain = optimiser_functions.calculate_fixed_gain( + property_required_measures, recommendations, p, needs_ventilation + ) + assert fixed_gain == 0, "No required measures should mean fixed gain is 0" + + gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) + + assert gain == 18.5, "Expected gain to be calculated correctly based on fixed gain and SAP target" + + optimiser = ( + GainOptimiser( + input_measures, max_cost=body.budget, max_gain=gain, + allow_slack=body.goal == "Increasing EPC" + ) if body.budget else CostOptimiser(input_measures, min_gain=gain) + ) + optimiser.setup() + optimiser.solve() + solution = optimiser.solution + + # ✅ Validate solution makes sense + assert solution, "Optimiser should return a non-empty solution" + assert all("id" in m for m in solution) + assert any("solar_pv" in m["type"] for m in solution), "Expected solar PV to be included" + + # ✅ Collect selected measure IDs + selected = {r["id"] for r in solution} + + assert selected == {'8_phase=7', '5_phase=4', '7_phase=6'} + + # ✅ Add required measures (none here) + solution = optimiser_functions.add_required_measures( + property_id=p.id, property_required_measures=property_required_measures, + recommendations=recommendations, selected=selected, + ) + + assert solution == [ + {'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'}, + {'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'}, + {'id': '8_phase=7', 'cost': 6013.139999999999, 'gain': np.float64(13.0), 'type': 'solar_pv'} + ] + + total_optimised_gain = sum(m["gain"] for m in solution) + assert total_optimised_gain == 18.6, "Total gain of optimised measures should meet or exceed target gain" + + selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) + + # ✅ Flatten recommendations for output + flattened = optimiser_functions.flatten_recommendations_with_defaults(p.id, recommendations, selected) + + # --------------------- + # FINAL ASSERTIONS + # --------------------- + assert isinstance(flattened, list) + assert all("default" in rec for rec in flattened) + assert any(rec["default"] for rec in flattened), "Some measures should be marked as default" + + # We don't add ventilation as major insulation work isn't done + ventilation_added = any(rec["recommendation_id"] == "3_phase=2" and rec["default"] for rec in flattened) + assert not ventilation_added, "Ventilation should not be added without major insulation work"