working on defining the optimisation sub problems

This commit is contained in:
Khalim Conn-Kowlessar 2025-08-12 17:44:19 +01:00
parent fb5960c4fe
commit f27447bed8
4 changed files with 954 additions and 26 deletions

View file

@ -3,7 +3,8 @@ from typing import List
import pandas as pd
from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes
from backend.app.plan.schemas import HousingType, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, MEASURE_MAP
from backend.app.plan.schemas import VALID_HOUSING_TYPES, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, \
MEASURE_MAP
class EligibilityCaveats(Enum):
@ -11,7 +12,7 @@ class EligibilityCaveats(Enum):
TENANT_ON_BENEFITS_OR_LOW_INCOME = "tenant_on_benefits_or_low_income"
INNOVATION_REQUIRED = "innovation_required"
SOLAR_NEEDS_HEATING = "solar_needs_heating"
MINIMUM_INSULATION_PRECONgiDITIONS_NOT_MET = "minimum_insulation_preconditions_not_met"
MINIMUM_INSULATION_PRECONDITIONS_NOT_MET = "minimum_insulation_preconditions_not_met"
class Funding:
@ -39,7 +40,7 @@ class Funding:
partial_project_scores_matrix,
whlg_eligible_postcodes
):
if tenure not in [HousingType.PRIVATE, HousingType.SOCIAL]:
if tenure not in VALID_HOUSING_TYPES:
raise ValueError("Invalid tenure type. Must be 'Private' or 'Social'.")
self.tenure = tenure
self.eco4_social_cavity_abs_rate = eco4_social_cavity_abs_rate
@ -342,6 +343,8 @@ class Funding:
starting_str = "2"
elif closest_starting == 2.00:
starting_str = "2.0"
elif closest_starting == 1.70:
starting_str = "1.7"
else:
starting_str = f"{closest_starting:.2f}"
@ -495,15 +498,11 @@ class Funding:
def calculate_partial_project_abs(
self,
measure_type: str,
mainheating: dict,
main_fuel: dict,
mainheat_energy_eff: str,
filtered_pps_matrix: pd.DataFrame,
pre_heating_system: str,
current_wall_uvalue: float = None,
is_partial: bool = False,
existing_li_thickness: float = None,
# is_roof_insulated: bool = False
):
"""
Calculate the partial project ABS score for a single measure.
@ -615,6 +614,18 @@ class Funding:
return 0
if measure_type == "time_temperature_zone_control":
pps = filtered_pps_matrix[
filtered_pps_matrix["Measure_Type"] == "TTZC"
]
if pre_heating_system in pps["Pre_Main_Heating_Source"].values:
pps = pps[pps["Pre_Main_Heating_Source"] == pre_heating_system]
if pps.shape[0] != 1:
raise ValueError("something went wrong, more than one pps for TTZC")
return pps.squeeze()["Cost Savings"]
# If we don't have a pre heating system, we assume the measure is not applicable
return 0
raise ValueError(f"Invalid measure type for partial project ABS calculation: {measure_type}")
# -----------------------
@ -704,6 +715,28 @@ class Funding:
- all other measures are insulation (can be non-innovation)
"""
raise ValueError(
"THis isnt quite right. Band D homes must be pre-insulated OR it should include one of the"
)
# The condition is:
# one of the following insulation measures must be installed as part of the
# same ECO4 project:
# o roof insulation (flat roof, pitched roof, room-in-roof)
# o exterior facing wall insulation (cavity wall, solid wall)
# o party cavity wall insulation
# or,
# • all measures listed above must already be installed
#
# All Band E, F and G homes receiving any heating measure and Band D homes
# receiving FTCH or a DHC must have all exterior facing cavity walls and loft
# (including rafters) / roof (including flat and pitched roof or room-in-roof) area
# insulated (except where insulation is not possible and exemptions are lodged,
# see 5.87). The insulation of these areas can be:
# • installed as part of the same ECO4 project,
# • pre-existing insulation,
# • subject to exemptions or
# • a combination of the above
if not (55 <= starting_sap <= 68):
return True # Only EPC D requires innovation check
@ -802,9 +835,6 @@ class Funding:
continue
pps = self.calculate_partial_project_abs(
measure_type=measure,
mainheating=mainheating,
main_fuel=main_fuel,
mainheat_energy_eff=mainheat_energy_eff,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
@ -922,9 +952,6 @@ class Funding:
if self.gbis_eligible:
self.partial_project_abs = self.calculate_partial_project_abs(
measure_type=measure_types[0],
mainheating=mainheating,
main_fuel=main_fuel,
mainheat_energy_eff=mainheat_energy_eff,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
@ -971,9 +998,6 @@ class Funding:
# Calculate the partial project score - this is dependent on the measure
self.partial_project_abs = self.calculate_partial_project_abs(
measure_type=measure_types[0],
mainheating=mainheating,
main_fuel=main_fuel,
mainheat_energy_eff=mainheat_energy_eff,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
@ -987,3 +1011,53 @@ class Funding:
else:
raise NotImplementedError("Only 'Private' and 'Social' tenures are supported.")
def get_innovation_uplift(
self, measure, starting_sap, floor_area, current_wall_uvalue, mainheating, main_fuel, mainheat_energy_eff,
is_partial, is_cavity, existing_li_thickness=None
):
"""
Helper function to calculate the innovation uplift for a measure based on the PPS
:param measure:
:param current_wall_uvalue:
:return:
"""
self.starting_sap_band = self.get_sap_band(starting_sap)
self.floor_area_band = self.get_floor_area_band(floor_area)
filtered_pps_matrix = self.partial_project_scores_matrix[
(self.partial_project_scores_matrix["Total Floor Area Band"] == self.floor_area_band) &
(self.partial_project_scores_matrix["Starting Band"] == self.starting_sap_band)
].copy()
pre_heating_system = self._map_to_pre_main_heating(mainheating, main_fuel, mainheat_energy_eff)
measure_type = measure["measure_type"]
pps = self.calculate_partial_project_abs(
measure_type=measure_type,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
filtered_pps_matrix=filtered_pps_matrix,
pre_heating_system=pre_heating_system
)
innovation_uplift = pps * measure["uplift"]
if self.tenure == "Private":
# We return ECO4 rates
return innovation_uplift * (
self.eco4_private_cavity_abs_rate if is_cavity
else self.eco4_private_solid_abs_rate
)
if self.tenure == "Social":
# We return ECO4 rates
return innovation_uplift * (
self.eco4_social_cavity_abs_rate if is_cavity
else self.eco4_social_solid_abs_rate
)
raise ValueError("Invalid tenure type for innovation uplift calculation: {}".format(self.tenure))

View file

@ -818,6 +818,16 @@ async def model_engine(body: PlanTriggerRequest):
)
continue
# We layer funding on top of the recommendations
# We take one of these options
funding_paths = [
[["internal_wall_insulation", "external_wall_insulation"]],
["air_source_heat_pump"],
# We must have both of these options (though we check if the property doesn't already have HHRSH and
# is recommended it
[["solar_pv"], ["high_heat_retention_storage_heaters"]]
]
fixed_gain = optimiser_functions.calculate_fixed_gain(
property_required_measures, recommendations, p, needs_ventilation
)

View file

@ -6,7 +6,7 @@ from backend.app.utils import epc_to_sap_lower_bound
from recommendations.optimiser.CostOptimiser import CostOptimiser
def prepare_input_measures(property_recommendations, goal, needs_ventilation):
def prepare_input_measures(property_recommendations, goal, needs_ventilation, funding=False):
"""
Prepares a nested list of measure options for optimisation.
@ -34,6 +34,9 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation):
Optimisation goal, one of: "Increasing EPC", "Energy Savings", "Reducing CO2 emissions".
needs_ventilation : bool
Whether the property requires mechanical ventilation to accompany certain measures.
funding: bool, optional
If true, the function will include the innovation uplift in the total cost calculation. If false, this is
excluded, since innovation uplift cannot be claimed where funding is not available.
Returns
-------
@ -75,20 +78,28 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation):
# Build enriched measure data
to_append = []
for rec in recs:
total = (
rec["total"] + ventilation_recommendation["total"]
if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
else rec["total"]
)
if funding:
total = (
rec["total"] - rec["innovation_uplift"] + ventilation_recommendation["total"]
if rec["measure_type"] in assumptions.measures_needing_ventilation and needs_ventilation
else rec["total"] - rec["innovation_uplift"]
)
else:
total = (
rec["total"] + ventilation_recommendation["total"]
if rec["measure_type"] in assumptions.measures_needing_ventilation and needs_ventilation
else rec["total"]
)
total = 0 if total < 0 else total
gain = (
rec[goal_key] + ventilation_recommendation[goal_key]
if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
if rec["measure_type"] in assumptions.measures_needing_ventilation and needs_ventilation
else rec[goal_key]
)
rec_type = (
f"{rec['type']}+{ventilation_recommendation['type']}"
if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
else rec["type"]
f"{rec['measure_type']}+{ventilation_recommendation['measure_type']}"
if rec["measure_type"] in assumptions.measures_needing_ventilation and needs_ventilation
else rec["measure_type"]
)
to_append.append(

View file

@ -0,0 +1,833 @@
import numpy as np
import pandas as pd
from pandas import Timestamp
from numpy import nan
import datetime
from copy import deepcopy
from recommendations.optimiser.CostOptimiser import CostOptimiser
from recommendations.optimiser.GainOptimiser import GainOptimiser
from backend.Funding import Funding
project_scores_matrix = pd.read_csv("/Users/khalimconn-kowlessar/Downloads/ECO4 Full Project Scores Matrix.csv")
partial_project_scores_matrix = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv")
partial_project_scores_matrix.columns = ['Measure category', 'Measure_Type', 'Pre_Main_Heating_Source',
'Post_Main_Heating_Source', 'Total Floor Area Band', 'Starting Band',
'Average Treatable Factor', 'Cost Savings', 'SAP Savings']
whlg_eligible_postcodes = pd.DataFrame([{"Postcode": "ab12cd"}])
funding = Funding(
project_scores_matrix=project_scores_matrix,
partial_project_scores_matrix=partial_project_scores_matrix,
whlg_eligible_postcodes=whlg_eligible_postcodes,
eco4_social_cavity_abs_rate=13.5,
eco4_social_solid_abs_rate=17,
eco4_private_cavity_abs_rate=13.5,
eco4_private_solid_abs_rate=17,
gbis_social_cavity_abs_rate=21,
gbis_social_solid_abs_rate=25,
gbis_private_cavity_abs_rate=22,
gbis_private_solid_abs_rate=28,
tenure="Social"
)
# Assume these costs have been adjusted
property_recommendations = [
[{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation',
'description': 'EWI Pro EPS external wall insulation system with '
'Brick Slip finish',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.0, 'plant_cost': 0.0,
'total_cost': 298.35,
'notes': 'This is the quoted value from SCIS',
'is_installer_quote': True, 'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 19090.810139104888,
'labour_hours': 0.0, 'labour_days': 0.0}],
'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation',
'description': 'Install 150mm EWI Pro EPS external wall insulation system with Brick '
'Slip finish on external walls',
'starting_u_value': 1.7, 'new_u_value': 0.32, 'already_installed': False,
'sap_points': np.float64(9.6),
'simulation_config': {'is_as_built_ending': False, 'walls_is_assumed_ending': False,
'walls_insulation_thickness_ending': 'average',
'external_insulation_ending': True,
'walls_energy_eff_ending': 'Good',
'walls_thermal_transmittance_ending': 0.23},
'description_simulation': {'walls-description': 'Solid brick, with external insulation',
'walls-energy-eff': 'Good'}, 'total': 19090.810139104888,
'labour_hours': 0.0, 'labour_days': 0.0, 'survey': False,
'recommendation_id': '0_phase=0', 'efficiency': 11229.568317120522,
'co2_equivalent_savings': np.float64(0.5), 'heat_demand': np.float64(37.099999999999994),
'kwh_savings': np.float64(1827.8999999999996),
'energy_cost_savings': np.float64(136.1247882352941)}, {'phase': 0, 'parts': [
{'id': 2373, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish',
'depth': 95.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032,
'thermal_conductivity_unit': None,
'link': 'SCIS', 'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1,
'plant_cost': 0.0, 'total_cost': 89.0, 'notes': None, 'is_installer_quote': True,
'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 5694.929118083911, 'labour_hours': 134.37473199973275,
'labour_days': 4.199210374991648}], 'type': 'internal_wall_insulation',
'measure_type': 'internal_wall_insulation',
'description': 'Install 95mm '
'SWIP EcoBatt & '
'Plastered '
'finish on '
'internal walls',
'starting_u_value': 1.7,
'new_u_value': 0.32,
'already_installed': False,
'sap_points': 6,
'simulation_config': {
'is_as_built_ending': False,
'walls_is_assumed_ending':
False,
'walls_insulation_thickness_ending': 'average',
'internal_insulation_ending': True,
'walls_energy_eff_ending':
'Good',
'walls_thermal_transmittance_ending': 0.29},
'description_simulation': {
'walls-description': 'Solid '
'brick, with internal '
'insulation',
'walls-energy-eff': 'Good'},
'total': 5694.929118083911,
'labour_hours': 134.37473199973275,
'labour_days': 4.199210374991648,
'survey': True,
'recommendation_id': '1_phase=0',
'efficiency': 3349.6383047552417,
'co2_equivalent_savings': np.float64(
0.5),
'heat_demand': np.float64(
35.30000000000001),
'kwh_savings': np.float64(
1432.3999999999996),
'energy_cost_savings': np.float64(
106.67167058823532)}], [
{'phase': 1, 'parts': [{'id': 2351, 'type': 'loft_insulation',
'description': 'Knauf Loft Roll 44 glass fibre roll',
'depth': 300.0, 'depth_unit': 'mm', 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.11, 'plant_cost': 0.0,
'total_cost': 15.0,
'notes': 'This is the cost if there is less than 100mm '
'existing insulation',
'is_installer_quote': True, 'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 645.0, 'labour_hours': 8,
'labour_days': 1}], 'type': 'loft_insulation',
'measure_type': 'loft_insulation',
'description': 'Install 300mm of Knauf Loft Roll 44 glass fibre roll in your loft',
'starting_u_value': 2.3, 'new_u_value': 2.3, 'sap_points': np.float64(2.4),
'already_installed': False,
'simulation_config': {'is_loft_ending': True, 'roof_is_assumed_ending': False,
'roof_insulation_thickness_ending': '300',
'roof_thermal_transmittance_ending': 2.3,
'roof_energy_eff_ending': 'Very Good'},
'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation',
'roof-energy-eff': 'Very Good'}, 'total': 645.0,
'labour_hours': 8, 'labour_days': 1, 'survey': False, 'recommendation_id': '2_phase=1',
'efficiency': 278.1347826086957,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(1.5), 'kwh_savings': np.float64(566.1499999999996),
'energy_cost_savings': np.float64(42.16152352941185)}], [{'phase': 2, 'parts': [
{'id': 2329, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
'thermal_conductivity_unit': None,
'link': 'SCIS', 'created_at': datetime.datetime(2025, 3, 16, 15, 26, 22, 379496), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0,
'plant_cost': 0.0, 'total_cost': 350.0, 'notes': None, 'is_installer_quote': True, 'total': 700.0,
'quantity': 2,
'quantity_unit': 'part'}], 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation',
'description': 'Install 2 '
'Mechanical '
'Extract '
'Ventilation units',
'starting_u_value': None,
'new_u_value': None,
'already_installed': False,
'sap_points': np.float64(
-0.10000000000000142),
'heat_demand': np.float64(
-3.3999999999999773),
'kwh_savings': np.float64(
-53.80000000000018),
'co2_equivalent_savings': np.float64(
0.0),
'energy_cost_savings': np.float64(
-4.0065176470588995),
'total': 700.0,
'labour_hours': 8,
'labour_days': 1.0,
'simulation_config': {
'mechanical_ventilation_ending': 'mechanical, '
'extract only'},
'description_simulation': {
'mechanical-ventilation': 'mechanical, '
'extract only'},
'recommendation_id': '3_phase=2',
'efficiency': 0}], [
{'phase': 3, 'parts': [{'id': 2409, 'type': 'suspended_floor_insulation',
'description': 'Q-bot underfloor insulation', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 1.63, 'plant_cost': 0.0,
'total_cost': 93.75,
'notes': 'Linearly interpolated based on Qbot costs',
'is_installer_quote': True, 'quantity': 43.0,
'quantity_unit': 'm2', 'total': 4031.25,
'labour_hours': 70.08999999999999,
'labour_days': 2.920416666666666}],
'type': 'suspended_floor_insulation', 'measure_type': 'suspended_floor_insulation',
'description': 'Install 75mm Q-bot underfloor insulation insulation in suspended '
'floor',
'starting_u_value': 0.83, 'new_u_value': 0.22, 'sap_points': 2, 'survey': True,
'already_installed': False, 'simulation_config': {'floor_is_assumed_ending': False,
'floor_insulation_thickness_ending': 'average',
'floor_thermal_transmittance_ending': 0.685593},
'description_simulation': {'floor-description': 'Suspended, insulated'},
'total': 4031.25, 'labour_hours': 70.08999999999999, 'labour_days': 2.920416666666666,
'recommendation_id': '4_phase=3', 'efficiency': 4856.707710843373,
'co2_equivalent_savings': np.float64(0.20000000000000018),
'heat_demand': np.float64(33.5), 'kwh_savings': np.float64(1021.1999999999998),
'energy_cost_savings': np.float64(76.04936470588231)}], [
{'phase': 4, 'parts': [], 'type': 'low_energy_lighting',
'measure_type': 'low_energy_lighting',
'description': 'Install low energy lighting in -886 outlets', 'starting_u_value': None,
'new_u_value': None, 'already_installed': False, 'sap_points': 2,
'kwh_savings': -48508.5, 'energy_cost_savings': -12481.237049999998,
'co2_equivalent_savings': -7.858377,
'description_simulation': {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all fixed'
' outlets',
'low-energy-lighting': 100}, 'total': -3411.1000000000004,
'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
'recommendation_id': '5_phase=4', 'efficiency': -1705.5500000000002,
'heat_demand': np.float64(5.099999999999994)}], [
{'type': 'heating', 'phase': 5, 'measure_type': 'time_temperature_zone_control',
'parts': [],
'description': 'Upgrade heating controls to Smart Thermostats, room sensors and '
'smart radiator valves (time & temperature zone control)',
'total': 739.576, 'subtotal': 700.48, 'vat': 39.096000000000004,
'labour_hours': 3.6199999999999997, 'labour_days': np.float64(1.0),
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(2.9),
'already_installed': False, 'simulation_config': {
'thermostatic_control_ending': 'time and temperature zone control',
'switch_system_ending': None, 'trvs_ending': None,
'mainheatc_energy_eff_ending': 'Very Good'}, 'description_simulation': {
'mainheatcont-description': 'Time and temperature zone control',
'mainheatc-energy-eff': 'Very Good'}, 'recommendation_id': '6_phase=5',
'efficiency': 739.576, 'co2_equivalent_savings': np.float64(0.30000000000000027),
'heat_demand': np.float64(6.599999999999994),
'kwh_savings': np.float64(876.8000000000002),
'energy_cost_savings': np.float64(65.29581176470589)}], [
{'phase': 6, 'parts': [], 'type': 'secondary_heating',
'measure_type': 'secondary_heating',
'description': 'Remove the secondary heating system', 'starting_u_value': None,
'new_u_value': None, 'sap_points': np.float64(3.6), 'already_installed': False,
'total': 30.0, 'subtotal': 25.0, 'vat': 5.0, 'labour_hours': 3.0,
'labour_days': np.float64(1.0),
'simulation_config': {'secondheat_description_ending': 'None'},
'description_simulation': {'secondheat-description': 'None'},
'recommendation_id': '7_phase=6', 'efficiency': 30.0,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(15.400000000000006),
'kwh_savings': np.float64(196.29999999999927),
'energy_cost_savings': np.float64(14.61857647058821)}], [
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
'description_simulation': {'photo-supply': np.float64(65.0)},
'recommendation_id': '8_phase=7', 'efficiency': np.float64(462.54923076923075),
'co2_equivalent_savings': np.float64(0.47347873833399995),
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2040.8566307499998),
'energy_cost_savings': np.float64(525.1124110919749)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
'description_simulation': {'photo-supply': np.float64(65.0)},
'recommendation_id': '9_phase=7', 'efficiency': np.float64(810.5390769230769),
'co2_equivalent_savings': np.float64(0.6628702336675999),
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2857.1992830499994),
'energy_cost_savings': np.float64(735.1573755287648)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3692.66794),
'description_simulation': {'photo-supply': np.float64(60.0)},
'recommendation_id': '10_phase=7', 'efficiency': np.float64(485.54099999999994),
'co2_equivalent_savings': np.float64(0.42834948104),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397),
'energy_cost_savings': np.float64(475.0617304809999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3692.66794),
'description_simulation': {'photo-supply': np.float64(60.0)},
'recommendation_id': '11_phase=7', 'efficiency': np.float64(862.5299999999999),
'co2_equivalent_savings': np.float64(0.599689273456),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558),
'energy_cost_savings': np.float64(665.0864226734)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
'description_simulation': {'photo-supply': np.float64(55.0)},
'recommendation_id': '12_phase=7', 'efficiency': np.float64(512.964),
'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(1650.2708274),
'energy_cost_savings': np.float64(424.61468389001993)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
'description_simulation': {'photo-supply': np.float64(55.0)},
'recommendation_id': '13_phase=7', 'efficiency': np.float64(924.2247272727273),
'co2_equivalent_savings': np.float64(0.53600796473952),
'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996),
'energy_cost_savings': np.float64(594.4605574460278)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
'description_simulation': {'photo-supply': np.float64(45.0)},
'recommendation_id': '14_phase=7', 'efficiency': np.float64(606.5253333333333),
'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(1453.5933906),
'energy_cost_savings': np.float64(374.00957940138)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
'description_simulation': {'photo-supply': np.float64(45.0)},
'recommendation_id': '15_phase=7', 'efficiency': np.float64(1109.1773333333333),
'co2_equivalent_savings': np.float64(0.47212713326688),
'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684),
'energy_cost_savings': np.float64(523.6134111619319)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2510.25188),
'description_simulation': {'photo-supply': np.float64(40.0)},
'recommendation_id': '16_phase=7', 'efficiency': np.float64(659.3565),
'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1255.12594),
'energy_cost_savings': np.float64(322.94390436199996)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2510.25188),
'description_simulation': {'photo-supply': np.float64(40.0)},
'recommendation_id': '17_phase=7', 'efficiency': np.float64(1224.84),
'co2_equivalent_savings': np.float64(0.40766490531199995),
'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998),
'energy_cost_savings': np.float64(452.1214661067999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2096.682636),
'description_simulation': {'photo-supply': np.float64(35.0)},
'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856),
'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1048.341318),
'energy_cost_savings': np.float64(269.7382211214)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2096.682636),
'description_simulation': {'photo-supply': np.float64(35.0)},
'recommendation_id': '19_phase=7', 'efficiency': np.float64(1373.5491428571427),
'co2_equivalent_savings': np.float64(0.3405012600864), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1467.6778451999999),
'energy_cost_savings': np.float64(377.6335095699599)}]
]
main_heating = {
'original_description': 'Boiler and radiators, mains gas', 'clean_description': 'Boiler and radiators, mains gas',
'has_radiators': True, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False,
'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': True,
'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False,
'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False,
'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False,
'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, 'has_electric_heat_pump': False,
'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, 'has_exhaust_source_heat_pump': False,
'has_community_heat_pump': False, 'has_hot-water-only': False, 'has_electric': False, 'has_mains_gas': True,
'has_wood_logs': False, 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
'has_mineral_and_wood': False, 'has_dual_fuel_appliance': False, 'has_assumed': False, 'has_electricaire': False,
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False
}
main_fuel = {
'original_description': 'mains gas (not community)', 'clean_description': 'Mains gas not community',
'fuel_type': 'mains gas', 'tariff_type': None, 'is_community': False,
'no_individual_heating_or_community_network': False, 'complex_fuel_type': None
}
# Insert the funding uplifts
for recs in property_recommendations:
for r in recs:
# Insert randomly
# Select one of 0, 0.25 or 0.45
r["uplift"] = np.random.choice([0, 0.25, 0.45])
# We calculate the innovation uplift against each measure
for recs in property_recommendations:
for r in recs:
if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]:
r["innovation_uplift"] = 0
continue
r["innovation_uplift"] = funding.get_innovation_uplift(
measure=r,
starting_sap=p.data["current-energy-efficiency"],
floor_area=p.floor_area,
is_cavity=False,
current_wall_uvalue=1.7,
is_partial=False,
existing_li_thickness=150,
mainheating=p.main_heating,
main_fuel=p.main_fuel,
mainheat_energy_eff=p.data["mainheat-energy-eff"],
)
print(r["innovation_uplift"])
property_measure_types = {rec["type"] for recs in property_recommendations for rec in recs}
property_required_measures = [m for m in property_recommendations if m[0]["type"] in []]
measures_to_optimise = [m for m in property_recommendations if m[0]["type"] not in []]
# If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore
# its inclusion
needs_ventilation = any(
x in property_measure_types for x in assumptions.measures_needing_ventilation
) and not p.has_ventilation
input_measures = optimiser_functions.prepare_input_measures(
measures_to_optimise, "Increasing EPC", needs_ventilation
)
# ---- rule definitions you can tweak -------------------------------------
HEATING_TYPES = {"air_source_heat_pump", "high_heat_retention_storage_heater", "solar_pv"}
MIN_INSULATION_OR = [{"loft_insulation"}, {"cavity_wall_insulation"}] # extend if needed
# “Funding paths”: each is a list of elements; each element is:
# - {"OR": {"types": {..}}} means choose one option from any group whose type is in that set
# - {"AND": [{"types": {..}}, {"types": {..}}]} means choose one from each of those
FUNDING_PATHS = [
# Path A: IWI OR EWI
[
{
"OR": {
"types": {"internal_wall_insulation", "external_wall_insulation"}
}
}
],
# Path B: Solar PV AND HHRSH
[{"AND": [{"types": {"solar_pv"}}, {"types": {"high_heat_retention_storage_heater"}}]}],
# Path C: ASHP alone (may still trigger min insulation rule below)
[{"OR": {"types": {"air_source_heat_pump"}}}],
#
]
def _find_measure(input_measures, measure_type):
for measures in input_measures:
for m in measures:
if measure_type in m["type"]:
return True
return False
def make_funding_paths(input_measures, tenure):
"""
Given the tenure of the property and the available measures, this function will construct the funding paths
:return:
"""
funding_paths = []
if tenure == "Social":
raise NotImplementedError("Implement me!")
if tenure == "Private":
# We cover off the main funding paths
# 1) The package must include EWI or IWI
# We check if we have any EWI or IWI measures available
ewi_or_iwi = [{"OR": []}]
# If we have EWI we add it in
if _find_measure(input_measures, "external_wall_insulation"):
ewi_or_iwi[0]["OR"].append("external_wall_insulation")
if _find_measure(input_measures, "internal_wall_insulation"):
ewi_or_iwi[0]["OR"].append("internal_wall_insulation")
if ewi_or_iwi[0]["OR"]:
funding_paths.append(ewi_or_iwi)
# 2) The package must include a renewable heating system like an ASHP
ashp = [{"OR": []}]
if _find_measure(input_measures, "air_source_heat_pump"):
ashp[0]["OR"].append("air_source_heat_pump")
funding_paths.append(ashp)
# 3) The package must have an existing eligible heating system. We test this with the funding checker
# If we have any remaining insulation measure to be applied to the property, we also need to include that in
# the package
single_solar_template = [{"OR": []}]
has_eligible_heating_system = funding.check_solar_eligible_heating_system(
mainheat_description=p.main_heating["clean_description"],
heating_control_description=p.main_heating_controls["clean_description"]
)
if has_eligible_heating_system:
single_solar_template[0]["OR"].append("solar_pv")
# We now look to pair this with any lingering insulation measures
wall_insulation_measures = [
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
"extension_cavity_wall_insulation"
]
roof_insulation_measures = [
"loft_insulation", "flat_roof_insulation", "room_roof_insulation"
]
# We search for these
solar_paths_with_insulation = []
for insulation_measure in wall_insulation_measures + roof_insulation_measures:
if _find_measure(input_measures, insulation_measure):
new_solar_path = deepcopy(single_solar_template)
new_solar_path[0]["OR"].append(insulation_measure)
solar_paths_with_insulation.append(new_solar_path)
if not solar_paths_with_insulation:
# If we have no insulation measures, we're good with just single solar
solar_paths_with_insulation.append(single_solar_template)
funding_paths.extend(solar_paths_with_insulation)
# ---- main wrapper around your optimiser ----------------------------------
def optimise_with_funding_paths(input_measures, budget=None, target_gain=None, social=False):
"""
run_optimizer(sub_measures, budget, target_gain) -> (picked_options, sub_cost, sub_gain)
"""
# TODO: Should be EPC D only that we require only innovation measures
# Social housing: filter to innovation-only before doing anything else
# if social:
# filtered = []
# for group in input_measures:
# opts = [o for o in group if o.get("is_innovation", False)]
# if opts:
# filtered.append(opts)
# input_measures = filtered
# Always include a "no funding path" baseline (empty fixed)
all_paths = FUNDING_PATHS + [[]]
solutions = []
for path_spec in all_paths:
# 1) expand fixed selections for this path
fixed_selections = expand_funding_path(input_measures, path_spec) if path_spec else [[]]
if not fixed_selections:
continue
for fixed in fixed_selections:
# 2) min insulation if heating is already in fixed
fixed_variants = expand_min_insulation_if_needed(input_measures, fixed)
if not fixed_variants:
continue
for fixed2 in fixed_variants:
# 3) compute fixed cost/gain, and strip those groups from subproblem
fixed_items = [opt for (_, _, opt) in fixed2]
fixed_ids = [opt['id'] for opt in fixed_items]
fixed_cost, fixed_gain = sum_cost_gain(fixed_items)
fixed_groups = {gi for (gi, _, _) in fixed2}
sub_measures = strip_groups(input_measures, fixed_groups)
# 4) run your existing optimiser for the remaining groups
# If we have a budget, we need to ensure the subproblem respects it so we remove the fixed cost (which
# may already be over budget) and the fixed gain (which may not be achievable)
picked, sub_cost, sub_gain = run_optimizer(
sub_measures,
budget - fixed_cost if budget is not None else None,
sub_target_gain=target_gain - fixed_gain if target_gain is not None else None
)
if picked is None:
continue
total_cost = fixed_cost + sub_cost
total_gain = fixed_gain + sub_gain
total_picks = fixed_items + picked
# you can change the objective here; Ill use max gain under budget
if budget is not None and total_cost > budget + 1e-9:
continue
solutions.append({
"fixed_ids": fixed_ids,
"items": total_picks,
"total_cost": total_cost,
"total_gain": total_gain,
"path": path_spec,
})
solutions = pd.DataFrame(solutions)
return solutions
# Run inputs:
target_gain = 18.5
from itertools import product
import math
# ---- helpers -------------------------------------------------------------
def split_types(t):
# supports "external_wall_insulation+mechanical_ventilation"
return set(part.strip() for part in str(t).split('+'))
def group_has_type(group, want):
# group is a list[option], all same 'type' pattern
return any(want in split_types(opt['type']) for opt in group)
def find_groups(input_measures, type_name):
return [(gi, g) for gi, g in enumerate(input_measures) if group_has_type(g, type_name)]
def strip_groups(input_measures, taken_group_indices):
return [g for gi, g in enumerate(input_measures) if gi not in taken_group_indices]
def sum_cost_gain(items):
c = sum(float(x['cost']) for x in items)
g = sum(float(x['gain']) for x in items)
return c, g
# ---- candidate expansion -------------------------------------------------
def iter_or_candidates(input_measures, type_set):
# collect all groups that match ANY type in type_set
matching = [(gi, group) for gi, group in enumerate(input_measures)
if any(group_has_type(group, t) for t in type_set)]
if not matching:
return # nothing to yield
# choose ONE option from ANY one of these groups
for gi, group in matching:
for oi, opt in enumerate(group):
yield {"fixed": [(gi, oi, opt)]}
def iter_and_candidates(input_measures, type_vec):
# type_vec is like [{"types": {"solar_pv"}}, {"types": {"high_heat_retention_storage_heater"}}]
per_leg = []
for leg in type_vec:
leg_types = leg["types"]
leg_groups = [(gi, group) for gi, group in enumerate(input_measures)
if any(group_has_type(group, t) for t in leg_types)]
if not leg_groups:
return # this AND path isnt available in this property; skip
# options for this leg: (gi, oi, opt)
options = []
for gi, group in leg_groups:
for oi, opt in enumerate(group):
options.append((gi, oi, opt))
per_leg.append(options)
for combo in product(*per_leg):
yield {"fixed": list(combo)}
def expand_funding_path(input_measures, path_spec):
# path_spec is a list of elements; combine all elements (theyre all required)
# Start with one empty selection; then cross-product accumulate
selections = [[]]
for elem in path_spec:
new_selections = []
if "OR" in elem:
for cand in iter_or_candidates(input_measures, elem["OR"]["types"]):
for base in selections:
new_selections.append(base + cand["fixed"])
elif "AND" in elem:
for cand in iter_and_candidates(input_measures, elem["AND"]):
for base in selections:
new_selections.append(base + cand["fixed"])
else:
raise ValueError("unknown path element")
selections = new_selections
if not selections:
break
# selections are lists of (gi, oi, opt)
# dedupe by group index (if users set a weird path that hits same group twice)
deduped = []
for sel in selections:
seen = set()
clean = []
ok = True
for gi, oi, opt in sel:
if gi in seen:
ok = False
break
seen.add(gi)
clean.append((gi, oi, opt))
if ok:
deduped.append(clean)
return deduped
# ---- minimum insulation handling ----------------------------------------
def expand_min_insulation_if_needed(input_measures, fixed_selection):
# If fixed contains any HEATING_TYPES, we must also include at least one of MIN_INSULATION_OR groups.
fixed_types = set()
fixed_group_idx = {gi for gi, _, _ in fixed_selection}
for _, _, opt in fixed_selection:
fixed_types |= split_types(opt['type'])
if not (fixed_types & HEATING_TYPES):
# BUT: heating might later be picked by optimiser… If you want to be strict,
# you can also add a *feasibility check* after optimisation and reject combos
# that pick heating without min insulation. For now we enforce only when
# already in fixed set.
return [fixed_selection]
# Build OR candidates for required insulation, but exclude groups already fixed
or_pool = []
for alt in MIN_INSULATION_OR:
types = alt
matches = []
for gi, group in enumerate(input_measures):
if gi in fixed_group_idx:
continue
if any(group_has_type(group, t) for t in types):
for oi, opt in enumerate(group):
matches.append((gi, oi, opt))
if not matches:
# No feasible insulation to satisfy the rule -> invalidate this branch
return []
or_pool.append(matches)
# choose one from any of the alt sets (if you have more than one OR bucket, pick one from at least one;
# simplest: union first OR bucket only — or take the union and pick one)
# Here well take the union across all buckets then pick exactly one.
union = {(gi, oi): (gi, oi, opt)
for matches in or_pool for (gi, oi, opt) in matches}.values()
expanded = []
for gi, oi, opt in union:
# avoid duplicating the same group as fixed
if gi in fixed_group_idx:
continue
expanded.append(fixed_selection + [(gi, oi, opt)])
return expanded
from copy import deepcopy
# ---- tiny utilities ----------------------------------------------------------
def parse_types(t):
# e.g. "external_wall_insulation+mechanical_ventilation" -> {"external_wall_insulation","mechanical_ventilation"}
return set(map(str.strip, t.split("+"))) if isinstance(t, str) else set()
def includes_heating(opt_types):
return any(x in opt_types for x in {
"air_source_heat_pump",
"high_heat_retention_storage_heater",
"time_temperature_zone_control", # controls count as a heating measure in your pipeline
"solar_pv" # you treat PV as heating for funding logic
})
def contributes_min_insulation(opt_types):
# MIR satisfiers you mentioned (extend as needed)
return any(x in opt_types for x in {
"external_wall_insulation",
"internal_wall_insulation",
"loft_insulation",
"cavity_wall_insulation",
})
def run_optimizer(input_measures, budget=None, sub_target_gain=None, allow_slack=False):
"""
Thin wrapper over your optimisers.
Returns: list[dict] selected_options
"""
if budget is not None:
opt = GainOptimiser(
input_measures, max_cost=budget, max_gain=(sub_target_gain or float("inf")),
allow_slack=allow_slack
)
else:
if sub_target_gain is None:
raise ValueError("Either budget or target_gain must be provided.")
opt = CostOptimiser(input_measures, min_gain=sub_target_gain)
opt.setup()
opt.solve()
return opt.solution, opt.solution_cost, opt.solution_gain