mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
working on defining the optimisation sub problems
This commit is contained in:
parent
fb5960c4fe
commit
f27447bed8
4 changed files with 954 additions and 26 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
833
recommendations/tests/test_optimisers.py
Normal file
833
recommendations/tests/test_optimisers.py
Normal 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; I’ll use max gain under budget
|
||||
if budget is not None and total_cost > budget + 1e-9:
|
||||
continue
|
||||
|
||||
solutions.append({
|
||||
"fixed_ids": fixed_ids,
|
||||
"items": total_picks,
|
||||
"total_cost": total_cost,
|
||||
"total_gain": total_gain,
|
||||
"path": path_spec,
|
||||
})
|
||||
|
||||
solutions = pd.DataFrame(solutions)
|
||||
|
||||
return solutions
|
||||
|
||||
|
||||
# Run inputs:
|
||||
target_gain = 18.5
|
||||
|
||||
from itertools import product
|
||||
import math
|
||||
|
||||
|
||||
# ---- helpers -------------------------------------------------------------
|
||||
|
||||
def split_types(t):
|
||||
# supports "external_wall_insulation+mechanical_ventilation"
|
||||
return set(part.strip() for part in str(t).split('+'))
|
||||
|
||||
|
||||
def group_has_type(group, want):
|
||||
# group is a list[option], all same 'type' pattern
|
||||
return any(want in split_types(opt['type']) for opt in group)
|
||||
|
||||
|
||||
def find_groups(input_measures, type_name):
|
||||
return [(gi, g) for gi, g in enumerate(input_measures) if group_has_type(g, type_name)]
|
||||
|
||||
|
||||
def strip_groups(input_measures, taken_group_indices):
|
||||
return [g for gi, g in enumerate(input_measures) if gi not in taken_group_indices]
|
||||
|
||||
|
||||
def sum_cost_gain(items):
|
||||
c = sum(float(x['cost']) for x in items)
|
||||
g = sum(float(x['gain']) for x in items)
|
||||
return c, g
|
||||
|
||||
|
||||
# ---- candidate expansion -------------------------------------------------
|
||||
|
||||
def iter_or_candidates(input_measures, type_set):
|
||||
# collect all groups that match ANY type in type_set
|
||||
matching = [(gi, group) for gi, group in enumerate(input_measures)
|
||||
if any(group_has_type(group, t) for t in type_set)]
|
||||
if not matching:
|
||||
return # nothing to yield
|
||||
# choose ONE option from ANY one of these groups
|
||||
for gi, group in matching:
|
||||
for oi, opt in enumerate(group):
|
||||
yield {"fixed": [(gi, oi, opt)]}
|
||||
|
||||
|
||||
def iter_and_candidates(input_measures, type_vec):
|
||||
# type_vec is like [{"types": {"solar_pv"}}, {"types": {"high_heat_retention_storage_heater"}}]
|
||||
per_leg = []
|
||||
for leg in type_vec:
|
||||
leg_types = leg["types"]
|
||||
leg_groups = [(gi, group) for gi, group in enumerate(input_measures)
|
||||
if any(group_has_type(group, t) for t in leg_types)]
|
||||
if not leg_groups:
|
||||
return # this AND path isn’t available in this property; skip
|
||||
# options for this leg: (gi, oi, opt)
|
||||
options = []
|
||||
for gi, group in leg_groups:
|
||||
for oi, opt in enumerate(group):
|
||||
options.append((gi, oi, opt))
|
||||
per_leg.append(options)
|
||||
for combo in product(*per_leg):
|
||||
yield {"fixed": list(combo)}
|
||||
|
||||
|
||||
def expand_funding_path(input_measures, path_spec):
|
||||
# path_spec is a list of elements; combine all elements (they’re all required)
|
||||
# Start with one empty selection; then cross-product accumulate
|
||||
selections = [[]]
|
||||
for elem in path_spec:
|
||||
new_selections = []
|
||||
if "OR" in elem:
|
||||
for cand in iter_or_candidates(input_measures, elem["OR"]["types"]):
|
||||
for base in selections:
|
||||
new_selections.append(base + cand["fixed"])
|
||||
elif "AND" in elem:
|
||||
for cand in iter_and_candidates(input_measures, elem["AND"]):
|
||||
for base in selections:
|
||||
new_selections.append(base + cand["fixed"])
|
||||
else:
|
||||
raise ValueError("unknown path element")
|
||||
selections = new_selections
|
||||
if not selections:
|
||||
break
|
||||
# selections are lists of (gi, oi, opt)
|
||||
# dedupe by group index (if users set a weird path that hits same group twice)
|
||||
deduped = []
|
||||
for sel in selections:
|
||||
seen = set()
|
||||
clean = []
|
||||
ok = True
|
||||
for gi, oi, opt in sel:
|
||||
if gi in seen:
|
||||
ok = False
|
||||
break
|
||||
seen.add(gi)
|
||||
clean.append((gi, oi, opt))
|
||||
if ok:
|
||||
deduped.append(clean)
|
||||
return deduped
|
||||
|
||||
|
||||
# ---- minimum insulation handling ----------------------------------------
|
||||
|
||||
def expand_min_insulation_if_needed(input_measures, fixed_selection):
|
||||
# If fixed contains any HEATING_TYPES, we must also include at least one of MIN_INSULATION_OR groups.
|
||||
fixed_types = set()
|
||||
fixed_group_idx = {gi for gi, _, _ in fixed_selection}
|
||||
for _, _, opt in fixed_selection:
|
||||
fixed_types |= split_types(opt['type'])
|
||||
|
||||
if not (fixed_types & HEATING_TYPES):
|
||||
# BUT: heating might later be picked by optimiser… If you want to be strict,
|
||||
# you can also add a *feasibility check* after optimisation and reject combos
|
||||
# that pick heating without min insulation. For now we enforce only when
|
||||
# already in fixed set.
|
||||
return [fixed_selection]
|
||||
|
||||
# Build OR candidates for required insulation, but exclude groups already fixed
|
||||
or_pool = []
|
||||
for alt in MIN_INSULATION_OR:
|
||||
types = alt
|
||||
matches = []
|
||||
for gi, group in enumerate(input_measures):
|
||||
if gi in fixed_group_idx:
|
||||
continue
|
||||
if any(group_has_type(group, t) for t in types):
|
||||
for oi, opt in enumerate(group):
|
||||
matches.append((gi, oi, opt))
|
||||
if not matches:
|
||||
# No feasible insulation to satisfy the rule -> invalidate this branch
|
||||
return []
|
||||
or_pool.append(matches)
|
||||
|
||||
# choose one from any of the alt sets (if you have more than one OR bucket, pick one from at least one;
|
||||
# simplest: union first OR bucket only — or take the union and pick one)
|
||||
# Here we’ll take the union across all buckets then pick exactly one.
|
||||
union = {(gi, oi): (gi, oi, opt)
|
||||
for matches in or_pool for (gi, oi, opt) in matches}.values()
|
||||
|
||||
expanded = []
|
||||
for gi, oi, opt in union:
|
||||
# avoid duplicating the same group as fixed
|
||||
if gi in fixed_group_idx:
|
||||
continue
|
||||
expanded.append(fixed_selection + [(gi, oi, opt)])
|
||||
return expanded
|
||||
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
# ---- tiny utilities ----------------------------------------------------------
|
||||
|
||||
def parse_types(t):
|
||||
# e.g. "external_wall_insulation+mechanical_ventilation" -> {"external_wall_insulation","mechanical_ventilation"}
|
||||
return set(map(str.strip, t.split("+"))) if isinstance(t, str) else set()
|
||||
|
||||
|
||||
def includes_heating(opt_types):
|
||||
return any(x in opt_types for x in {
|
||||
"air_source_heat_pump",
|
||||
"high_heat_retention_storage_heater",
|
||||
"time_temperature_zone_control", # controls count as a heating measure in your pipeline
|
||||
"solar_pv" # you treat PV as heating for funding logic
|
||||
})
|
||||
|
||||
|
||||
def contributes_min_insulation(opt_types):
|
||||
# MIR satisfiers you mentioned (extend as needed)
|
||||
return any(x in opt_types for x in {
|
||||
"external_wall_insulation",
|
||||
"internal_wall_insulation",
|
||||
"loft_insulation",
|
||||
"cavity_wall_insulation",
|
||||
})
|
||||
|
||||
|
||||
def run_optimizer(input_measures, budget=None, sub_target_gain=None, allow_slack=False):
|
||||
"""
|
||||
Thin wrapper over your optimisers.
|
||||
Returns: list[dict] selected_options
|
||||
"""
|
||||
if budget is not None:
|
||||
opt = GainOptimiser(
|
||||
input_measures, max_cost=budget, max_gain=(sub_target_gain or float("inf")),
|
||||
allow_slack=allow_slack
|
||||
)
|
||||
else:
|
||||
if sub_target_gain is None:
|
||||
raise ValueError("Either budget or target_gain must be provided.")
|
||||
opt = CostOptimiser(input_measures, min_gain=sub_target_gain)
|
||||
|
||||
opt.setup()
|
||||
opt.solve()
|
||||
return opt.solution, opt.solution_cost, opt.solution_gain
|
||||
Loading…
Add table
Reference in a new issue