from pandas import Timestamp from numpy import nan import datetime import numpy as np import pandas as pd import pytest from copy import deepcopy from recommendations.optimiser import optimiser_functions from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths, build_heat_pump_paths from backend.Funding import Funding from backend.app.plan.schemas import WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES ALLOWED_FABRIC_TYPES = set(WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES) @pytest.fixture def mock_project_scores_matrix(): data = [] floor_segments = ["0-72", "73-97", "98-199", "200"] bands = [ "Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C", "Low_B", "High_B", "Low_A", "High_A" ] cost = 50.0 for floor in floor_segments: for start in bands: for finish in bands: if start != finish: # skip identical start/finish (no SAP movement) data.append({ "Floor Area Segment": floor, "Starting Band": start, "Finishing Band": finish, "Cost Savings": cost }) cost += 5.0 # increment to create variety return pd.DataFrame(data) @pytest.fixture def mock_partial_scores_matrix(): df = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv") df.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'] return df class DummyProp: """Minimal property stub exposing just what your code reads.""" def __init__(self): self.data = { "current-energy-rating": "E", # or "D" for the special Social+D path "current-energy-efficiency": 55, # numeric SAP points used in eligibility calc "mainheat-energy-eff": "Very Good", } self.has_ventilation = False self.floor_area = 70.0 self.main_heating_controls = {"clean_description": "time and temperature zone control"} self.walls = {'original_description': 'Solid brick, as built, no insulation (assumed)', 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, 'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False, 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True, 'is_sandstone_or_limestone': False, 'insulation_thickness': 'none', 'external_insulation': False, 'internal_insulation': False} self.main_heating = { 'original_description': 'Boiler and radiators, mains gas', 'clean_description': 'Boiler and radiators, mains gas', 'has_radiators': True, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': True, 'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False, 'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False, 'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False, 'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, 'has_electric_heat_pump': False, 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, 'has_exhaust_source_heat_pump': False, 'has_community_heat_pump': False, 'has_hot-water-only': False, 'has_electric': False, 'has_mains_gas': True, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, 'has_mineral_and_wood': False, 'has_dual_fuel_appliance': False, 'has_assumed': False, 'has_electricaire': False, 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False } self.main_fuel = { 'original_description': 'mains gas (not community)', 'clean_description': 'Mains gas not community', 'fuel_type': 'mains gas', 'tariff_type': None, 'is_community': False, 'no_individual_heating_or_community_network': False, 'complex_fuel_type': None } @pytest.fixture def p(): return DummyProp() @pytest.fixture def funding(monkeypatch, mock_partial_scores_matrix, mock_project_scores_matrix): """Simple Funding that returns zero uplift so costs stay as provided.""" # Build the Funding with tiny in-memory frames (avoid test I/O) f = Funding( project_scores_matrix=mock_project_scores_matrix, partial_project_scores_matrix=mock_partial_scores_matrix, whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]), 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" ) # Keep innovation_uplift simple for the first test # monkeypatch.setattr(f, "get_innovation_uplift", lambda *args, **kwargs: 0.0) # If your solar precondition matters, you can force True/False here: # monkeypatch.setattr( # __import__("backend").Funding, "check_solar_eligible_heating_system", # staticmethod(lambda mainheat_description, heating_control_description: False) # ) return f @pytest.fixture def property_recommendations(): """Short sample; replace with your full block if you want.""" recs = [ [{'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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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', "innovation_rate": 0, '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)}] ] return recs def _attach_costs_and_uplifts(recs, funding, p): """Mimic what your script did: add cost fields & innovation uplift.""" out = deepcopy(recs) for group in out: for r in group: if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]: ( r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], r["uplift_project_score"], ) = ( 0, 0, 0, 0 ) continue ( r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], r["uplift_project_score"] ) = funding.get_innovation_uplift( measure=r, starting_sap=55, floor_area=70.0, 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="Very Good", ) # the optimiser_functions.prepare_input_measures will translate these to input format; but # for safety add explicit cost fields some downstream code expects: r["total"] = float(r["total"]) return out def _to_input_measures(recs, p): """Use your own helper so we test the full pipeline.""" property_measure_types = {rec["type"] for grp in recs for rec in grp} needs_ventilation = any( x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation ) and not getattr(p, "has_ventilation", False) # goal="Increasing EPC", add_uplift=True for Social path return optimiser_functions.prepare_input_measures( recs, goal="Increasing EPC", needs_ventilation=needs_ventilation, funding=True ) def _types_of(picked_items): return {item["type"] for item in picked_items} def test_social_fabric_only_returns_only_fabric_types(p, funding, property_recommendations, monkeypatch): # 1) prepare data like your script recs = _attach_costs_and_uplifts(property_recommendations, funding, p) input_measures = _to_input_measures(recs, p) # 2) run optimiser wrapper (budget and target_gain can be modest for the test) budget = 30000.0 target_gain = 8.0 solutions = optimise_with_funding_paths( p=p, input_measures=input_measures, housing_type="Social", budget=budget, target_gain=target_gain, funding=funding ) # 3) basic shape assertions assert isinstance(solutions, pd.DataFrame) assert not solutions.empty # 4) find the fabric-only ECO4 row fabric_rows = solutions[ solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "fabric-only:eco4")] assert not fabric_rows.empty, "Expected a fabric-only:eco4 solution for Social tenure" # 5) ensure only fabric measure types are present in that solution picked_types = _types_of(fabric_rows.iloc[0]["items"]) assert picked_types == {'internal_wall_insulation+mechanical_ventilation', 'suspended_floor_insulation'}, "incorrect types selected" # 6) respect budget assert fabric_rows.iloc[0]["total_cost"] <= budget + 1e-9 # (optional) ensure unfunded baseline also appears unfunded_rows = solutions[ solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "unfunded:all")] assert not unfunded_rows.empty def test_private_solid_wall_no_innovation_epc_d(p, funding, mock_project_scores_matrix, mock_partial_scores_matrix): """ We have a specific test for this case which was implemented incorrectly originally. This is an EPC D property and so shouldn't be eligible for ECO4. Instead, only GBIS should be considered. """ # Overwrite the data - copied from real example p2 = deepcopy(p) p2.data = { "current-energy-rating": "D", "current-energy-efficiency": 68, "mainheat-energy-eff": "Good", } p2.walls = {'original_description': 'Sandstone or limestone, as built, no insulation (assumed)', 'clean_description': 'Sandstone or limestone, as built, no insulation', 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True, 'is_sandstone_or_limestone': True, 'is_park_home': False, 'insulation_thickness': 'none', 'external_insulation': False, 'internal_insulation': False} funding2 = Funding( tenure="Private", project_scores_matrix=mock_project_scores_matrix, partial_project_scores_matrix=mock_partial_scores_matrix, whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]), eco4_social_cavity_abs_rate=12.5, eco4_social_solid_abs_rate=17, eco4_private_cavity_abs_rate=12.5, eco4_private_solid_abs_rate=17, gbis_social_cavity_abs_rate=21, gbis_social_solid_abs_rate=25, gbis_private_cavity_abs_rate=21, gbis_private_solid_abs_rate=28, ) input_measures = [ [{'id': '0_phase=0', 'cost': np.float64(4441.202499013676), 'gain': np.float64(3.4000000000000057), 'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756, 'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3), 'uplift_project_score': np.float64(0.0)}], [ {'id': '2_phase=2', 'cost': np.float64(2280.0), 'gain': np.float64(0.4), 'type': 'secondary_glazing', 'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(2280.0), 'raw_cost': np.float64(2280.0), 'partial_project_funding': np.float64(1421.1999999999998), 'partial_project_score': np.float64(83.6), 'uplift_project_score': np.float64(0.0)}], [ {'id': '3_phase=3', 'cost': np.float64(604.5840000000001), 'gain': np.float64(1.2), 'type': 'time_temperature_zone_control', 'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(604.5840000000001), 'raw_cost': 604.5840000000001, 'partial_project_funding': np.float64(702.0999999999999), 'partial_project_score': np.float64(41.3), 'uplift_project_score': np.float64(0.0)}], [ {'id': '4_phase=4', 'cost': 60.0, 'gain': np.float64(0.0), 'type': 'secondary_heating', 'innovation_uplift': 0, 'cost_minus_uplift': 60.0, 'raw_cost': 60.0, 'partial_project_funding': 0, 'partial_project_score': 0, 'uplift_project_score': 0}] ] solutions = optimise_with_funding_paths( p=p2, input_measures=input_measures, housing_type="Private", budget=None, target_gain=1.5, funding=funding2 ) # 3) basic shape assertions assert isinstance(solutions, pd.DataFrame) assert not solutions.empty # We should have 2 rows assert solutions.shape[0] == 2 # We should only have None or GBIS assert set(solutions["scheme"].unique()) == {"none", "gbis"} meets_upgrade_gbis = solutions[solutions["meets_upgrade_target"] & solutions["is_eligible"]] assert meets_upgrade_gbis.shape[0] == 1 # Check exact result assert meets_upgrade_gbis.squeeze().to_dict() == { 'fixed_ids': ['0_phase=0'], 'items': [ {'id': '0_phase=0', 'cost': 3881.2024990136756, 'gain': np.float64(3.4000000000000057), 'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756, 'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3), 'uplift_project_score': np.float64(0.0)}], 'total_cost': 3881.2024990136756, 'total_gain': 3.4000000000000057, 'path': [{'AND': ['internal_wall_insulation+mechanical_ventilation'], 'reference': 'internal_wall_insulation+mechanical_ventilation:gbis'}], 'scheme': 'gbis', 'is_eligible': True, 'unfunded_items': [], 'meets_upgrade_target': True, 'starting_sap': 68, 'floor_area': 70.0, 'ending_sap': 71.4, 'starting_band': 'High_D', 'ending_band': 'Low_C', 'floor_area_band': '0-72', 'project_score': 540.0, 'full_project_funding': 0.0, 'partial_project_funding': 2300.1000000000004, 'partial_project_score': 135.3, 'total_uplift': 0.0, 'total_uplift_score': 0.0 } def test_build_heat_pump_paths(): eg1 = build_heat_pump_paths([], ["loft_insulation"]) assert eg1 == [{'AND': ['loft_insulation', 'air_source_heat_pump']}] eg2 = build_heat_pump_paths(["internal_wall_insulation", "external_wall_insulation"], ["loft_insulation"]) assert eg2 == [{'AND': ['internal_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}, {'AND': ['external_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}]