mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge pull request #479 from Hestia-Homes/main
Funding feature - MVP released
This commit is contained in:
commit
dfbc62c8a3
58 changed files with 22503 additions and 1700 deletions
|
|
@ -303,7 +303,7 @@ class AssetList:
|
||||||
|
|
||||||
# Another version of non-intrusives:
|
# Another version of non-intrusives:
|
||||||
NON_INTRUSIVES_NEW_FORMAT_COLNAMES_V2 = [
|
NON_INTRUSIVES_NEW_FORMAT_COLNAMES_V2 = [
|
||||||
'Archetype', 'Archetype 2', 'Construction', 'Insulated', 'Material', 'Boroscoped?',
|
'Archetype', 'Archetype 2', 'Construction', 'Insulated', 'Material', 'Borescoped?',
|
||||||
'CIGA Check Required', 'ROOF ORIENTATION', 'TILE HUNG', 'RENDERED',
|
'CIGA Check Required', 'ROOF ORIENTATION', 'TILE HUNG', 'RENDERED',
|
||||||
'CLADDING', 'ACCESS ISSUES', 'FURTHER SURVEYOR NOTES', 'DATE',
|
'CLADDING', 'ACCESS ISSUES', 'FURTHER SURVEYOR NOTES', 'DATE',
|
||||||
'NAME OF SURVEYOR'
|
'NAME OF SURVEYOR'
|
||||||
|
|
@ -860,7 +860,7 @@ class AssetList:
|
||||||
return date_str.year
|
return date_str.year
|
||||||
|
|
||||||
# Handle numeric year (float or int)
|
# Handle numeric year (float or int)
|
||||||
if isinstance(date_str, (int, float)):
|
if isinstance(date_str, (int, float, np.int_)):
|
||||||
if 1000 <= int(date_str) <= 2100:
|
if 1000 <= int(date_str) <= 2100:
|
||||||
return int(date_str)
|
return int(date_str)
|
||||||
|
|
||||||
|
|
@ -887,9 +887,6 @@ class AssetList:
|
||||||
self.landlord_year_built
|
self.landlord_year_built
|
||||||
].apply(extract_year)
|
].apply(extract_year)
|
||||||
|
|
||||||
for x in self.standardised_asset_list[self.landlord_year_built].values:
|
|
||||||
extract_year(x)
|
|
||||||
|
|
||||||
# We now create standard lookups
|
# We now create standard lookups
|
||||||
to_remap = {
|
to_remap = {
|
||||||
self.landlord_property_type: {
|
self.landlord_property_type: {
|
||||||
|
|
@ -949,9 +946,19 @@ class AssetList:
|
||||||
|
|
||||||
if self.phase:
|
if self.phase:
|
||||||
# We filter on just the properties that have had an inspection
|
# We filter on just the properties that have had an inspection
|
||||||
self.standardised_asset_list = self.standardised_asset_list[
|
if self.new_format_non_insturives_present_v2:
|
||||||
~self.standardised_asset_list['Surveyors Name'].isin(["YET TO BE SURVEYED"])
|
self.standardised_asset_list = self.standardised_asset_list[
|
||||||
]
|
~self.standardised_asset_list['NAME OF SURVEYOR'].isin(
|
||||||
|
["YET TO BE SURVEYED", "", None]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.standardised_asset_list = self.standardised_asset_list[
|
||||||
|
~pd.isnull(self.standardised_asset_list["NAME OF SURVEYOR"])
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self.standardised_asset_list = self.standardised_asset_list[
|
||||||
|
~self.standardised_asset_list['Surveyors Name'].isin(["YET TO BE SURVEYED"])
|
||||||
|
]
|
||||||
|
|
||||||
if not self.variable_mappings and not override_empty_mappings:
|
if not self.variable_mappings and not override_empty_mappings:
|
||||||
raise ValueError("Please run init_standardise first")
|
raise ValueError("Please run init_standardise first")
|
||||||
|
|
@ -1336,7 +1343,9 @@ class AssetList:
|
||||||
|
|
||||||
if self.new_format_non_insturives_present_v2:
|
if self.new_format_non_insturives_present_v2:
|
||||||
existing_solar_non_intrusives_check = (
|
existing_solar_non_intrusives_check = (
|
||||||
self.standardised_asset_list["non-intrusives: ROOF ORIENTATION"] == "ALREADY HAS SOLAR PV"
|
self.standardised_asset_list["non-intrusives: ROOF ORIENTATION"].str.strip().isin(
|
||||||
|
["ALREADY HAS SOLAR PV"]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
existing_solar_non_intrusives_check = (
|
existing_solar_non_intrusives_check = (
|
||||||
|
|
@ -2110,6 +2119,7 @@ class AssetList:
|
||||||
RANGE_RE = re.compile(r'\b(\d+[A-Za-z]?)\s*[-–]\s*(\d+[A-Za-z]?)\b')
|
RANGE_RE = re.compile(r'\b(\d+[A-Za-z]?)\s*[-–]\s*(\d+[A-Za-z]?)\b')
|
||||||
NUM_RE = re.compile(r'\b\d+[A-Za-z]?\b') # captures 12, 12A, etc.
|
NUM_RE = re.compile(r'\b\d+[A-Za-z]?\b') # captures 12, 12A, etc.
|
||||||
TO_RANGE_RE = re.compile(r'\b(\d+[A-Za-z]?)\s+(?:to|To|TO)\s+(\d+[A-Za-z]?)\b') # captures "13 to 15"
|
TO_RANGE_RE = re.compile(r'\b(\d+[A-Za-z]?)\s+(?:to|To|TO)\s+(\d+[A-Za-z]?)\b') # captures "13 to 15"
|
||||||
|
LETTER_RANGE_RE = re.compile(r'\b(\d+)([A-Za-z]?)\s*[-–]\s*(\d+)([A-Za-z]?)\b') # captures "1A-3B"
|
||||||
|
|
||||||
expanded_rows = []
|
expanded_rows = []
|
||||||
|
|
||||||
|
|
@ -2166,7 +2176,7 @@ class AssetList:
|
||||||
# We update the full address
|
# We update the full address
|
||||||
|
|
||||||
new[self.DOMNA_PROPERTY_ID] = f"{row[self.DOMNA_PROPERTY_ID]}-{new_addr}"
|
new[self.DOMNA_PROPERTY_ID] = f"{row[self.DOMNA_PROPERTY_ID]}-{new_addr}"
|
||||||
expanded_rows.append(new)
|
expanded_rows.append(new.to_dict())
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2 ─ Explicit list (e.g. 1, 2, 5 Block) or split by an ampersand (e.g. 1 & 2 Block)
|
# 2 ─ Explicit list (e.g. 1, 2, 5 Block) or split by an ampersand (e.g. 1 & 2 Block)
|
||||||
|
|
@ -2177,12 +2187,36 @@ class AssetList:
|
||||||
new_addr = re.sub(NUM_RE, n, addr, count=1) # replace the first number only
|
new_addr = re.sub(NUM_RE, n, addr, count=1) # replace the first number only
|
||||||
new[self.STANDARD_ADDRESS_1] = new_addr
|
new[self.STANDARD_ADDRESS_1] = new_addr
|
||||||
new[self.DOMNA_PROPERTY_ID] = f"{row[self.DOMNA_PROPERTY_ID]}-{new_addr}"
|
new[self.DOMNA_PROPERTY_ID] = f"{row[self.DOMNA_PROPERTY_ID]}-{new_addr}"
|
||||||
expanded_rows.append(new)
|
expanded_rows.append(new.to_dict())
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 3 ─ Single number or no number, treat as individual dwelling
|
# Check for a range of lettered addresses e.g 31A - 31D
|
||||||
|
letter_range = LETTER_RANGE_RE.search(full_addr)
|
||||||
|
if letter_range:
|
||||||
|
start_num, start_letter, end_num, end_letter = letter_range.groups()
|
||||||
|
start_num, end_num = int(start_num), int(end_num)
|
||||||
|
if start_num != end_num:
|
||||||
|
raise NotImplementedError(f"Unusual range - handle me")
|
||||||
|
|
||||||
|
# We define the looping range on whether we have odd, even or all numbers
|
||||||
|
house_number_range = range(start_num, end_num + 1)
|
||||||
|
if has_odd:
|
||||||
|
house_number_range = [x for x in house_number_range if x % 2 != 0]
|
||||||
|
if has_even:
|
||||||
|
house_number_range = [x for x in house_number_range if x % 2 == 0]
|
||||||
|
|
||||||
|
for n in house_number_range:
|
||||||
|
for letter in range(ord(start_letter), ord(end_letter) + 1):
|
||||||
|
new = row.copy()
|
||||||
|
new_addr = f"{n}{chr(letter)}"
|
||||||
|
new[self.STANDARD_ADDRESS_1] = new_addr
|
||||||
|
new[self.DOMNA_PROPERTY_ID] = f"{row[self.DOMNA_PROPERTY_ID]}-{new_addr}"
|
||||||
|
expanded_rows.append(new.to_dict())
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 4 ─ Single number or no number, treat as individual dwelling
|
||||||
if (len(nums) == 1) or not nums:
|
if (len(nums) == 1) or not nums:
|
||||||
expanded_rows.append(row)
|
expanded_rows.append(row.to_dict())
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Anything else with digits is unrecognised
|
# Anything else with digits is unrecognised
|
||||||
|
|
|
||||||
|
|
@ -59,324 +59,426 @@ def app():
|
||||||
Property UPRN
|
Property UPRN
|
||||||
"""
|
"""
|
||||||
|
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Broadlands"
|
# Colchester
|
||||||
data_filename = "Broadlands Asset List.xlsx"
|
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Colchester/Aug2025 202 inspections"
|
||||||
sheet_name = "Assets"
|
data_filename = "Colchester Borough Homes - Inspections - Additional 202 Addresses JW 280725 copy.xlsx"
|
||||||
postcode_column = 'POSTCODE'
|
sheet_name = "Extra 202 Colchester Addresses"
|
||||||
fulladdress_column = None
|
postcode_column = 'domna_postcode'
|
||||||
address1_column = "Address1"
|
address1_column = "domna_address_1"
|
||||||
address1_method = None
|
address1_method = None
|
||||||
address_cols_to_concat = ["Address1"]
|
fulladdress_column = "domna_full_address"
|
||||||
|
address_cols_to_concat = []
|
||||||
missing_postcodes_method = None
|
missing_postcodes_method = None
|
||||||
landlord_year_built = "DATEBUILT"
|
landlord_year_built = None
|
||||||
landlord_os_uprn = None
|
landlord_os_uprn = None
|
||||||
landlord_property_type = "PropertyType"
|
landlord_property_type = "landlord_property_type"
|
||||||
landlord_built_form = "PropertyType"
|
landlord_built_form = "landlord_built_form"
|
||||||
landlord_wall_construction = None
|
landlord_wall_construction = None
|
||||||
landlord_heating_system = "Heating Fuel"
|
|
||||||
landlord_existing_pv = None
|
|
||||||
landlord_property_id = "Row ID"
|
|
||||||
outcomes_filename = [os.path.join(data_folder, "outcomes.xlsx")]
|
|
||||||
outcomes_sheetname = ["Sheet1"]
|
|
||||||
outcomes_postcode = ["Postcode"]
|
|
||||||
outcomes_houseno = ["No."]
|
|
||||||
outcomes_address = ["Address"]
|
|
||||||
outcomes_id = [None]
|
|
||||||
master_filepaths = [
|
|
||||||
os.path.join(data_folder, "eco3 submissions.csv"),
|
|
||||||
os.path.join(data_folder, "eco4 submissions.csv"),
|
|
||||||
]
|
|
||||||
master_to_asset_list_filepath = None
|
|
||||||
asset_list_header = 0
|
|
||||||
landlord_block_reference = None
|
|
||||||
master_id_colnames = [None, None]
|
|
||||||
landlord_roof_construction = None
|
landlord_roof_construction = None
|
||||||
phase = False
|
landlord_heating_system = None
|
||||||
|
landlord_existing_pv = None
|
||||||
|
landlord_property_id = "landlord_property_id"
|
||||||
landlord_sap = None
|
landlord_sap = None
|
||||||
ecosurv_landlords = "broadland"
|
outcomes_filename = None
|
||||||
|
outcomes_sheetname = None
|
||||||
|
outcomes_postcode = None
|
||||||
|
outcomes_houseno = None
|
||||||
|
outcomes_id = None
|
||||||
|
outcomes_address = None
|
||||||
|
master_filepaths = []
|
||||||
|
master_id_colnames = []
|
||||||
|
master_to_asset_list_filepath = None
|
||||||
|
phase = False
|
||||||
|
ecosurv_landlords = None
|
||||||
|
asset_list_header = 0
|
||||||
|
landlord_block_reference = "landlord_block_reference"
|
||||||
|
|
||||||
|
# # Abri
|
||||||
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Abri/Post Inspections"
|
||||||
|
# data_filename = "Desktop ABRI data - Standardised After Programmes (2).xlsx"
|
||||||
|
# sheet_name = "Reviewed List"
|
||||||
|
# postcode_column = 'domna_postcode'
|
||||||
|
# address1_column = "domna_address_1"
|
||||||
|
# address1_method = None
|
||||||
|
# fulladdress_column = "domna_full_address"
|
||||||
|
# address_cols_to_concat = []
|
||||||
|
# missing_postcodes_method = None
|
||||||
|
# landlord_year_built = "landlord_year_built"
|
||||||
|
# landlord_os_uprn = None
|
||||||
|
# landlord_property_type = "PropertyType_original_from_landlord"
|
||||||
|
# landlord_built_form = "BuildForm_original_from_landlord"
|
||||||
|
# landlord_wall_construction = "Wall Construction_original_from_landlord"
|
||||||
|
# landlord_roof_construction = None
|
||||||
|
# landlord_heating_system = "HeatingType_original_from_landlord"
|
||||||
|
# landlord_existing_pv = None
|
||||||
|
# landlord_property_id = "landlord_property_id"
|
||||||
|
# landlord_sap = None
|
||||||
|
# outcomes_filename = None
|
||||||
|
# outcomes_sheetname = None
|
||||||
|
# outcomes_postcode = None
|
||||||
|
# outcomes_houseno = None
|
||||||
|
# outcomes_id = None
|
||||||
|
# outcomes_address = None
|
||||||
|
# master_filepaths = []
|
||||||
|
# master_id_colnames = []
|
||||||
|
# master_to_asset_list_filepath = None
|
||||||
|
# phase = False
|
||||||
|
# ecosurv_landlords = None
|
||||||
|
# asset_list_header = 0
|
||||||
|
# landlord_block_reference = None
|
||||||
|
|
||||||
|
# Freebridge
|
||||||
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Freebridge"
|
||||||
|
# data_filename = "Domna - FCH property data May 25 copy.xlsx"
|
||||||
|
# sheet_name = "EPC Data"
|
||||||
|
# postcode_column = 'Post Code'
|
||||||
|
# address1_column = "Address 1"
|
||||||
|
# address1_method = None
|
||||||
|
# fulladdress_column = None
|
||||||
|
# address_cols_to_concat = ["Address 1", "Address 4"]
|
||||||
|
# missing_postcodes_method = None
|
||||||
|
# landlord_year_built = "Build Date"
|
||||||
|
# landlord_os_uprn = None
|
||||||
|
# landlord_property_type = "Property Type"
|
||||||
|
# landlord_built_form = None
|
||||||
|
# landlord_wall_construction = "Walls Description"
|
||||||
|
# landlord_heating_system = "Heating Type"
|
||||||
|
# landlord_existing_pv = None
|
||||||
|
# landlord_property_id = "Place Ref"
|
||||||
|
# landlord_roof_construction = "Roof Description"
|
||||||
|
# landlord_sap = "Current SAP"
|
||||||
|
# outcomes_filename = []
|
||||||
|
# outcomes_sheetname = []
|
||||||
|
# outcomes_postcode = []
|
||||||
|
# outcomes_houseno = []
|
||||||
|
# outcomes_address = []
|
||||||
|
# outcomes_id = []
|
||||||
|
# master_filepaths = []
|
||||||
|
# master_to_asset_list_filepath = None
|
||||||
|
# asset_list_header = 0
|
||||||
|
# landlord_block_reference = None
|
||||||
|
# master_id_colnames = []
|
||||||
|
# phase = True # Inspections not complete, produce a partial view
|
||||||
|
# ecosurv_landlords = None
|
||||||
|
|
||||||
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Broadlands"
|
||||||
|
# data_filename = "Broadlands Asset List.xlsx"
|
||||||
|
# sheet_name = "Assets"
|
||||||
|
# postcode_column = 'POSTCODE'
|
||||||
|
# fulladdress_column = None
|
||||||
|
# address1_column = "Address1"
|
||||||
|
# address1_method = None
|
||||||
|
# address_cols_to_concat = ["Address1"]
|
||||||
|
# missing_postcodes_method = None
|
||||||
|
# landlord_year_built = "DATEBUILT"
|
||||||
|
# landlord_os_uprn = None
|
||||||
|
# landlord_property_type = "PropertyType"
|
||||||
|
# landlord_built_form = "PropertyType"
|
||||||
|
# landlord_wall_construction = None
|
||||||
|
# landlord_heating_system = "Heating Fuel"
|
||||||
|
# landlord_existing_pv = None
|
||||||
|
# landlord_property_id = "Row ID"
|
||||||
|
# outcomes_filename = [os.path.join(data_folder, "outcomes.xlsx")]
|
||||||
|
# outcomes_sheetname = ["Sheet1"]
|
||||||
|
# outcomes_postcode = ["Postcode"]
|
||||||
|
# outcomes_houseno = ["No."]
|
||||||
|
# outcomes_address = ["Address"]
|
||||||
|
# outcomes_id = [None]
|
||||||
|
# master_filepaths = [
|
||||||
|
# os.path.join(data_folder, "eco3 submissions.csv"),
|
||||||
|
# os.path.join(data_folder, "eco4 submissions.csv"),
|
||||||
|
# ]
|
||||||
|
# master_to_asset_list_filepath = None
|
||||||
|
# asset_list_header = 0
|
||||||
|
# landlord_block_reference = None
|
||||||
|
# master_id_colnames = [None, None]
|
||||||
|
# landlord_roof_construction = None
|
||||||
|
# phase = False
|
||||||
|
# landlord_sap = None
|
||||||
|
# ecosurv_landlords = "broadland"
|
||||||
|
# #
|
||||||
#
|
#
|
||||||
|
# # Community:
|
||||||
# Community:
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Community Housing/New Programme"
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Community Housing/New Programme"
|
# data_filename = "SUB EPC C to DOMNA - 24.07.25.xlsx"
|
||||||
data_filename = "SUB EPC C to DOMNA - 24.07.25.xlsx"
|
# sheet_name = "Sheet1"
|
||||||
sheet_name = "Sheet1"
|
# postcode_column = 'POSTCODE'
|
||||||
postcode_column = 'POSTCODE'
|
# fulladdress_column = "ADDRESS"
|
||||||
fulladdress_column = "ADDRESS"
|
# address1_column = None
|
||||||
address1_column = None
|
# address1_method = "house_number_extraction"
|
||||||
address1_method = "house_number_extraction"
|
# address_cols_to_concat = []
|
||||||
address_cols_to_concat = []
|
# missing_postcodes_method = None
|
||||||
missing_postcodes_method = None
|
# landlord_year_built = "BUILD DATE"
|
||||||
landlord_year_built = "BUILD DATE"
|
# landlord_os_uprn = None
|
||||||
landlord_os_uprn = None
|
# landlord_property_type = "PROPERTY TYPE"
|
||||||
landlord_property_type = "PROPERTY TYPE"
|
# landlord_built_form = "Archetype" # Using the inspections archetype
|
||||||
landlord_built_form = "Archetype" # Using the inspections archetype
|
# landlord_wall_construction = "CONSTRUCTION TYPE"
|
||||||
landlord_wall_construction = "CONSTRUCTION TYPE"
|
# landlord_roof_construction = None
|
||||||
landlord_roof_construction = None
|
# landlord_heating_system = None
|
||||||
landlord_heating_system = None
|
# landlord_existing_pv = None
|
||||||
landlord_existing_pv = None
|
# landlord_property_id = "UPRN"
|
||||||
landlord_property_id = "UPRN"
|
# landlord_sap = None
|
||||||
landlord_sap = None
|
# outcomes_filename = []
|
||||||
outcomes_filename = []
|
# outcomes_sheetname = []
|
||||||
outcomes_sheetname = []
|
# outcomes_postcode = []
|
||||||
outcomes_postcode = []
|
# outcomes_houseno = []
|
||||||
outcomes_houseno = []
|
# outcomes_id = []
|
||||||
outcomes_id = []
|
# outcomes_address = []
|
||||||
outcomes_address = []
|
# master_filepaths = []
|
||||||
master_filepaths = []
|
# master_to_asset_list_filepath = None
|
||||||
master_to_asset_list_filepath = None
|
# phase = False
|
||||||
phase = False
|
# ecosurv_landlords = None
|
||||||
ecosurv_landlords = None
|
# asset_list_header = 1
|
||||||
asset_list_header = 1
|
# landlord_block_reference = None
|
||||||
landlord_block_reference = None
|
# master_id_colnames = []
|
||||||
master_id_colnames = []
|
#
|
||||||
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Ealing/Programme Analysis"
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Ealing/Programme Analysis"
|
# data_filename = "EalingProjectRebuildJW210725.xlsx"
|
||||||
data_filename = "EalingProjectRebuildJW210725.xlsx"
|
# sheet_name = "Refine & Houses"
|
||||||
sheet_name = "Refine & Houses"
|
# postcode_column = 'Postcode'
|
||||||
postcode_column = 'Postcode'
|
# fulladdress_column = "Address"
|
||||||
fulladdress_column = "Address"
|
# address1_column = None
|
||||||
address1_column = None
|
# address1_method = "house_number_extraction"
|
||||||
address1_method = "house_number_extraction"
|
# address_cols_to_concat = []
|
||||||
address_cols_to_concat = []
|
# missing_postcodes_method = None
|
||||||
missing_postcodes_method = None
|
# landlord_year_built = None
|
||||||
landlord_year_built = None
|
# landlord_os_uprn = None
|
||||||
landlord_os_uprn = None
|
# landlord_property_type = None # Using the inspections property type
|
||||||
landlord_property_type = None # Using the inspections property type
|
# landlord_built_form = None
|
||||||
landlord_built_form = None
|
# landlord_wall_construction = None
|
||||||
landlord_wall_construction = None
|
# landlord_roof_construction = None
|
||||||
landlord_roof_construction = None
|
# landlord_heating_system = None
|
||||||
landlord_heating_system = None
|
# landlord_existing_pv = None
|
||||||
landlord_existing_pv = None
|
# landlord_property_id = "Property ref"
|
||||||
landlord_property_id = "Property ref"
|
# landlord_sap = None
|
||||||
landlord_sap = None
|
# outcomes_filename = []
|
||||||
outcomes_filename = []
|
# outcomes_sheetname = []
|
||||||
outcomes_sheetname = []
|
# outcomes_postcode = []
|
||||||
outcomes_postcode = []
|
# outcomes_houseno = []
|
||||||
outcomes_houseno = []
|
# outcomes_id = []
|
||||||
outcomes_id = []
|
# outcomes_address = []
|
||||||
outcomes_address = []
|
# master_filepaths = []
|
||||||
master_filepaths = []
|
# master_to_asset_list_filepath = None
|
||||||
master_to_asset_list_filepath = None
|
# phase = False
|
||||||
phase = False
|
# ecosurv_landlords = None
|
||||||
ecosurv_landlords = None
|
# asset_list_header = 0
|
||||||
asset_list_header = 0
|
# landlord_block_reference = "Block Reference"
|
||||||
landlord_block_reference = "Block Reference"
|
# master_id_colnames = []
|
||||||
master_id_colnames = []
|
#
|
||||||
|
# # TODO: Delete me
|
||||||
# TODO: Delete me
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/NRLA/"
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/NRLA/"
|
# data_filename = "20250716 Asset List.xlsx"
|
||||||
data_filename = "20250716 Asset List.xlsx"
|
# sheet_name = "Sheet 1"
|
||||||
sheet_name = "Sheet 1"
|
# postcode_column = 'Postcode'
|
||||||
postcode_column = 'Postcode'
|
# fulladdress_column = "Full Address"
|
||||||
fulladdress_column = "Full Address"
|
# address1_column = None
|
||||||
address1_column = None
|
# address1_method = "house_number_extraction"
|
||||||
address1_method = "house_number_extraction"
|
# address_cols_to_concat = []
|
||||||
address_cols_to_concat = []
|
# missing_postcodes_method = None
|
||||||
missing_postcodes_method = None
|
# landlord_year_built = None
|
||||||
landlord_year_built = None
|
# landlord_os_uprn = None
|
||||||
landlord_os_uprn = None
|
# landlord_property_type = None
|
||||||
landlord_property_type = None
|
# landlord_built_form = None
|
||||||
landlord_built_form = None
|
# landlord_wall_construction = None
|
||||||
landlord_wall_construction = None
|
# landlord_heating_system = None
|
||||||
landlord_heating_system = None
|
# landlord_existing_pv = None
|
||||||
landlord_existing_pv = None
|
# landlord_property_id = "Row ID"
|
||||||
landlord_property_id = "Row ID"
|
# outcomes_filename = []
|
||||||
outcomes_filename = []
|
# outcomes_sheetname = []
|
||||||
outcomes_sheetname = []
|
# outcomes_postcode = []
|
||||||
outcomes_postcode = []
|
# outcomes_houseno = []
|
||||||
outcomes_houseno = []
|
# outcomes_address = []
|
||||||
outcomes_address = []
|
# outcomes_id = []
|
||||||
outcomes_id = []
|
# master_filepaths = []
|
||||||
master_filepaths = []
|
# master_to_asset_list_filepath = None
|
||||||
master_to_asset_list_filepath = None
|
# asset_list_header = 0
|
||||||
asset_list_header = 0
|
# landlord_block_reference = None
|
||||||
landlord_block_reference = None
|
# master_id_colnames = []
|
||||||
master_id_colnames = []
|
# landlord_roof_construction = None
|
||||||
landlord_roof_construction = None
|
# phase = False
|
||||||
phase = False
|
# landlord_sap = None
|
||||||
landlord_sap = None
|
# ecosurv_landlords = None
|
||||||
ecosurv_landlords = None
|
#
|
||||||
|
# # Southend
|
||||||
# Southend
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Southend/July 2025 Programme"
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Southend/July 2025 Programme"
|
# data_filename = "SOUTHEND - RYAN.xlsx"
|
||||||
data_filename = "SOUTHEND - RYAN.xlsx"
|
# sheet_name = "July 2025 Surveys"
|
||||||
sheet_name = "July 2025 Surveys"
|
# postcode_column = 'Postcode'
|
||||||
postcode_column = 'Postcode'
|
# fulladdress_column = "Full postal address"
|
||||||
fulladdress_column = "Full postal address"
|
# address1_column = None
|
||||||
address1_column = None
|
# address1_method = "house_number_extraction"
|
||||||
address1_method = "house_number_extraction"
|
# address_cols_to_concat = []
|
||||||
address_cols_to_concat = []
|
# missing_postcodes_method = None
|
||||||
missing_postcodes_method = None
|
# landlord_year_built = "Property age"
|
||||||
landlord_year_built = "Property age"
|
# landlord_os_uprn = None
|
||||||
landlord_os_uprn = None
|
# landlord_property_type = "Property type"
|
||||||
landlord_property_type = "Property type"
|
# landlord_built_form = "Property type"
|
||||||
landlord_built_form = "Property type"
|
# landlord_wall_construction = None
|
||||||
landlord_wall_construction = None
|
# landlord_heating_system = None
|
||||||
landlord_heating_system = None
|
# landlord_existing_pv = None
|
||||||
landlord_existing_pv = None
|
# landlord_property_id = "ID"
|
||||||
landlord_property_id = "ID"
|
# outcomes_filename = []
|
||||||
outcomes_filename = []
|
# outcomes_sheetname = []
|
||||||
outcomes_sheetname = []
|
# outcomes_postcode = []
|
||||||
outcomes_postcode = []
|
# outcomes_houseno = []
|
||||||
outcomes_houseno = []
|
# outcomes_address = []
|
||||||
outcomes_address = []
|
# outcomes_id = []
|
||||||
outcomes_id = []
|
# master_filepaths = []
|
||||||
master_filepaths = []
|
# master_to_asset_list_filepath = None
|
||||||
master_to_asset_list_filepath = None
|
# asset_list_header = 0
|
||||||
asset_list_header = 0
|
# landlord_block_reference = None
|
||||||
landlord_block_reference = None
|
# master_id_colnames = []
|
||||||
master_id_colnames = []
|
# landlord_roof_construction = None
|
||||||
landlord_roof_construction = None
|
# phase = False
|
||||||
phase = False
|
# landlord_sap = None
|
||||||
landlord_sap = None
|
# ecosurv_landlords = None
|
||||||
ecosurv_landlords = None
|
#
|
||||||
|
# # For Rooftop
|
||||||
# For Rooftop
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Rooftop"
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Rooftop"
|
# data_filename = "Rooftop Asset List - July 2025.xlsx"
|
||||||
data_filename = "Rooftop Asset List - July 2025.xlsx"
|
# sheet_name = "Sheet1"
|
||||||
sheet_name = "Sheet1"
|
# postcode_column = 'post_code'
|
||||||
postcode_column = 'post_code'
|
# fulladdress_column = None
|
||||||
fulladdress_column = None
|
# address1_column = "add_1"
|
||||||
address1_column = "add_1"
|
# address1_method = None
|
||||||
address1_method = None
|
# address_cols_to_concat = [
|
||||||
address_cols_to_concat = [
|
# "add_1", "add_2", "add_3", "add_4"
|
||||||
"add_1", "add_2", "add_3", "add_4"
|
# ]
|
||||||
]
|
# missing_postcodes_method = None
|
||||||
missing_postcodes_method = None
|
# landlord_year_built = "date_built"
|
||||||
landlord_year_built = "date_built"
|
# landlord_os_uprn = None
|
||||||
landlord_os_uprn = None
|
# landlord_property_type = "ConstructionStyle"
|
||||||
landlord_property_type = "ConstructionStyle"
|
# landlord_built_form = "ConstructionStyle"
|
||||||
landlord_built_form = "ConstructionStyle"
|
# landlord_wall_construction = None
|
||||||
landlord_wall_construction = None
|
# landlord_heating_system = "Description"
|
||||||
landlord_heating_system = "Description"
|
# landlord_existing_pv = None
|
||||||
landlord_existing_pv = None
|
# landlord_property_id = "PropertyCode"
|
||||||
landlord_property_id = "PropertyCode"
|
# outcomes_filename = [os.path.join(data_folder, "Rooftop_Outcomes.xlsx")]
|
||||||
outcomes_filename = [os.path.join(data_folder, "Rooftop_Outcomes.xlsx")]
|
# outcomes_sheetname = ["OUTCOMES"]
|
||||||
outcomes_sheetname = ["OUTCOMES"]
|
# outcomes_postcode = ["POSTCODE"]
|
||||||
outcomes_postcode = ["POSTCODE"]
|
# outcomes_houseno = ["NO"]
|
||||||
outcomes_houseno = ["NO"]
|
# outcomes_address = ["ADDRESS"]
|
||||||
outcomes_address = ["ADDRESS"]
|
# outcomes_id = [None]
|
||||||
outcomes_id = [None]
|
# master_filepaths = [os.path.join(data_folder, "Master.csv")]
|
||||||
master_filepaths = [os.path.join(data_folder, "Master.csv")]
|
# master_to_asset_list_filepath = None
|
||||||
master_to_asset_list_filepath = None
|
# asset_list_header = 1
|
||||||
asset_list_header = 1
|
# landlord_block_reference = "bl_rec_ref"
|
||||||
landlord_block_reference = "bl_rec_ref"
|
# master_id_colnames = [None]
|
||||||
master_id_colnames = [None]
|
# landlord_roof_construction = None
|
||||||
landlord_roof_construction = None
|
# phase = False
|
||||||
phase = False
|
# landlord_sap = None
|
||||||
landlord_sap = None
|
# ecosurv_landlords = "rooftop"
|
||||||
ecosurv_landlords = "rooftop"
|
#
|
||||||
|
# # For Housing
|
||||||
# For Housing
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/For Housing/New Programme July 2025"
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/For Housing/New Programme July 2025"
|
# data_filename = "FOR HOUSING Asset List (Combined).xlsx"
|
||||||
data_filename = "FOR HOUSING Asset List (Combined).xlsx"
|
# sheet_name = "Asset List"
|
||||||
sheet_name = "Asset List"
|
# postcode_column = 'Postcode'
|
||||||
postcode_column = 'Postcode'
|
# fulladdress_column = "Address"
|
||||||
fulladdress_column = "Address"
|
# address1_column = None
|
||||||
address1_column = None
|
# address1_method = "house_number_extraction"
|
||||||
address1_method = "house_number_extraction"
|
# address_cols_to_concat = []
|
||||||
address_cols_to_concat = []
|
# missing_postcodes_method = None
|
||||||
missing_postcodes_method = None
|
# landlord_year_built = None
|
||||||
landlord_year_built = None
|
# landlord_os_uprn = None
|
||||||
landlord_os_uprn = None
|
# landlord_property_type = "Type"
|
||||||
landlord_property_type = "Type"
|
# landlord_built_form = "Type"
|
||||||
landlord_built_form = "Type"
|
# landlord_wall_construction = None
|
||||||
landlord_wall_construction = None
|
# landlord_heating_system = "Heating - full"
|
||||||
landlord_heating_system = "Heating - full"
|
# landlord_existing_pv = None
|
||||||
landlord_existing_pv = None
|
# landlord_property_id = "UPRN"
|
||||||
landlord_property_id = "UPRN"
|
# outcomes_filename = [os.path.join(data_folder, "Khalim Combined - for analysis.xlsx")]
|
||||||
outcomes_filename = [os.path.join(data_folder, "Khalim Combined - for analysis.xlsx")]
|
# outcomes_sheetname = ["Sheet1"]
|
||||||
outcomes_sheetname = ["Sheet1"]
|
# outcomes_postcode = ["POSTCODE"]
|
||||||
outcomes_postcode = ["POSTCODE"]
|
# outcomes_houseno = ["NO"]
|
||||||
outcomes_houseno = ["NO"]
|
# outcomes_address = ["ADDRESS"]
|
||||||
outcomes_address = ["ADDRESS"]
|
# outcomes_id = [None]
|
||||||
outcomes_id = [None]
|
# master_filepaths = [os.path.join(data_folder, "submissions.csv")]
|
||||||
master_filepaths = [os.path.join(data_folder, "submissions.csv")]
|
# master_to_asset_list_filepath = None
|
||||||
master_to_asset_list_filepath = None
|
# asset_list_header = 0
|
||||||
asset_list_header = 0
|
# landlord_block_reference = None
|
||||||
landlord_block_reference = None
|
# master_id_colnames = [None]
|
||||||
master_id_colnames = [None]
|
# landlord_roof_construction = None
|
||||||
landlord_roof_construction = None
|
# phase = False
|
||||||
phase = False
|
# landlord_sap = "SAP"
|
||||||
landlord_sap = "SAP"
|
# ecosurv_landlords = "for housing"
|
||||||
ecosurv_landlords = "for housing"
|
#
|
||||||
|
# # CDS
|
||||||
# CDS
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/CDS"
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/CDS"
|
# data_filename = "Founder Estates - Asset List.xlsx"
|
||||||
data_filename = "Founder Estates - Asset List.xlsx"
|
# sheet_name = "Combined"
|
||||||
sheet_name = "Combined"
|
# postcode_column = 'Postcode'
|
||||||
postcode_column = 'Postcode'
|
# fulladdress_column = "Address"
|
||||||
fulladdress_column = "Address"
|
# address1_column = None
|
||||||
address1_column = None
|
# address1_method = "house_number_extraction"
|
||||||
address1_method = "house_number_extraction"
|
# address_cols_to_concat = []
|
||||||
address_cols_to_concat = []
|
# missing_postcodes_method = None
|
||||||
missing_postcodes_method = None
|
# landlord_year_built = None
|
||||||
landlord_year_built = None
|
# landlord_os_uprn = None
|
||||||
landlord_os_uprn = None
|
# landlord_property_type = None
|
||||||
landlord_property_type = None
|
# landlord_built_form = None
|
||||||
landlord_built_form = None
|
# landlord_wall_construction = None
|
||||||
landlord_wall_construction = None
|
# landlord_heating_system = "Heating Type"
|
||||||
landlord_heating_system = "Heating Type"
|
# landlord_existing_pv = None
|
||||||
landlord_existing_pv = None
|
# landlord_property_id = "Row ID"
|
||||||
landlord_property_id = "Row ID"
|
# outcomes_filename = []
|
||||||
outcomes_filename = []
|
# outcomes_sheetname = []
|
||||||
outcomes_sheetname = []
|
# outcomes_postcode = []
|
||||||
outcomes_postcode = []
|
# outcomes_houseno = []
|
||||||
outcomes_houseno = []
|
# outcomes_address = []
|
||||||
outcomes_address = []
|
# outcomes_id = []
|
||||||
outcomes_id = []
|
# master_filepaths = [os.path.join(data_folder, "submissions.csv")]
|
||||||
master_filepaths = [os.path.join(data_folder, "submissions.csv")]
|
# master_to_asset_list_filepath = None
|
||||||
master_to_asset_list_filepath = None
|
# asset_list_header = 0
|
||||||
asset_list_header = 0
|
# landlord_block_reference = None
|
||||||
landlord_block_reference = None
|
# master_id_colnames = [None]
|
||||||
master_id_colnames = [None]
|
# landlord_roof_construction = None
|
||||||
landlord_roof_construction = None
|
# phase = False
|
||||||
phase = False
|
# landlord_sap = None
|
||||||
landlord_sap = None
|
# ecosurv_landlords = "cds"
|
||||||
ecosurv_landlords = "cds"
|
#
|
||||||
|
# # Plus Dane
|
||||||
# Plus Dane
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Plus Dane/New Programme July 2025/"
|
||||||
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Plus Dane/New Programme July 2025/"
|
# data_filename = "20250711 Plus Dane Asset List.xlsx"
|
||||||
data_filename = "20250711 Plus Dane Asset List.xlsx"
|
# sheet_name = "Sheet1"
|
||||||
sheet_name = "Sheet1"
|
# postcode_column = 'Postcode'
|
||||||
postcode_column = 'Postcode'
|
# fulladdress_column = "Address"
|
||||||
fulladdress_column = "Address"
|
# address1_column = None
|
||||||
address1_column = None
|
# address1_method = "house_number_extraction"
|
||||||
address1_method = "house_number_extraction"
|
# address_cols_to_concat = []
|
||||||
address_cols_to_concat = []
|
# missing_postcodes_method = None
|
||||||
missing_postcodes_method = None
|
# landlord_year_built = "Property Age"
|
||||||
landlord_year_built = "Property Age"
|
# landlord_os_uprn = None
|
||||||
landlord_os_uprn = None
|
# landlord_property_type = "Property Type"
|
||||||
landlord_property_type = "Property Type"
|
# landlord_built_form = "Built Form"
|
||||||
landlord_built_form = "Built Form"
|
# landlord_wall_construction = "Wall Construction"
|
||||||
landlord_wall_construction = "Wall Construction"
|
# landlord_heating_system = "Full Heating System"
|
||||||
landlord_heating_system = "Full Heating System"
|
# landlord_existing_pv = None
|
||||||
landlord_existing_pv = None
|
# landlord_property_id = "UPRN"
|
||||||
landlord_property_id = "UPRN"
|
# outcomes_filename = [
|
||||||
outcomes_filename = [
|
# os.path.join(data_folder, "Outcomes - Plus Dane_CWI_2024.xlsx"),
|
||||||
os.path.join(data_folder, "Outcomes - Plus Dane_CWI_2024.xlsx"),
|
# os.path.join(data_folder, "Outcomes - Plus Dane_CWI_2025.xlsx"),
|
||||||
os.path.join(data_folder, "Outcomes - Plus Dane_CWI_2025.xlsx"),
|
# os.path.join(data_folder, "Outcomes - Plus Dane_PV_2025.xlsx"),
|
||||||
os.path.join(data_folder, "Outcomes - Plus Dane_PV_2025.xlsx"),
|
# ]
|
||||||
]
|
# outcomes_sheetname = [
|
||||||
outcomes_sheetname = [
|
# "CWI & LI - 2024", "2025 - CWI", "PV - 2025",
|
||||||
"CWI & LI - 2024", "2025 - CWI", "PV - 2025",
|
# ]
|
||||||
]
|
# outcomes_postcode = ["Postcode", "Postcode", "Postcode"]
|
||||||
outcomes_postcode = ["Postcode", "Postcode", "Postcode"]
|
# outcomes_houseno = ["No.", "No", "No"]
|
||||||
outcomes_houseno = ["No.", "No", "No"]
|
# outcomes_address = ["Address", "Address", "Address"]
|
||||||
outcomes_address = ["Address", "Address", "Address"]
|
# outcomes_id = ["Asset Reference", "LL UPRN", "LL UPRN"]
|
||||||
outcomes_id = ["Asset Reference", "LL UPRN", "LL UPRN"]
|
# master_filepaths = [
|
||||||
master_filepaths = [
|
# os.path.join(data_folder, "submissions/JJC-Table 1.csv"),
|
||||||
os.path.join(data_folder, "submissions/JJC-Table 1.csv"),
|
# os.path.join(data_folder, "submissions/SCIS-Table 1.csv")
|
||||||
os.path.join(data_folder, "submissions/SCIS-Table 1.csv")
|
# ]
|
||||||
]
|
# master_to_asset_list_filepath = None
|
||||||
master_to_asset_list_filepath = None
|
# asset_list_header = 1
|
||||||
asset_list_header = 1
|
# landlord_block_reference = None
|
||||||
landlord_block_reference = None
|
# master_id_colnames = [None, None]
|
||||||
master_id_colnames = [None, None]
|
# landlord_roof_construction = None
|
||||||
landlord_roof_construction = None
|
# phase = False
|
||||||
phase = False
|
# landlord_sap = "SAP Rating"
|
||||||
landlord_sap = "SAP Rating"
|
# ecosurv_landlords = "plus dane"
|
||||||
ecosurv_landlords = "plus dane"
|
|
||||||
|
|
||||||
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Brentwood/July 2025 New Programme"
|
# data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Brentwood/July 2025 New Programme"
|
||||||
# data_filename = "20250710 Asset List Brentwood.xlsx"
|
# data_filename = "20250710 Asset List Brentwood.xlsx"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from enum import IntEnum, Enum
|
from enum import IntEnum, Enum
|
||||||
|
|
||||||
CRM_PIPELINE_NAME = 'Operations - Housing Associations'
|
CRM_PIPELINE_NAME = 'Operations - Social Housing'
|
||||||
|
|
||||||
|
|
||||||
class HubspotProcessStatus(IntEnum):
|
class HubspotProcessStatus(IntEnum):
|
||||||
|
|
|
||||||
|
|
@ -44,26 +44,27 @@ def app():
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# inputs:
|
# inputs:
|
||||||
reconcile_programme = True # If True, the hubspot upload will include all properties with a project code
|
reconcile_programme = False # If True, the hubspot upload will include all properties with a project code
|
||||||
customer_domain = "https://southend.gov.uk"
|
customer_domain = "https://shgroup.org.uk"
|
||||||
installer_name = "J & J CRUMP"
|
installer_name = "SCIS"
|
||||||
asset_list_filepath = (
|
asset_list_filepath = (
|
||||||
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Southend/July 2025 Programme/SOUTHEND - RYAN - "
|
"/Users/khalimconn-kowlessar/Downloads/20250701 Optivo Southern - Standardised.xlsx"
|
||||||
"Standardised 2.xlsx"
|
|
||||||
)
|
)
|
||||||
asset_list_sheet_name = "Standardised Asset List"
|
asset_list_sheet_name = "Solar Route Revised (100)"
|
||||||
asset_list_header = 0
|
asset_list_header = 0
|
||||||
|
|
||||||
contact_details_filepath = None
|
contact_details_filepath = (
|
||||||
contacts_sheet_name = "Sheet 1"
|
"/Users/khalimconn-kowlessar/Downloads/southern_optivo_solar_pv.xlsx"
|
||||||
contacts_landlord_property_id = "UPRN"
|
)
|
||||||
contacts_phone_number_column = "phone_number"
|
contacts_sheet_name = "Sheet1"
|
||||||
contacts_secondary_phone_number_column = "secondary_phone_number"
|
contacts_landlord_property_id = "landlord_property_id"
|
||||||
contacts_secondary_contact_full_name = "secondary_contact_full_name"
|
contacts_phone_number_column = "Primary phone number"
|
||||||
contacts_email_column = "email"
|
contacts_secondary_phone_number_column = "Secondary phone number"
|
||||||
contacts_fullname_column = "fullname"
|
contacts_secondary_contact_full_name = None
|
||||||
contacts_firstname_column = "First Name"
|
contacts_email_column = "Email Address"
|
||||||
contacts_lastname_column = "Last Name"
|
contacts_fullname_column = None
|
||||||
|
contacts_firstname_column = "Name"
|
||||||
|
contacts_lastname_column = None
|
||||||
|
|
||||||
existing_programme_filepath = None
|
existing_programme_filepath = None
|
||||||
|
|
||||||
|
|
@ -89,6 +90,18 @@ def app():
|
||||||
reconcile_programme=reconcile_programme
|
reconcile_programme=reconcile_programme
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for x in asset_list.hubspot_data["Phone <CONTACT phone>"].values:
|
||||||
|
normalize_uk_phone(x)
|
||||||
|
|
||||||
|
asset_list.hubspot_data["Phone <CONTACT phone>"] = (
|
||||||
|
asset_list.hubspot_data["Phone <CONTACT phone>"].astype("Int64").astype(str).apply(normalize_uk_phone)
|
||||||
|
)
|
||||||
|
asset_list.hubspot_data["Secondary Phone <CONTACT secondary_phone_number>"] = asset_list.hubspot_data[
|
||||||
|
"Secondary Phone <CONTACT secondary_phone_number>"].astype(
|
||||||
|
"Int64").astype(
|
||||||
|
str).apply(
|
||||||
|
normalize_uk_phone)
|
||||||
|
|
||||||
# Remove the existing programme
|
# Remove the existing programme
|
||||||
# existing_programme = pd.read_csv(existing_programme_filepath, encoding="utf-8-sig")
|
# existing_programme = pd.read_csv(existing_programme_filepath, encoding="utf-8-sig")
|
||||||
# asset_list.hubspot_data = asset_list.hubspot_data[
|
# asset_list.hubspot_data = asset_list.hubspot_data[
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,47 @@ HEATING_MAPPINGS = {
|
||||||
'Mains Electric': 'electric fuel',
|
'Mains Electric': 'electric fuel',
|
||||||
'Unvented cylinder': 'other',
|
'Unvented cylinder': 'other',
|
||||||
'MVHR & Heat Recovery': 'other',
|
'MVHR & Heat Recovery': 'other',
|
||||||
'Solar': 'other'
|
'Solar': 'other',
|
||||||
|
|
||||||
|
'Electric storage heaters, Electric storage heaters': 'electric storage heaters',
|
||||||
|
'Room heaters, electric': 'room heaters',
|
||||||
|
'Room heaters, mains gas, Room heaters, electric': 'room heaters',
|
||||||
|
'Air source heat pump, underfloor, electric': 'air source heat pump',
|
||||||
|
'Air source heat pump, radiators, electric': 'air source heat pump',
|
||||||
|
'Air source heat pump, Systems with radiators, electric': 'air source heat pump',
|
||||||
|
'Electric storage heaters': 'electric storage heaters',
|
||||||
|
'Air source heat pump, Underfloor heating and radiators, pipes in screed above insulation, electric': 'air source '
|
||||||
|
'heat pump',
|
||||||
|
'Room heaters, coal': 'room heaters',
|
||||||
|
'Room heaters, electric, Electric storage heaters': 'electric storage heaters',
|
||||||
|
'Air source heat pump, fan coil units, electric': 'air source heat pump',
|
||||||
|
'Boiler and radiators, mains gas': 'gas boiler, radiators',
|
||||||
|
'Boiler and radiators, mains gas, Electric storage heaters': 'condensing boiler, radiators',
|
||||||
|
'Room heaters, mains gas': 'room heaters',
|
||||||
|
'Air source heat pump, radiators, electric, Air source heat pump, fan coil units, electric': 'air source heat pump',
|
||||||
|
'Air source heat pump, warm air, electric': 'air source heat pump',
|
||||||
|
'Electric ceiling heating, electric': 'electric ceiling',
|
||||||
|
'Electric storage heaters, Room heaters, electric': 'electric storage heaters',
|
||||||
|
'Room heaters, dual fuel (mineral and wood)': 'room heaters',
|
||||||
|
'Water source heat pump, radiators, electric': 'other',
|
||||||
|
'Warm air, electric': 'warm air heating',
|
||||||
|
'Boiler and radiators, wood logs': 'solid fuel',
|
||||||
|
'Boiler and radiators, dual fuel (mineral and wood)': 'solid fuel',
|
||||||
|
'Boiler & underfloor, mains gas': 'gas boiler, radiators',
|
||||||
|
'Boiler and underfloor heating, mains gas': 'gas boiler, radiators',
|
||||||
|
'Community scheme': 'communal heating',
|
||||||
|
'Warm air, Electricaire': 'warm air heating',
|
||||||
|
'Boiler and radiators, smokeless fuel': 'solid fuel',
|
||||||
|
'Warm air, mains gas': 'warm air heating',
|
||||||
|
'Warm air , electric': 'warm air heating',
|
||||||
|
'Boiler and radiators, LPG': 'boiler - other fuel',
|
||||||
|
'Boiler & underfloor, oil': 'oil boiler',
|
||||||
|
'Boiler and radiators, bottled LPG': 'boiler - other fuel',
|
||||||
|
'Boiler and underfloor heating, oil': 'oil boiler',
|
||||||
|
'SAP05:Main-Heating': 'unknown',
|
||||||
|
'Boiler and radiators, coal': 'solid fuel',
|
||||||
|
'Boiler and radiators, oil': 'oil boiler',
|
||||||
|
'Boiler and radiators, electric': 'electric boiler',
|
||||||
|
'No system present: electric heaters assumed': 'electric radiators',
|
||||||
|
'Boiler and radiators, anthracite': 'solid fuel'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -337,5 +337,9 @@ PROPERTY_MAPPING = {
|
||||||
'Maisonette - Mid Terrace': 'maisonette',
|
'Maisonette - Mid Terrace': 'maisonette',
|
||||||
'Chalet - Wheelchair': 'other',
|
'Chalet - Wheelchair': 'other',
|
||||||
'Amenity Block - Detached': 'other',
|
'Amenity Block - Detached': 'other',
|
||||||
'Amenity Block - Semi detached': 'other'
|
'Amenity Block - Semi detached': 'other',
|
||||||
|
'house': 'house',
|
||||||
|
'block of flats': 'block of flats',
|
||||||
|
'bungalow': 'bungalow',
|
||||||
|
'flat': 'flat'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,12 @@ STANDARD_ROOF_CONSTRUCTIONS = {
|
||||||
"another dwelling above",
|
"another dwelling above",
|
||||||
"flat unknown insulation",
|
"flat unknown insulation",
|
||||||
"flat insulated",
|
"flat insulated",
|
||||||
|
"flat uninsulated",
|
||||||
"unknown insulated",
|
"unknown insulated",
|
||||||
"unknown",
|
"unknown",
|
||||||
|
"room roof insulated",
|
||||||
|
"room roof uninsulated",
|
||||||
|
"average thermal transmittance",
|
||||||
}
|
}
|
||||||
|
|
||||||
ROOF_CONSTRUCTION_MAPPINGS = {
|
ROOF_CONSTRUCTION_MAPPINGS = {
|
||||||
|
|
@ -173,6 +177,73 @@ ROOF_CONSTRUCTION_MAPPINGS = {
|
||||||
|
|
||||||
'PitchedNormalNoLoftAccess: Unknown': 'pitched no access to loft',
|
'PitchedNormalNoLoftAccess: Unknown': 'pitched no access to loft',
|
||||||
'PitchedNormalLoftAccess: Unknown': 'pitched unknown insulation',
|
'PitchedNormalLoftAccess: Unknown': 'pitched unknown insulation',
|
||||||
'AnotherDwellingAbove: Unknown': 'another dwelling above'
|
'AnotherDwellingAbove: Unknown': 'another dwelling above',
|
||||||
|
|
||||||
|
'Flat, insulated': 'flat insulated',
|
||||||
|
'Pitched, insulated (assumed)': 'pitched insulated',
|
||||||
|
'Flat, insulated (assumed)': 'flat insulated',
|
||||||
|
'(another dwelling above)': 'another dwelling above',
|
||||||
|
'Pitched, insulated at rafters': 'pitched insulated',
|
||||||
|
'(other premises above)': 'another dwelling above',
|
||||||
|
'Average thermal transmittance 0.15 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Pitched, 25 mm loft insulation': 'pitched less than 100mm insulation',
|
||||||
|
'Roof room(s), insulated (assumed)': 'room roof insulated',
|
||||||
|
'Pitched, limited insulation (assumed)': 'pitched less than 100mm insulation',
|
||||||
|
'Pitched, 270 mm loft insulation': 'pitched insulated',
|
||||||
|
'Pitched, 250 mm loft insulation': 'pitched insulated',
|
||||||
|
'Pitched, 200mm loft insulation': 'pitched insulated',
|
||||||
|
'Flat, no insulation': 'flat uninsulated',
|
||||||
|
|
||||||
|
'Pitched, 75 mm loft insulation': 'pitched less than 100mm insulation',
|
||||||
|
'Average thermal transmittance 0.09 W/m-¦K': 'average thermal transmittance',
|
||||||
|
|
||||||
|
'SAP05:Roof': 'unknown',
|
||||||
|
'Pitched, 400 mm loft insulation': 'pitched insulated',
|
||||||
|
'Pitched, 150mm loft insulation': 'pitched insulated',
|
||||||
|
'Average thermal transmittance 0.11 W/m-¦K': 'unknown',
|
||||||
|
'Pitched, 100 mm loft insulation': 'pitched less than 100mm insulation',
|
||||||
|
'Pitched, 300 mm loft insulation': 'pitched insulated',
|
||||||
|
'Pitched, 75mm loft insulation': 'pitched less than 100mm insulation',
|
||||||
|
'Pitched, 300+mm loft insulation': 'pitched insulated',
|
||||||
|
'Pitched, 300+ mm loft insulation': 'pitched insulated',
|
||||||
|
'Average thermal transmittance 0.11 W/m?K': 'average thermal transmittance',
|
||||||
|
'Average thermal transmittance 0.10 W/m?K': 'average thermal transmittance',
|
||||||
|
'Pitched, 250mm loft insulation': 'pitched insulated',
|
||||||
|
'Pitched, 300+ mm loft insulation': 'pitched insulated',
|
||||||
|
'Average thermal transmittance 0.1 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Pitched, *** INVALID INPUT Code : 57 *** loft insulation': 'unknown',
|
||||||
|
'Pitched, 100mm loft insulation': 'pitched less than 100mm insulation',
|
||||||
|
'Pitched, loft insulation': 'pitched less than 100mm insulation',
|
||||||
|
'Average thermal transmittance 0.20 W/m?K': 'average thermal transmittance',
|
||||||
|
'Average thermal transmittance 0.1 W/m?K': 'average thermal transmittance',
|
||||||
|
'Average thermal transmittance 0.16 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Average thermal transmittance 0.14 W/m?K': 'average thermal transmittance',
|
||||||
|
'Pitched, 50 mm loft insulation': 'pitched less than 100mm insulation',
|
||||||
|
'Flat, limited insulation': 'flat uninsulated',
|
||||||
|
'Average thermal transmittance 0.12 W/m?K': 'average thermal transmittance',
|
||||||
|
'Roof room(s), ceiling insulated': 'room roof insulated',
|
||||||
|
'Average thermal transmittance 0.18 W/m?K': 'average thermal transmittance',
|
||||||
|
'Average thermal transmittance 0.10 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Pitched, 400+ mm loft insulation': 'pitched insulated',
|
||||||
|
'Average thermal transmittance 0.14 W/m²K': 'average thermal transmittance',
|
||||||
|
'Pitched, no insulation (assumed)': 'pitched less than 100mm insulation',
|
||||||
|
'Average thermal transmittance 0.16 W/m?K': 'average thermal transmittance',
|
||||||
|
'Average thermal transmittance 0.21 W/m?K': 'average thermal transmittance',
|
||||||
|
'Flat, no insulation (assumed)': 'flat uninsulated',
|
||||||
|
'Pitched, no insulation': 'pitched less than 100mm insulation',
|
||||||
|
|
||||||
|
'Average thermal transmittance 0.12 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Pitched, 12 mm loft insulation': 'pitched less than 100mm insulation',
|
||||||
|
'Average thermal transmittance 0.07 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Roof room(s), no insulation (assumed)': 'room roof uninsulated',
|
||||||
|
'Pitched, no insulation(assumed)': 'pitched less than 100mm insulation',
|
||||||
|
'Average thermal transmittance 0.13 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Average thermal transmittance 0.08 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Average thermal transmittance 0.14 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Pitched, 350 mm loft insulation': 'pitched insulated',
|
||||||
|
'Average thermal transmittance 0 W/m-¦K': 'average thermal transmittance',
|
||||||
|
'Pitched, 200 mm loft insulation': 'pitched insulated',
|
||||||
|
'Pitched, 150 mm loft insulation': 'pitched insulated',
|
||||||
|
'Flat, limited insulation (assumed)': 'flat uninsulated',
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -334,4 +334,13 @@ WALL_CONSTRUCTION_MAPPINGS = {
|
||||||
'Cavity: FilledCavity, TimberFrame: AsBuilt': 'filled cavity',
|
'Cavity: FilledCavity, TimberFrame: AsBuilt': 'filled cavity',
|
||||||
'Cavity: FilledCavity, SolidBrick: AsBuilt, SolidBrick: Internal': 'filled cavity',
|
'Cavity: FilledCavity, SolidBrick: AsBuilt, SolidBrick: Internal': 'filled cavity',
|
||||||
'Cavity: Internal, SolidBrick: AsBuilt': 'filled cavity',
|
'Cavity: Internal, SolidBrick: AsBuilt': 'filled cavity',
|
||||||
|
|
||||||
|
'Timber frame, filled cavity': 'filled cavity',
|
||||||
|
'Cob, as built': 'cob',
|
||||||
|
'Cavity wall, filled cavity and internal insulation': 'filled cavity',
|
||||||
|
'SAP05:Walls': 'other',
|
||||||
|
'Solid brick, as built, partial insulation (assumed)': 'insulated solid brick',
|
||||||
|
'Sandstone, as built, no insulation (assumed)': 'uninsulated sandstone or limestone',
|
||||||
|
'System built, as built, partial insulation (assumed)': 'system built unknown insulation',
|
||||||
|
'Timber frame, with external insulation': 'insulated timber frame'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1518
backend/Funding.py
1518
backend/Funding.py
File diff suppressed because it is too large
Load diff
|
|
@ -213,9 +213,16 @@ class Property:
|
||||||
self.parse_kwargs(kwargs)
|
self.parse_kwargs(kwargs)
|
||||||
|
|
||||||
# Funding
|
# Funding
|
||||||
self.gbis_eligibiltiy = None
|
# self.gbis_eligibiltiy = None
|
||||||
self.eco4_eligibility = None
|
# self.eco4_eligibility = None
|
||||||
self.whlg_eligibility = None
|
# self.whlg_eligibility = None
|
||||||
|
self.scheme = None
|
||||||
|
self.funded_measures = None
|
||||||
|
self.project_funding = None
|
||||||
|
self.total_uplift = None
|
||||||
|
self.full_project_score = None
|
||||||
|
self.partial_project_score = None
|
||||||
|
self.uplift_project_score = None
|
||||||
|
|
||||||
# Ventilation
|
# Ventilation
|
||||||
self.has_ventilation = self.identify_ventilation()
|
self.has_ventilation = self.identify_ventilation()
|
||||||
|
|
@ -531,7 +538,7 @@ class Property:
|
||||||
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
||||||
"cylinder_thermostat", "loft_insulation", "room_roof_insulation", "flat_roof_insulation",
|
"cylinder_thermostat", "loft_insulation", "room_roof_insulation", "flat_roof_insulation",
|
||||||
"solid_floor_insulation", "suspended_floor_insulation", "mixed_glazing",
|
"solid_floor_insulation", "suspended_floor_insulation", "mixed_glazing",
|
||||||
"windows_glazing", "mechanical_ventilation"
|
"windows_glazing", "mechanical_ventilation", "solar_pv"
|
||||||
]:
|
]:
|
||||||
# We update the data, as defined in the recommendaton
|
# We update the data, as defined in the recommendaton
|
||||||
for prefix in ["walls", "roof", "floor"]:
|
for prefix in ["walls", "roof", "floor"]:
|
||||||
|
|
@ -547,9 +554,6 @@ class Property:
|
||||||
|
|
||||||
output.update(simulation_config)
|
output.update(simulation_config)
|
||||||
|
|
||||||
if recommendation["type"] == "solar_pv":
|
|
||||||
output["photo_supply_ending"] = recommendation["photo_supply"]
|
|
||||||
|
|
||||||
if recommendation["type"] not in [
|
if recommendation["type"] not in [
|
||||||
"sealing_open_fireplace", "low_energy_lighting",
|
"sealing_open_fireplace", "low_energy_lighting",
|
||||||
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
||||||
|
|
@ -1241,15 +1245,11 @@ class Property:
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
suitable_house = self.data["property-type"] == "House" and self.data["built-form"] in [
|
suitable_property_type = (
|
||||||
"Detached", "Semi-Detached", "End-Terrace",
|
self.data["property-type"] in ["House", "Bungalow"] and
|
||||||
]
|
self.data["built-form"] not in ["Enclosed Mid-Terrace", "Enclosed End-Terrace"]
|
||||||
|
)
|
||||||
|
|
||||||
suitable_bungalow = self.data["property-type"] == "Bungalow" and self.data["built-form"] in [
|
|
||||||
"Detached", "Semi-Detached"
|
|
||||||
]
|
|
||||||
|
|
||||||
suitable_property_type = suitable_house or suitable_bungalow
|
|
||||||
has_air_source_heat_pump = self.main_heating["has_air_source_heat_pump"]
|
has_air_source_heat_pump = self.main_heating["has_air_source_heat_pump"]
|
||||||
|
|
||||||
return suitable_property_type and not has_air_source_heat_pump
|
return suitable_property_type and not has_air_source_heat_pump
|
||||||
|
|
@ -1343,13 +1343,26 @@ class Property:
|
||||||
|
|
||||||
return electric_consumption
|
return electric_consumption
|
||||||
|
|
||||||
def insert_funding(self, funding_calulator: Funding):
|
def insert_funding(
|
||||||
|
self,
|
||||||
|
scheme,
|
||||||
|
funded_measures,
|
||||||
|
project_funding,
|
||||||
|
total_uplift,
|
||||||
|
full_project_score,
|
||||||
|
partial_project_score,
|
||||||
|
uplift_project_score
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
This method inserts the funding into the property object
|
This method inserts the funding into the property object
|
||||||
"""
|
"""
|
||||||
self.gbis_eligibiltiy = funding_calulator.gbis_eligibiltiy
|
self.scheme = scheme
|
||||||
self.eco4_eligibility = funding_calulator.eco4_eligibility
|
self.funded_measures = funded_measures
|
||||||
self.whlg_eligibility = funding_calulator.whlg_eligibility
|
self.project_funding = project_funding
|
||||||
|
self.total_uplift = total_uplift
|
||||||
|
self.full_project_score = full_project_score
|
||||||
|
self.partial_project_score = partial_project_score
|
||||||
|
self.uplift_project_score = uplift_project_score
|
||||||
|
|
||||||
def identify_ventilation(self):
|
def identify_ventilation(self):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ class GoogleSolarApi:
|
||||||
# Error Messages
|
# Error Messages
|
||||||
ENTITY_NOT_FOUND_ERROR = 'Requested entity was not found.'
|
ENTITY_NOT_FOUND_ERROR = 'Requested entity was not found.'
|
||||||
|
|
||||||
def __init__(self, api_key, max_retries=5):
|
def __init__(self, api_key, solar_materials: list, max_retries=5):
|
||||||
"""
|
"""
|
||||||
Initialize the GoogleSolarApi class with the provided API key and maximum retries.
|
Initialize the GoogleSolarApi class with the provided API key and maximum retries.
|
||||||
|
|
||||||
|
|
@ -77,7 +77,6 @@ class GoogleSolarApi:
|
||||||
# property attributes:
|
# property attributes:
|
||||||
self.floor_area = None
|
self.floor_area = None
|
||||||
self.roof_area = None
|
self.roof_area = None
|
||||||
self.roof_segment_indexes = None
|
|
||||||
self.panel_area = assumptions.RDSAP_AREA_PER_PANEL
|
self.panel_area = assumptions.RDSAP_AREA_PER_PANEL
|
||||||
self.panel_wattage = None
|
self.panel_wattage = None
|
||||||
self.panel_performance = None
|
self.panel_performance = None
|
||||||
|
|
@ -87,6 +86,9 @@ class GoogleSolarApi:
|
||||||
|
|
||||||
# Indicates if we think we have both units attached to a semi-detached property
|
# Indicates if we think we have both units attached to a semi-detached property
|
||||||
self.double_property = False
|
self.double_property = False
|
||||||
|
self.solar_materials = solar_materials
|
||||||
|
|
||||||
|
self.allowed_segment_indices = None
|
||||||
|
|
||||||
def get_building_insights(self, longitude, latitude, required_quality="MEDIUM", max_retries=None):
|
def get_building_insights(self, longitude, latitude, required_quality="MEDIUM", max_retries=None):
|
||||||
"""
|
"""
|
||||||
|
|
@ -202,19 +204,17 @@ class GoogleSolarApi:
|
||||||
# It should be straightforward, but I'd rather see an actual instance of this happening
|
# It should be straightforward, but I'd rather see an actual instance of this happening
|
||||||
raise NotImplementedError("Panel wattage is not 400W - implement me")
|
raise NotImplementedError("Panel wattage is not 400W - implement me")
|
||||||
|
|
||||||
self.roof_segment_indexes = [segment['segmentIndex'] for segment in self.roof_segments]
|
|
||||||
|
|
||||||
# We now start finding the solar panel configurations
|
# We now start finding the solar panel configurations
|
||||||
self.optimise_solar_configuration(
|
self.optimise_solar_configuration(
|
||||||
energy_consumption=energy_consumption,
|
energy_consumption=energy_consumption,
|
||||||
is_building=is_building,
|
is_building=is_building,
|
||||||
property_instance=property_instance
|
property_instance=property_instance,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Finally, if we have a double property, we half the data we stored area
|
# Finally, if we have a double property, we half the data we stored area
|
||||||
if self.double_property:
|
if self.double_property:
|
||||||
self.roof_area = self.roof_area / 2
|
self.roof_area = self.roof_area / 2
|
||||||
self.floor_area = self.floor_area / 2
|
self.floor_area = float(self.floor_area) / 2
|
||||||
|
|
||||||
def save_to_db(self, session, uprns_to_location, scenario_type):
|
def save_to_db(self, session, uprns_to_location, scenario_type):
|
||||||
if self.insights_data is None:
|
if self.insights_data is None:
|
||||||
|
|
@ -279,7 +279,9 @@ class GoogleSolarApi:
|
||||||
installation_life_span)) /
|
installation_life_span)) /
|
||||||
(1 - efficiency_depreciation_factor))
|
(1 - efficiency_depreciation_factor))
|
||||||
|
|
||||||
def optimise_solar_configuration(self, energy_consumption, is_building=False, property_instance=None):
|
def optimise_solar_configuration(
|
||||||
|
self, energy_consumption, is_building=False, property_instance=None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Optimise the solar panel configuration for the building.
|
Optimise the solar panel configuration for the building.
|
||||||
:return:
|
:return:
|
||||||
|
|
@ -291,10 +293,10 @@ class GoogleSolarApi:
|
||||||
# Remove any north facing roof segments
|
# Remove any north facing roof segments
|
||||||
panel_performance = []
|
panel_performance = []
|
||||||
for config in self.insights_data["solarPotential"].get("solarPanelConfigs", []):
|
for config in self.insights_data["solarPotential"].get("solarPanelConfigs", []):
|
||||||
roof_segment_summaries = config["roofSegmentSummaries"]
|
|
||||||
# Filter on just the segments in self.roof_segment_indexes
|
|
||||||
roof_segment_summaries = [
|
roof_segment_summaries = [
|
||||||
segment for segment in roof_segment_summaries if segment["segmentIndex"] in self.roof_segment_indexes
|
s for s in config.get("roofSegmentSummaries", [])
|
||||||
|
if s["segmentIndex"] in self.allowed_segment_indices
|
||||||
]
|
]
|
||||||
|
|
||||||
roi_summary = []
|
roi_summary = []
|
||||||
|
|
@ -321,9 +323,25 @@ class GoogleSolarApi:
|
||||||
if roi_summary["n_panels"].sum() < min_panels:
|
if roi_summary["n_panels"].sum() < min_panels:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
total_panels = roi_summary["n_panels"].sum()
|
||||||
|
# find a product which is suitable for the ROI calc, without a battery. 400 Watts for the baseline
|
||||||
|
solar_product = next(
|
||||||
|
(m for m in self.solar_materials if m["type"] == "solar_pv" and
|
||||||
|
abs(m["size"] - (400 * total_panels) / 1000) < 0.1 and not m["includes_battery"]),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
if solar_product is None:
|
||||||
|
logger.info("No suitable solar product found for the configuration with %d panels.", total_panels)
|
||||||
|
continue
|
||||||
|
|
||||||
total_cost = Costs.solar_pv(
|
total_cost = Costs.solar_pv(
|
||||||
n_panels=roi_summary["n_panels"].sum(),
|
solar_product=solar_product,
|
||||||
has_battery=False,
|
# We don't actually need scaffolding for the ROI calc
|
||||||
|
scaffolding_options=[
|
||||||
|
{"total_cost": 1000, "size": property_instance.number_of_floors},
|
||||||
|
{"total_cost": 1000, "size": 3}
|
||||||
|
],
|
||||||
# Assume the most amount of scaffolding
|
# Assume the most amount of scaffolding
|
||||||
n_floors=3 if property_instance is None else property_instance.number_of_floors
|
n_floors=3 if property_instance is None else property_instance.number_of_floors
|
||||||
)["total"]
|
)["total"]
|
||||||
|
|
@ -525,23 +543,24 @@ class GoogleSolarApi:
|
||||||
|
|
||||||
def exclude_north_facing_segments(self, property_instance):
|
def exclude_north_facing_segments(self, property_instance):
|
||||||
"""
|
"""
|
||||||
Filter out any north-facing roof segments from the roof_segments attribute.
|
Filter out any north-facing roof segments from self.roof_segments.
|
||||||
|
Keep API's original segmentIndex; optionally add a localIndex.
|
||||||
North-facing segments are defined as those with an azimuth between -30 and 30 degrees.
|
|
||||||
"""
|
"""
|
||||||
|
is_flat = property_instance.roof["is_flat"]
|
||||||
|
|
||||||
filtered_segments = []
|
kept = []
|
||||||
for segment_index, segment in enumerate(self.roof_segments):
|
allowed = set()
|
||||||
segment["segmentIndex"] = segment_index
|
for i, seg in enumerate(self.roof_segments): # i is the API segmentIndex
|
||||||
# Check if the segment is north-facing
|
if not is_flat and (
|
||||||
if (
|
self.NORTH_FACING_AZIMUTH_RANGE[0] <= seg['azimuthDegrees'] <= self.NORTH_FACING_AZIMUTH_RANGE[1]):
|
||||||
self.NORTH_FACING_AZIMUTH_RANGE[0] <= segment['azimuthDegrees'] <= self.NORTH_FACING_AZIMUTH_RANGE[1]
|
|
||||||
) and not property_instance.roof["is_flat"]:
|
|
||||||
continue
|
continue
|
||||||
|
s = dict(seg)
|
||||||
|
s["localIndex"] = len(kept) # for charts/UI only
|
||||||
|
kept.append(s)
|
||||||
|
allowed.add(i) # this i IS the API segmentIndex
|
||||||
|
|
||||||
filtered_segments.append(segment)
|
self.roof_segments = kept
|
||||||
|
self.allowed_segment_indices = allowed
|
||||||
self.roof_segments = filtered_segments
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def haversine(lat1, lon1, lat2, lon2):
|
def haversine(lat1, lon1, lat2, lon2):
|
||||||
|
|
@ -723,7 +742,8 @@ class GoogleSolarApi:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def building_solar_analysis(
|
def building_solar_analysis(
|
||||||
cls, building_solar_config: List, input_properties: List[Property], session, google_solar_api_key: str
|
cls, building_solar_config: List, input_properties: List[Property], session, google_solar_api_key: str,
|
||||||
|
solar_materials: list
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Perform the solar analysis for the building level
|
Perform the solar analysis for the building level
|
||||||
|
|
@ -731,6 +751,7 @@ class GoogleSolarApi:
|
||||||
:param input_properties: List of properties
|
:param input_properties: List of properties
|
||||||
:param session: Database session
|
:param session: Database session
|
||||||
:param google_solar_api_key: Google Solar API key
|
:param google_solar_api_key: Google Solar API key
|
||||||
|
:param solar_materials: List of solar materials
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -769,7 +790,7 @@ class GoogleSolarApi:
|
||||||
energy_consumption = sum(
|
energy_consumption = sum(
|
||||||
[entry['energy_consumption'] for entry in building_solar_config if entry['building_id'] == building_id]
|
[entry['energy_consumption'] for entry in building_solar_config if entry['building_id'] == building_id]
|
||||||
)
|
)
|
||||||
solar_api_client = cls(api_key=google_solar_api_key)
|
solar_api_client = cls(api_key=google_solar_api_key, solar_materials=solar_materials)
|
||||||
solar_api_client.get(
|
solar_api_client.get(
|
||||||
longitude=coordinates["longitude"],
|
longitude=coordinates["longitude"],
|
||||||
latitude=coordinates["latitude"],
|
latitude=coordinates["latitude"],
|
||||||
|
|
@ -805,7 +826,8 @@ class GoogleSolarApi:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def unit_solar_analysis(
|
def unit_solar_analysis(
|
||||||
cls, unit_solar_config: List, input_properties: List[Property], session, body, google_solar_api_key: str
|
cls, unit_solar_config: List, input_properties: List[Property], session, body, google_solar_api_key: str,
|
||||||
|
solar_materials: list
|
||||||
):
|
):
|
||||||
|
|
||||||
if not unit_solar_config:
|
if not unit_solar_config:
|
||||||
|
|
@ -844,7 +866,7 @@ class GoogleSolarApi:
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
solar_api_client = cls(api_key=google_solar_api_key)
|
solar_api_client = cls(api_key=google_solar_api_key, solar_materials=solar_materials)
|
||||||
solar_api_client.get(
|
solar_api_client.get(
|
||||||
longitude=unit["longitude"],
|
longitude=unit["longitude"],
|
||||||
latitude=unit["latitude"],
|
latitude=unit["latitude"],
|
||||||
|
|
@ -852,7 +874,7 @@ class GoogleSolarApi:
|
||||||
is_building=False,
|
is_building=False,
|
||||||
session=session,
|
session=session,
|
||||||
uprn=unit["uprn"],
|
uprn=unit["uprn"],
|
||||||
property_instance=property_instance
|
property_instance=property_instance,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store the data in the database
|
# Store the data in the database
|
||||||
|
|
|
||||||
62
backend/app/db/functions/funding_functions.py
Normal file
62
backend/app/db/functions/funding_functions.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from backend.app.db.models.funding import FundingPackage, FundingPackageMeasures
|
||||||
|
|
||||||
|
|
||||||
|
def upload_funding(session: Session, p, plan_id, recommendations_to_upload):
|
||||||
|
try:
|
||||||
|
# Prepare data for bulk insert for Recommendation
|
||||||
|
funding_package_data = {
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"scheme": p.scheme,
|
||||||
|
"project_funding": float(p.project_funding),
|
||||||
|
"total_uplift": float(p.total_uplift),
|
||||||
|
"full_project_score": float(p.full_project_score),
|
||||||
|
"partial_project_score": float(p.partial_project_score),
|
||||||
|
"uplift_project_score": float(p.uplift_project_score)
|
||||||
|
}
|
||||||
|
|
||||||
|
# upload the funding package data and get back the ID
|
||||||
|
new_funding_package = FundingPackage(**funding_package_data)
|
||||||
|
session.add(new_funding_package)
|
||||||
|
session.flush()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
funding_package_id = new_funding_package.id
|
||||||
|
|
||||||
|
# We now prepare the list of funding measures to be uploaded
|
||||||
|
funding_measures_data = []
|
||||||
|
for part in p.funded_measures:
|
||||||
|
recommendation_id = part["id"]
|
||||||
|
recommendation = next(
|
||||||
|
(x for x in recommendations_to_upload if x["recommendation_id"] == recommendation_id), {}
|
||||||
|
)
|
||||||
|
material_id = None
|
||||||
|
if recommendation["parts"]:
|
||||||
|
material_id = recommendation["parts"][0]["id"]
|
||||||
|
funding_measures_data.append({
|
||||||
|
"funding_package_id": funding_package_id,
|
||||||
|
"measure": part["type"],
|
||||||
|
"material_id": material_id,
|
||||||
|
"innovation_uplift": float(part["innovation_uplift"]),
|
||||||
|
"partial_project_score": float(part["partial_project_score"]),
|
||||||
|
"uplift_project_score": float(part["uplift_project_score"])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Bulk insert the funding measures data
|
||||||
|
if funding_measures_data:
|
||||||
|
session.bulk_insert_mappings(FundingPackageMeasures, funding_measures_data)
|
||||||
|
|
||||||
|
# flush the changes to get the newly created IDs
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Commit the transaction
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
# Rollback the transaction in case of an error
|
||||||
|
session.rollback()
|
||||||
|
print(f"An error occurred: {e}")
|
||||||
|
return False
|
||||||
|
|
@ -29,6 +29,7 @@ def aggregate_portfolio_recommendations(
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Contingeny and funding are in the aggregated data
|
||||||
aggregates_dict = {
|
aggregates_dict = {
|
||||||
"cost": aggregates.cost or 0,
|
"cost": aggregates.cost or 0,
|
||||||
"total_work_hours": aggregates.total_work_hours or 0,
|
"total_work_hours": aggregates.total_work_hours or 0,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from backend.app.db.models.recommendations import (
|
||||||
from backend.app.db.models.portfolio import (
|
from backend.app.db.models.portfolio import (
|
||||||
PropertyModel, PropertyTargetsModel, PropertyDetailsEpcModel
|
PropertyModel, PropertyTargetsModel, PropertyDetailsEpcModel
|
||||||
)
|
)
|
||||||
|
from backend.app.db.models.funding import FundingPackageMeasures, FundingPackage
|
||||||
|
|
||||||
|
|
||||||
def create_plan(session: Session, plan):
|
def create_plan(session: Session, plan):
|
||||||
|
|
@ -138,9 +139,9 @@ def upload_recommendations(session: Session, recommendations_to_upload, property
|
||||||
"recommendation_id": recommendation_id,
|
"recommendation_id": recommendation_id,
|
||||||
"material_id": part["id"],
|
"material_id": part["id"],
|
||||||
"depth": int(part["depth"]) if part["depth"] else None,
|
"depth": int(part["depth"]) if part["depth"] else None,
|
||||||
"quantity": float(part["quantity"]),
|
"quantity": float(part["quantity"]) if part.get("quantity") else None,
|
||||||
"quantity_unit": part["quantity_unit"],
|
"quantity_unit": part.get("quantity_unit", None),
|
||||||
"estimated_cost": part["total"],
|
"estimated_cost": float(part.get("total", part.get("total_cost"))),
|
||||||
}
|
}
|
||||||
for rec, recommendation_id in zip(recommendations_to_upload, uploaded_recommendation_ids)
|
for rec, recommendation_id in zip(recommendations_to_upload, uploaded_recommendation_ids)
|
||||||
for part in rec["parts"]
|
for part in rec["parts"]
|
||||||
|
|
@ -176,6 +177,10 @@ def clear_portfolio(session: Session, portfolio_id: int):
|
||||||
recommendation_ids = session.query(Recommendation.id).filter(Recommendation.property_id.in_(property_ids)).all()
|
recommendation_ids = session.query(Recommendation.id).filter(Recommendation.property_id.in_(property_ids)).all()
|
||||||
recommendation_ids = [r.id for r in recommendation_ids]
|
recommendation_ids = [r.id for r in recommendation_ids]
|
||||||
|
|
||||||
|
# Fetch all plan IDs associated with the portfolio
|
||||||
|
plan_ids = session.query(Plan.id).filter(Plan.portfolio_id == portfolio_id).all()
|
||||||
|
plan_ids = [p.id for p in plan_ids]
|
||||||
|
|
||||||
# Delete all entries from RecommendationMaterials for these recommendations
|
# Delete all entries from RecommendationMaterials for these recommendations
|
||||||
session.execute(
|
session.execute(
|
||||||
delete(RecommendationMaterials).where(RecommendationMaterials.recommendation_id.in_(recommendation_ids))
|
delete(RecommendationMaterials).where(RecommendationMaterials.recommendation_id.in_(recommendation_ids))
|
||||||
|
|
@ -186,6 +191,16 @@ def clear_portfolio(session: Session, portfolio_id: int):
|
||||||
session.query(Plan.id).filter(Plan.portfolio_id == portfolio_id).subquery().as_scalar()
|
session.query(Plan.id).filter(Plan.portfolio_id == portfolio_id).subquery().as_scalar()
|
||||||
)))
|
)))
|
||||||
|
|
||||||
|
# Delete FundingPackageMeasures → FundingPackage → Plan
|
||||||
|
session.execute(
|
||||||
|
delete(FundingPackageMeasures).where(FundingPackageMeasures.funding_package_id.in_(
|
||||||
|
session.query(FundingPackage.id).filter(FundingPackage.plan_id.in_(plan_ids))
|
||||||
|
))
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
delete(FundingPackage).where(FundingPackage.plan_id.in_(plan_ids))
|
||||||
|
)
|
||||||
|
|
||||||
# Delete all Plans associated with the portfolio
|
# Delete all Plans associated with the portfolio
|
||||||
session.execute(delete(Plan).where(Plan.portfolio_id == portfolio_id))
|
session.execute(delete(Plan).where(Plan.portfolio_id == portfolio_id))
|
||||||
|
|
||||||
|
|
|
||||||
48
backend/app/db/models/funding.py
Normal file
48
backend/app/db/models/funding.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Float, Enum, TIMESTAMP, BigInteger, ForeignKey
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from backend.app.db.models.recommendations import Plan
|
||||||
|
from backend.app.db.models.materials import MaterialType, Material
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class SchemeEnum(enum.Enum):
|
||||||
|
eco4 = "eco4"
|
||||||
|
gbis = "gbis"
|
||||||
|
whlg = "whlg"
|
||||||
|
none = "none"
|
||||||
|
|
||||||
|
|
||||||
|
class FundingPackage(Base):
|
||||||
|
__tablename__ = 'funding_package'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
plan_id = Column(BigInteger, ForeignKey(Plan.id), nullable=False)
|
||||||
|
scheme = Column(
|
||||||
|
Enum(SchemeEnum, values_callable=lambda x: [e.value for e in x], create_constraint=False),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||||
|
project_funding = Column(Float)
|
||||||
|
total_uplift = Column(Float)
|
||||||
|
full_project_score = Column(Float)
|
||||||
|
partial_project_score = Column(Float)
|
||||||
|
uplift_project_score = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class FundingPackageMeasures(Base):
|
||||||
|
__tablename__ = 'funding_package_measures'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
funding_package_id = Column(BigInteger, ForeignKey(FundingPackage.id), nullable=False)
|
||||||
|
measure = Column(
|
||||||
|
Enum(MaterialType, values_callable=lambda x: [e.value for e in x], create_constraint=False),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
material_id = Column(BigInteger, ForeignKey(Material.id), nullable=False) # Assuming material table exists
|
||||||
|
innovation_uplift = Column(Float)
|
||||||
|
partial_project_score = Column(Float)
|
||||||
|
uplift_project_score = Column(Float)
|
||||||
|
|
@ -38,12 +38,27 @@ class MaterialType(enum.Enum):
|
||||||
flat_roof_preparation = "flat_roof_preparation"
|
flat_roof_preparation = "flat_roof_preparation"
|
||||||
flat_roof_vapour_barrier = "flat_roof_vapour_barrier"
|
flat_roof_vapour_barrier = "flat_roof_vapour_barrier"
|
||||||
flat_roof_waterproofing = "flat_roof_waterproofing"
|
flat_roof_waterproofing = "flat_roof_waterproofing"
|
||||||
|
trickle_vent = "trickle_vent"
|
||||||
|
door_undercut = "door_undercut"
|
||||||
|
solar_pv = "solar_pv"
|
||||||
|
solar_battery = "solar_battery"
|
||||||
|
scaffolding = "scaffolding"
|
||||||
|
high_heat_retention_storage_heaters = "high_heat_retention_storage_heaters"
|
||||||
|
sealing_fireplace = "sealing_fireplace"
|
||||||
|
|
||||||
|
|
||||||
class DepthUnit(enum.Enum):
|
class DepthUnit(enum.Enum):
|
||||||
mm = "mm"
|
mm = "mm"
|
||||||
|
|
||||||
|
|
||||||
|
class SizeUnit(enum.Enum):
|
||||||
|
# ["kWp", "kW", "watt", "storey"]
|
||||||
|
kWp = "kWp"
|
||||||
|
kW = "kW"
|
||||||
|
watt = "watt"
|
||||||
|
storey = "storey"
|
||||||
|
|
||||||
|
|
||||||
class CostUnit(enum.Enum):
|
class CostUnit(enum.Enum):
|
||||||
gbp_sq_meter = "gbp_sq_meter"
|
gbp_sq_meter = "gbp_sq_meter"
|
||||||
gbp_per_unit = "gbp_per_unit"
|
gbp_per_unit = "gbp_per_unit"
|
||||||
|
|
@ -90,3 +105,11 @@ class Material(Base):
|
||||||
total_cost = Column(Float)
|
total_cost = Column(Float)
|
||||||
notes = Column(String)
|
notes = Column(String)
|
||||||
is_installer_quote = Column(Boolean, nullable=False, default=False)
|
is_installer_quote = Column(Boolean, nullable=False, default=False)
|
||||||
|
innovation_rate = Column(Float, default=0.0)
|
||||||
|
size = Column(Float)
|
||||||
|
size_unit = Column(
|
||||||
|
Enum(SizeUnit, values_callable=lambda x: [e.value for e in x]), nullable=True
|
||||||
|
)
|
||||||
|
includes_scaffolding = Column(Boolean, default=False)
|
||||||
|
includes_battery = Column(Boolean, default=False)
|
||||||
|
battery_size = Column(Float)
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ class Scenario(Base):
|
||||||
|
|
||||||
# Add in the fields we need, which were previously sitting at the portfolio level
|
# Add in the fields we need, which were previously sitting at the portfolio level
|
||||||
cost = Column(Float)
|
cost = Column(Float)
|
||||||
|
contingency = Column(Float)
|
||||||
|
funding = Column(Float)
|
||||||
total_work_hours = Column(Float)
|
total_work_hours = Column(Float)
|
||||||
energy_savings = Column(Float)
|
energy_savings = Column(Float)
|
||||||
co2_equivalent_savings = Column(Float)
|
co2_equivalent_savings = Column(Float)
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,26 @@ TYPICAL_MEASURE_TYPES = [
|
||||||
"secondary_heating", "solar_pv"
|
"secondary_heating", "solar_pv"
|
||||||
]
|
]
|
||||||
|
|
||||||
SPECIFIC_MEASURES = [
|
WALL_INSULATION_MEASURES = ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"]
|
||||||
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
ROOF_INSULATION_MEASURES = ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"]
|
||||||
"loft_insulation", "flat_roof_insulation", "room_roof_insulation",
|
|
||||||
"suspended_floor_insulation", "solid_floor_insulation",
|
# Both all and roof insulaiton measures are eligible for ECO4. These are the remaining fabric and heating measures
|
||||||
"boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump",
|
# This is based on th measures we have recommendations for
|
||||||
"secondary_heating", "solar_pv", "double_glazing", "secondary_glazing",
|
ECO4_ELIGIBILE_FABRIC_MEASURES = [
|
||||||
"ventilation", "low_energy_lighting", "fireplace", "hot_water_tank_insulation",
|
"suspended_floor_insulation", "solid_floor_insulation", "double_glazing", "secondary_glazing"
|
||||||
"cylinder_thermostat"
|
|
||||||
]
|
]
|
||||||
|
ECO4_ELIGIBLE_HEATING_MEASURES = [
|
||||||
|
"boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump", "solar_pv"
|
||||||
|
]
|
||||||
|
|
||||||
|
SPECIFIC_MEASURES = (
|
||||||
|
WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES +
|
||||||
|
ECO4_ELIGIBLE_HEATING_MEASURES + [
|
||||||
|
"secondary_heating" "ventilation", "low_energy_lighting", "fireplace",
|
||||||
|
"hot_water_tank_insulation",
|
||||||
|
"cylinder_thermostat"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
INSULATION_MEASURES = [
|
INSULATION_MEASURES = [
|
||||||
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
||||||
|
|
@ -96,7 +107,6 @@ class PlanTriggerRequest(BaseModel):
|
||||||
scenario_name: Optional[str] = ""
|
scenario_name: Optional[str] = ""
|
||||||
scenario_id: Optional[str | int] = None # Used to utilise and existing scenario for a engine run
|
scenario_id: Optional[str | int] = None # Used to utilise and existing scenario for a engine run
|
||||||
multi_plan: Optional[bool] = False
|
multi_plan: Optional[bool] = False
|
||||||
optimise: Optional[bool] = True
|
|
||||||
default_u_values: Optional[bool] = True
|
default_u_values: Optional[bool] = True
|
||||||
|
|
||||||
ashp_cop: Optional[float] = 2.8
|
ashp_cop: Optional[float] = 2.8
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
@ -22,9 +23,10 @@ from backend.app.db.functions.property_functions import (
|
||||||
from backend.app.db.functions.recommendations_functions import (
|
from backend.app.db.functions.recommendations_functions import (
|
||||||
create_plan, upload_recommendations, create_scenario
|
create_plan, upload_recommendations, create_scenario
|
||||||
)
|
)
|
||||||
|
from backend.app.db.functions.funding_functions import upload_funding
|
||||||
from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn
|
from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn
|
||||||
from backend.app.db.models.portfolio import rating_lookup
|
from backend.app.db.models.portfolio import rating_lookup
|
||||||
from backend.app.plan.schemas import PlanTriggerRequest
|
from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES
|
||||||
from backend.app.plan.utils import get_cleaned
|
from backend.app.plan.utils import get_cleaned
|
||||||
from backend.app.utils import sap_to_epc
|
from backend.app.utils import sap_to_epc
|
||||||
import backend.app.assumptions as assumptions
|
import backend.app.assumptions as assumptions
|
||||||
|
|
@ -45,6 +47,10 @@ from etl.bill_savings.KwhData import KwhData
|
||||||
from etl.spatial.OpenUprnClient import OpenUprnClient
|
from etl.spatial.OpenUprnClient import OpenUprnClient
|
||||||
from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc
|
from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc
|
||||||
|
|
||||||
|
from backend.Funding import Funding
|
||||||
|
from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths
|
||||||
|
from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value
|
||||||
|
|
||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
||||||
BATCH_SIZE = 5
|
BATCH_SIZE = 5
|
||||||
|
|
@ -161,7 +167,9 @@ def extract_portfolio_aggregation_data(
|
||||||
"sap_point_improvement": sap_point_improvement,
|
"sap_point_improvement": sap_point_improvement,
|
||||||
"lower_bound_valuation_uplift": lower_bound_valuation_uplift,
|
"lower_bound_valuation_uplift": lower_bound_valuation_uplift,
|
||||||
"upper_bound_valuation_uplift": upper_bound_valuation_uplift,
|
"upper_bound_valuation_uplift": upper_bound_valuation_uplift,
|
||||||
"has_recommendations": has_recommendations
|
"has_recommendations": has_recommendations,
|
||||||
|
"funding": float(p.project_funding) if p.project_funding is not None else 0,
|
||||||
|
"contingency": float(sum([x.get("contingency", 0) for x in default_recommendations]))
|
||||||
})
|
})
|
||||||
|
|
||||||
agg_data = pd.DataFrame(agg_data)
|
agg_data = pd.DataFrame(agg_data)
|
||||||
|
|
@ -209,6 +217,9 @@ def extract_portfolio_aggregation_data(
|
||||||
cost_per_sap_point = agg_data["cost"].sum() / total_sap_points if total_sap_points > 0 else 0
|
cost_per_sap_point = agg_data["cost"].sum() / total_sap_points if total_sap_points > 0 else 0
|
||||||
cost_per_sap_point = format_money(cost_per_sap_point)
|
cost_per_sap_point = format_money(cost_per_sap_point)
|
||||||
|
|
||||||
|
total_funding = agg_data["funding"].sum()
|
||||||
|
total_contingency = agg_data["contingency"].sum()
|
||||||
|
|
||||||
aggregation_data = {
|
aggregation_data = {
|
||||||
"epc_breakdown_pre_retrofit": json.dumps(
|
"epc_breakdown_pre_retrofit": json.dumps(
|
||||||
reformat_epc_data(agg_data["pre_retrofit_epc"].value_counts().to_dict())
|
reformat_epc_data(agg_data["pre_retrofit_epc"].value_counts().to_dict())
|
||||||
|
|
@ -231,6 +242,8 @@ def extract_portfolio_aggregation_data(
|
||||||
"cost_per_co2_saved": cost_per_co2_saved,
|
"cost_per_co2_saved": cost_per_co2_saved,
|
||||||
"cost_per_sap_point": cost_per_sap_point,
|
"cost_per_sap_point": cost_per_sap_point,
|
||||||
"valuation_return_on_investment": valuation_return_on_investment,
|
"valuation_return_on_investment": valuation_return_on_investment,
|
||||||
|
"funding": float(total_funding),
|
||||||
|
"contingency": float(total_contingency)
|
||||||
}
|
}
|
||||||
|
|
||||||
return aggregation_data
|
return aggregation_data
|
||||||
|
|
@ -415,13 +428,25 @@ def get_funding_data():
|
||||||
project_scores_matrix.columns = ['Floor Area Segment', 'Starting Band', 'Finishing Band', 'Cost Savings']
|
project_scores_matrix.columns = ['Floor Area Segment', 'Starting Band', 'Finishing Band', 'Cost Savings']
|
||||||
project_scores_matrix["Cost Savings"] = project_scores_matrix["Cost Savings"].astype(float)
|
project_scores_matrix["Cost Savings"] = project_scores_matrix["Cost Savings"].astype(float)
|
||||||
|
|
||||||
|
partial_project_scores_matrix = read_csv_from_s3(
|
||||||
|
bucket_name=get_settings().DATA_BUCKET,
|
||||||
|
filepath="funding/ECO4_Partial_Project_Scores_Matrix_v6.csv",
|
||||||
|
)
|
||||||
|
partial_project_scores_matrix = pd.DataFrame(partial_project_scores_matrix)
|
||||||
|
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'
|
||||||
|
]
|
||||||
|
partial_project_scores_matrix["Cost Savings"] = partial_project_scores_matrix["Cost Savings"].astype(float)
|
||||||
|
|
||||||
whlg_eligible_postcodes = read_csv_from_s3(
|
whlg_eligible_postcodes = read_csv_from_s3(
|
||||||
bucket_name=get_settings().DATA_BUCKET,
|
bucket_name=get_settings().DATA_BUCKET,
|
||||||
filepath="funding/whlg eligible postcodes.csv",
|
filepath="funding/whlg eligible postcodes.csv",
|
||||||
)
|
)
|
||||||
whlg_eligible_postcodes = pd.DataFrame(whlg_eligible_postcodes)
|
whlg_eligible_postcodes = pd.DataFrame(whlg_eligible_postcodes)
|
||||||
|
|
||||||
return project_scores_matrix, whlg_eligible_postcodes
|
return project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes
|
||||||
|
|
||||||
|
|
||||||
async def model_engine(body: PlanTriggerRequest):
|
async def model_engine(body: PlanTriggerRequest):
|
||||||
|
|
@ -535,7 +560,6 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
epc_searcher.ordnance_survey_client.property_type = config.get("property_type", None)
|
epc_searcher.ordnance_survey_client.property_type = config.get("property_type", None)
|
||||||
# For the moment, our OS API access is unavailable, so we skip and interpolate
|
# For the moment, our OS API access is unavailable, so we skip and interpolate
|
||||||
epc_searcher.find_property(skip_os=True)
|
epc_searcher.find_property(skip_os=True)
|
||||||
# TODO: Placeholder
|
|
||||||
if epc_searcher.newest_epc.get("estimated") and body.file_format == "domna_asset_list":
|
if epc_searcher.newest_epc.get("estimated") and body.file_format == "domna_asset_list":
|
||||||
epc_searcher.newest_epc["uprn-source"] = epc_searcher.UPRN_SOURCE_SIMULATED
|
epc_searcher.newest_epc["uprn-source"] = epc_searcher.UPRN_SOURCE_SIMULATED
|
||||||
|
|
||||||
|
|
@ -648,7 +672,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
logger.info("Reading in materials and cleaned datasets")
|
logger.info("Reading in materials and cleaned datasets")
|
||||||
materials = get_materials(session)
|
materials = get_materials(session)
|
||||||
cleaned = get_cleaned()
|
cleaned = get_cleaned()
|
||||||
eco_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
|
project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
|
||||||
|
|
||||||
kwh_client = KwhData(bucket=get_settings().DATA_BUCKET, read_consumption_data=True)
|
kwh_client = KwhData(bucket=get_settings().DATA_BUCKET, read_consumption_data=True)
|
||||||
|
|
||||||
|
|
@ -689,7 +713,8 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
building_solar_config=building_solar_config,
|
building_solar_config=building_solar_config,
|
||||||
input_properties=input_properties,
|
input_properties=input_properties,
|
||||||
session=session,
|
session=session,
|
||||||
google_solar_api_key=get_settings().GOOGLE_SOLAR_API_KEY
|
google_solar_api_key=get_settings().GOOGLE_SOLAR_API_KEY,
|
||||||
|
solar_materials=[m for m in materials if m["type"] == "solar_pv"]
|
||||||
)
|
)
|
||||||
|
|
||||||
input_properties = GoogleSolarApi.unit_solar_analysis(
|
input_properties = GoogleSolarApi.unit_solar_analysis(
|
||||||
|
|
@ -697,7 +722,8 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
input_properties=input_properties,
|
input_properties=input_properties,
|
||||||
session=session,
|
session=session,
|
||||||
body=body,
|
body=body,
|
||||||
google_solar_api_key=get_settings().GOOGLE_SOLAR_API_KEY
|
solar_materials=[m for m in materials if m["type"] == "solar_pv"],
|
||||||
|
google_solar_api_key=get_settings().GOOGLE_SOLAR_API_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Identifying property recommendations")
|
logger.info("Identifying property recommendations")
|
||||||
|
|
@ -807,11 +833,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
x in property_measure_types for x in assumptions.measures_needing_ventilation
|
x in property_measure_types for x in assumptions.measures_needing_ventilation
|
||||||
) and not p.has_ventilation
|
) and not p.has_ventilation
|
||||||
|
|
||||||
input_measures = optimiser_functions.prepare_input_measures(
|
if not measures_to_optimise:
|
||||||
measures_to_optimise, body.goal, needs_ventilation
|
|
||||||
)
|
|
||||||
|
|
||||||
if not input_measures[0]:
|
|
||||||
# Nothing to do, we just reshape the recommendations
|
# Nothing to do, we just reshape the recommendations
|
||||||
recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
|
recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
|
||||||
p.id, recommendations, set()
|
p.id, recommendations, set()
|
||||||
|
|
@ -823,20 +845,173 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
)
|
)
|
||||||
gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
|
gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
|
||||||
|
|
||||||
if not body.optimise:
|
funding = Funding(
|
||||||
if body.goal != "Increasing EPC":
|
tenure=body.housing_type,
|
||||||
raise NotImplementedError("Only EPC optimisation is currently supported")
|
project_scores_matrix=project_scores_matrix,
|
||||||
solution = [max(sub_list, key=lambda x: (x['gain'], -x['cost'])) for sub_list in input_measures]
|
partial_project_scores_matrix=partial_project_scores_matrix,
|
||||||
|
whlg_eligible_postcodes=whlg_eligible_postcodes,
|
||||||
|
eco4_social_cavity_abs_rate=13,
|
||||||
|
eco4_social_solid_abs_rate=17,
|
||||||
|
eco4_private_cavity_abs_rate=13,
|
||||||
|
eco4_private_solid_abs_rate=17,
|
||||||
|
gbis_social_cavity_abs_rate=21,
|
||||||
|
gbis_social_solid_abs_rate=25,
|
||||||
|
gbis_private_cavity_abs_rate=21,
|
||||||
|
gbis_private_solid_abs_rate=28,
|
||||||
|
)
|
||||||
|
|
||||||
|
li_thickness = convert_thickness_to_numeric(
|
||||||
|
p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"]
|
||||||
|
)
|
||||||
|
current_wall_u_value = p.walls["thermal_transmittance"]
|
||||||
|
if current_wall_u_value is None:
|
||||||
|
current_wall_u_value = get_wall_u_value(
|
||||||
|
clean_description=p.walls["clean_description"],
|
||||||
|
age_band=p.age_band,
|
||||||
|
is_granite_or_whinstone=p.walls["is_granite_or_whinstone"],
|
||||||
|
is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# We insert the innovation uplift
|
||||||
|
measures_to_optimise_with_uplift = deepcopy(measures_to_optimise)
|
||||||
|
|
||||||
|
# TODO: Turn this into a function and store the innovaiton uplift
|
||||||
|
for group in measures_to_optimise_with_uplift:
|
||||||
|
for r in group:
|
||||||
|
|
||||||
|
if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]:
|
||||||
|
(
|
||||||
|
r["partial_project_score"],
|
||||||
|
r["partial_project_funding"],
|
||||||
|
r["innovation_uplift"],
|
||||||
|
r["uplift_project_score"],
|
||||||
|
) = (
|
||||||
|
0, 0, 0, 0
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
(
|
||||||
|
r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
|
||||||
|
r["uplift_project_score"]
|
||||||
|
) = funding.get_innovation_uplift(
|
||||||
|
measure=r,
|
||||||
|
starting_sap=p.data["current-energy-efficiency"],
|
||||||
|
floor_area=p.floor_area,
|
||||||
|
is_cavity=p.walls["is_cavity_wall"],
|
||||||
|
current_wall_uvalue=current_wall_u_value,
|
||||||
|
is_partial="partial" in p.walls["clean_description"].lower(),
|
||||||
|
existing_li_thickness=li_thickness,
|
||||||
|
mainheating=p.main_heating,
|
||||||
|
main_fuel=p.main_fuel,
|
||||||
|
mainheat_energy_eff=p.data["mainheat-energy-eff"],
|
||||||
|
)
|
||||||
|
|
||||||
|
input_measures = optimiser_functions.prepare_input_measures(
|
||||||
|
measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# When the goal is Increasing EPC, we can run the funding optimiser
|
||||||
|
if body.goal == "Increasing EPC":
|
||||||
|
|
||||||
|
solutions = optimise_with_funding_paths(
|
||||||
|
p=p,
|
||||||
|
input_measures=input_measures,
|
||||||
|
housing_type=body.housing_type,
|
||||||
|
budget=body.budget,
|
||||||
|
target_gain=gain,
|
||||||
|
funding=funding
|
||||||
|
)
|
||||||
|
|
||||||
|
# Given the solutions we select the optimal one
|
||||||
|
solutions["cost_less_full_project_funding"] = np.where(
|
||||||
|
solutions["scheme"] == "eco4",
|
||||||
|
solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"],
|
||||||
|
solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"]
|
||||||
|
)
|
||||||
|
|
||||||
|
solutions["cost_less_full_project_funding"] = (
|
||||||
|
solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"]
|
||||||
|
)
|
||||||
|
solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True)
|
||||||
|
|
||||||
|
if solutions["meets_upgrade_target"].any():
|
||||||
|
# If we have a solution that meets the upgrade target, we select that one
|
||||||
|
optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0]
|
||||||
|
else:
|
||||||
|
optimal_solution = solutions.iloc[0]
|
||||||
|
|
||||||
|
# This is the list of measures that we will recommend
|
||||||
|
scheme = optimal_solution["scheme"]
|
||||||
|
funded_measures = optimal_solution["items"] if scheme != "none" else []
|
||||||
|
solution = optimal_solution["items"] + optimal_solution["unfunded_items"]
|
||||||
|
# This is the total amount of funding that the project will produce (including uplifts) (£)
|
||||||
|
project_funding = optimal_solution["full_project_funding"]
|
||||||
|
# This is the total amount of funding associated to the uplift (£)
|
||||||
|
total_uplift = optimal_solution["total_uplift"]
|
||||||
|
# This is the funding scheme selected
|
||||||
|
# This is the full project ABS
|
||||||
|
full_project_score = optimal_solution["full_project_funding"]
|
||||||
|
# This is the partial project ABS
|
||||||
|
partial_project_score = optimal_solution["partial_project_score"]
|
||||||
|
# This is the uplift score ABS
|
||||||
|
uplift_project_score = optimal_solution["total_uplift_score"]
|
||||||
else:
|
else:
|
||||||
|
# We optimise and then we determine eligibility for funding, based on the measures selected
|
||||||
optimiser = (
|
optimiser = (
|
||||||
GainOptimiser(
|
GainOptimiser(
|
||||||
input_measures, max_cost=body.budget, max_gain=gain, allow_slack=body.goal == "Increasing EPC"
|
input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False
|
||||||
) if body.budget else CostOptimiser(input_measures, min_gain=gain)
|
) if body.budget else CostOptimiser(input_measures, min_gain=gain)
|
||||||
)
|
)
|
||||||
optimiser.setup()
|
optimiser.setup()
|
||||||
optimiser.solve()
|
optimiser.solve()
|
||||||
solution = optimiser.solution
|
solution = optimiser.solution
|
||||||
|
|
||||||
|
recommendation_types = []
|
||||||
|
for measures in input_measures:
|
||||||
|
for measure in measures:
|
||||||
|
recommendation_types.append(measure["type"])
|
||||||
|
recommendation_types = set(recommendation_types)
|
||||||
|
|
||||||
|
has_wall_insulation_recommendation = any(
|
||||||
|
(m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in
|
||||||
|
WALL_INSULATION_MEASURES
|
||||||
|
)
|
||||||
|
has_roof_insulation_recommendation = any(
|
||||||
|
(m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in
|
||||||
|
ROOF_INSULATION_MEASURES
|
||||||
|
)
|
||||||
|
|
||||||
|
funding.check_funding(
|
||||||
|
measures=solution,
|
||||||
|
starting_sap=p.data["current-energy-efficiency"],
|
||||||
|
ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]),
|
||||||
|
floor_area=p.floor_area,
|
||||||
|
mainheat_description=p.main_heating["clean_description"],
|
||||||
|
heating_control_description=p.main_heating_controls["clean_description"],
|
||||||
|
is_cavity=p.walls["is_cavity_wall"],
|
||||||
|
current_wall_uvalue=current_wall_u_value,
|
||||||
|
is_partial="partial" in p.walls["clean_description"].lower(),
|
||||||
|
existing_li_thickness=li_thickness,
|
||||||
|
mainheating=p.main_heating,
|
||||||
|
main_fuel=p.main_fuel,
|
||||||
|
mainheat_energy_eff=p.data["mainheat-energy-eff"],
|
||||||
|
has_wall_insulation_recommendation=has_wall_insulation_recommendation,
|
||||||
|
has_roof_insulation_recommendation=has_roof_insulation_recommendation,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine the scheme
|
||||||
|
scheme = "none"
|
||||||
|
if funding.eco4_eligible:
|
||||||
|
scheme = "eco4"
|
||||||
|
if scheme == "none" and funding.gbis_eligible:
|
||||||
|
scheme = "gbis"
|
||||||
|
|
||||||
|
funded_measures = solution if scheme in ["gbis", "eco4"] else []
|
||||||
|
project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs
|
||||||
|
total_uplift = funding.eco4_uplift
|
||||||
|
full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs
|
||||||
|
partial_project_score = funding.partial_project_abs
|
||||||
|
uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift
|
||||||
|
|
||||||
selected = {r["id"] for r in solution}
|
selected = {r["id"] for r in solution}
|
||||||
|
|
||||||
if property_required_measures:
|
if property_required_measures:
|
||||||
|
|
@ -852,6 +1027,21 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
p.id, recommendations, selected
|
p.id, recommendations, selected
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# TODO: functionise
|
||||||
|
for measure in funded_measures:
|
||||||
|
if "+mechanical_ventilation" in measure["type"]:
|
||||||
|
measure["type"] = measure["type"].split("+mechanical_ventilation")[0]
|
||||||
|
|
||||||
|
p.insert_funding(
|
||||||
|
scheme=scheme,
|
||||||
|
funded_measures=funded_measures,
|
||||||
|
project_funding=project_funding,
|
||||||
|
total_uplift=total_uplift,
|
||||||
|
full_project_score=full_project_score,
|
||||||
|
partial_project_score=partial_project_score,
|
||||||
|
uplift_project_score=uplift_project_score
|
||||||
|
)
|
||||||
|
|
||||||
# when we have buildings, we tweak our solar PV recommendations as if one unit needs it, we apply it to all
|
# when we have buildings, we tweak our solar PV recommendations as if one unit needs it, we apply it to all
|
||||||
# of them
|
# of them
|
||||||
# TODO: We can probably do better and optimise at the building level - this is temp
|
# TODO: We can probably do better and optimise at the building level - this is temp
|
||||||
|
|
@ -878,28 +1068,6 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
# need to be updated
|
# need to be updated
|
||||||
rec["default"] = True
|
rec["default"] = True
|
||||||
|
|
||||||
# ~~~~~~~~~~~~~~~~
|
|
||||||
# Funding
|
|
||||||
# ~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
# for p in input_properties:
|
|
||||||
# funding_calulator = Funding(
|
|
||||||
# tenure=body.housing_type,
|
|
||||||
# starting_epc=p.data["current-energy-rating"],
|
|
||||||
# starting_sap=int(p.data["current-energy-efficiency"]),
|
|
||||||
# postcode=p.postcode,
|
|
||||||
# floor_area=p.floor_area,
|
|
||||||
# council_tax_band=None, # This is seemingly always None at the moment
|
|
||||||
# property_recommendations=recommendations[p.id],
|
|
||||||
# project_scores_matrix=eco_project_scores_matrix,
|
|
||||||
# whlg_eligible_postcodes=whlg_eligible_postcodes,
|
|
||||||
# gbis_abs_rate=15,
|
|
||||||
# eco4_abs_rate=15,
|
|
||||||
# )
|
|
||||||
# funding_calulator.check_eligibiltiy()
|
|
||||||
# # Insert finding
|
|
||||||
# p.insert_funding(funding_calulator)
|
|
||||||
|
|
||||||
logger.info("Uploading recommendations to the database")
|
logger.info("Uploading recommendations to the database")
|
||||||
# If we have any work to do, we create a new scenario
|
# If we have any work to do, we create a new scenario
|
||||||
if body.scenario_id:
|
if body.scenario_id:
|
||||||
|
|
@ -988,6 +1156,8 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
session, recommendations_to_upload, p.id, new_plan_id
|
session, recommendations_to_upload, p.id, new_plan_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
upload_funding(session, p, new_plan_id, recommendations_to_upload)
|
||||||
|
|
||||||
property_valuation_increases.append(
|
property_valuation_increases.append(
|
||||||
valuations["average_increased_value"] - valuations["current_value"]
|
valuations["average_increased_value"] - valuations["current_value"]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
16565
backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv
Normal file
16565
backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv
Normal file
File diff suppressed because it is too large
Load diff
104
backend/tests/test_data/heating_scenarios.py
Normal file
104
backend/tests/test_data/heating_scenarios.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
from backend.Funding import EligibilityCaveats
|
||||||
|
|
||||||
|
heating_scenarios = [
|
||||||
|
{
|
||||||
|
"description": "EPC D with ASHP and no insulation at all — fails precondition 1",
|
||||||
|
"measures": [{"type": "air_source_heat_pump"}],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "air source heat pump",
|
||||||
|
"heating_control_description": "roomstat_programmer_trvs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "EPC D with ASHP and no insulation at all — fails precondition 1",
|
||||||
|
"measures": [{"type": "air_source_heat_pump"}],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "air source heat pump",
|
||||||
|
"heating_control_description": "roomstat_programmer_trvs",
|
||||||
|
"has_wall_insulation_recommendation": False,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "EPC D with ASHP and floor insulation — passes precondition 1",
|
||||||
|
"measures": [
|
||||||
|
{"type": "air_source_heat_pump"},
|
||||||
|
{"type": "suspended_floor_insulation"}
|
||||||
|
],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"has_wall_insulation_recommendation": False,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"mainheat_description": "air source heat pump",
|
||||||
|
"heating_control_description": "roomstat_programmer_trvs",
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "EPC E with ASHP and only floor insulation — fails precondition 2 due to missing wall/roof",
|
||||||
|
"measures": [
|
||||||
|
{"type": "air_source_heat_pump"},
|
||||||
|
{"type": "suspended_floor_insulation"}
|
||||||
|
],
|
||||||
|
"starting_sap": 45,
|
||||||
|
"mainheat_description": "air source heat pump",
|
||||||
|
"heating_control_description": "roomstat_programmer_trvs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "EPC E with ASHP and both wall and roof insulation — passes precondition 2",
|
||||||
|
"measures": [
|
||||||
|
{"type": "air_source_heat_pump"},
|
||||||
|
{"type": "external_wall_insulation"},
|
||||||
|
{"type": "loft_insulation"}
|
||||||
|
],
|
||||||
|
"starting_sap": 45,
|
||||||
|
"mainheat_description": "air source heat pump",
|
||||||
|
"heating_control_description": "roomstat_programmer_trvs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "EPC D with FTCH and no insulation — still passes (exempt from precondition 1)",
|
||||||
|
"measures": [{"type": "first_time_central_heating"}],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "none",
|
||||||
|
"heating_control_description": "none",
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "EPC E with FTCH and no insulation — fails precondition 2",
|
||||||
|
"measures": [{"type": "first_time_central_heating"}],
|
||||||
|
"starting_sap": 45,
|
||||||
|
"mainheat_description": "none",
|
||||||
|
"heating_control_description": "none",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "EPC E with FTCH and wall/roof insulation — passes precondition 2",
|
||||||
|
"measures": [
|
||||||
|
{"type": "first_time_central_heating"},
|
||||||
|
{"type": "external_wall_insulation"},
|
||||||
|
{"type": "loft_insulation"},
|
||||||
|
],
|
||||||
|
"starting_sap": 45,
|
||||||
|
"mainheat_description": "none",
|
||||||
|
"heating_control_description": "none",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
170
backend/tests/test_data/innovation_measure_fixtures.py
Normal file
170
backend/tests/test_data/innovation_measure_fixtures.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
from backend.Funding import EligibilityCaveats
|
||||||
|
|
||||||
|
innovation_scenarios = [
|
||||||
|
# 1) Innovation PV, non-eligible heating system in place, EPC D - not eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, non-eligible heating system in place, EPC D",
|
||||||
|
"measures": [{"type": "solar_pv", "is_innovation": True, "uplift": 0.45}],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Electric storage heaters",
|
||||||
|
"heating_control_description": "Manual charge control",
|
||||||
|
"has_wall_insulation_recommendation": False,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.SOLAR_NEEDS_HEATING],
|
||||||
|
},
|
||||||
|
# 2) Innovation PV, eligible heating system in place, EPC D - eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, eligible heating system in place, EPC D",
|
||||||
|
"measures": [{"type": "solar_pv", "is_innovation": True, "uplift": 0.45}],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": False,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
# 3) Innovation PV, non-eligible heating system, heating upgrade to HHRSH, EPC E - eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV + HHRSH upgrade, EPC E",
|
||||||
|
"measures": [
|
||||||
|
{"type": "solar_pv", "is_innovation": True, "uplift": 0.45},
|
||||||
|
{"type": "high_heat_retention_storage_heater", "is_innovation": True, "uplift": 0.1}
|
||||||
|
],
|
||||||
|
"starting_sap": 50,
|
||||||
|
"mainheat_description": "Electric storage heaters",
|
||||||
|
"heating_control_description": "Manual charge control",
|
||||||
|
"has_wall_insulation_recommendation": False,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
# 4) Innovation PV + HHRSH upgrade
|
||||||
|
{
|
||||||
|
"description": "Innovation PV + HHRSH upgrade, EPC E",
|
||||||
|
"measures": [
|
||||||
|
{"type": "solar_pv", "is_innovation": True, "uplift": 0.45},
|
||||||
|
{"type": "high_heat_retention_storage_heater", "is_innovation": True, "uplift": 0.1}
|
||||||
|
],
|
||||||
|
"starting_sap": 50,
|
||||||
|
"mainheat_description": "Electric storage heaters",
|
||||||
|
"heating_control_description": "Manual charge control",
|
||||||
|
"has_wall_insulation_recommendation": False,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
# 5) Innovation PV, needs wall insulation, no wall insulation measure - not eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, wall insulation recommended, but not installed",
|
||||||
|
"measures": [{"type": "solar_pv", "is_innovation": True, "uplift": 0.45}],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
# 6) Innovation PV, wall insulation recommended and installed - eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, wall insulation recommended and installed",
|
||||||
|
"measures": [
|
||||||
|
{"type": "solar_pv", "is_innovation": True, "uplift": 0.45},
|
||||||
|
{"type": "internal_wall_insulation", "is_innovation": False, "uplift": 0.25}
|
||||||
|
],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": False,
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
# 7) Innovation PV, needs roof insulation, no roof insulation measure - not eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, roof insulation recommended, not installed",
|
||||||
|
"measures": [{"type": "solar_pv", "is_innovation": True, "uplift": 0.45}],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": False,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
# 8) Innovation PV, roof insulation recommended and installed - eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, roof insulation recommended and installed",
|
||||||
|
"measures": [
|
||||||
|
{"type": "solar_pv", "is_innovation": True, "uplift": 0.45},
|
||||||
|
{"type": "loft_insulation", "is_innovation": False, "uplift": 0}
|
||||||
|
],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": False,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
# 9) Innovation PV, needs both roof + wall insulation, no insulation - not eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, both insulations recommended, none installed",
|
||||||
|
"measures": [{"type": "solar_pv", "is_innovation": True, "uplift": 0.45}],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
# 10) Innovation PV, both recommended, only wall insulation installed - not eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, both insulations recommended, only wall done",
|
||||||
|
"measures": [
|
||||||
|
{"type": "solar_pv", "is_innovation": True, "uplift": 0.45},
|
||||||
|
{"type": "internal_wall_insulation", "is_innovation": False, "uplift": 0.25}
|
||||||
|
],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
# 11) Innovation PV, both recommended, only roof insulation installed - not eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, both insulations recommended, only roof done",
|
||||||
|
"measures": [
|
||||||
|
{"type": "solar_pv", "is_innovation": True, "uplift": 0.45},
|
||||||
|
{"type": "loft_insulation", "is_innovation": False, "uplift": 0}
|
||||||
|
],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": False,
|
||||||
|
"expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET],
|
||||||
|
},
|
||||||
|
# 12) Innovation PV, both recommended, both installed - eligible
|
||||||
|
{
|
||||||
|
"description": "Innovation PV, both insulations recommended and installed",
|
||||||
|
"measures": [
|
||||||
|
{"type": "solar_pv", "is_innovation": True, "uplift": 0.45},
|
||||||
|
{"type": "internal_wall_insulation", "is_innovation": False, "uplift": 0.25},
|
||||||
|
{"type": "loft_insulation", "is_innovation": False, "uplift": 0}
|
||||||
|
],
|
||||||
|
"starting_sap": 60,
|
||||||
|
"mainheat_description": "Air source heat pump, radiators",
|
||||||
|
"heating_control_description": "Programmer, room thermostat and TRVs",
|
||||||
|
"has_wall_insulation_recommendation": True,
|
||||||
|
"has_roof_insulation_recommendation": True,
|
||||||
|
"expected_eligibility": True,
|
||||||
|
"expected_caveats": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
144
backend/tests/test_data/pre_heating_scenarios.py
Normal file
144
backend/tests/test_data/pre_heating_scenarios.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
# Each scenario: super explicit about inputs and expected mapping
|
||||||
|
pre_main_heating_scenarios = [
|
||||||
|
# --- Mains gas boilers (radiators) ---
|
||||||
|
{
|
||||||
|
"description": "Boiler and radiators, mains gas (condensing expected)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Boiler and radiators, mains gas",
|
||||||
|
"MAIN_FUEL": "mains gas (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Good",
|
||||||
|
"expected": "Condensing Gas Boiler",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Boiler and radiators, mains gas (non-condensing expected)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Boiler and radiators, mains gas",
|
||||||
|
"MAIN_FUEL": "mains gas - this is for backwards compatibility only and should not be used",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Average",
|
||||||
|
"expected": "Non Condensing Gas Boiler",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Boiler and radiators, mains gas (very poor => back boiler to rads)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Boiler and radiators, mains gas",
|
||||||
|
"MAIN_FUEL": "Gas: mains gas",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Very Poor",
|
||||||
|
"expected": "Gas Back Boiler to Radiators",
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- Warm air (treated like gas boiler family in your mapper) ---
|
||||||
|
{
|
||||||
|
"description": "Warm air, mains gas (good => condensing)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Warm air, mains gas",
|
||||||
|
"MAIN_FUEL": "mains gas (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Good",
|
||||||
|
"expected": "Condensing Gas Boiler",
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- Community scheme (CHP vs non-CHP depends on energy eff) ---
|
||||||
|
{
|
||||||
|
"description": "Community scheme (gas, good => CHP)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Community scheme",
|
||||||
|
"MAIN_FUEL": "mains gas (community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Good",
|
||||||
|
"expected": "DHS CHP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Community scheme (gas, average => non-CHP)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Community scheme",
|
||||||
|
"MAIN_FUEL": "mains gas (community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Average",
|
||||||
|
"expected": "DHS non-CHP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Community scheme (no fuel data, good => CHP)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Community scheme",
|
||||||
|
"MAIN_FUEL": "NO DATA!",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Good",
|
||||||
|
"expected": "DHS CHP",
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- Electric storage heaters (ESH responsiveness split) ---
|
||||||
|
{
|
||||||
|
"description": "Electric storage heaters (average => responsiveness > 0.2)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Electric storage heaters",
|
||||||
|
"MAIN_FUEL": "electricity (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Average",
|
||||||
|
"expected": "Electric Storage Heaters Responsiveness >0.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Electric storage heaters (poor => responsiveness > 0.2)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Electric storage heaters",
|
||||||
|
"MAIN_FUEL": "electricity - this is for backwards compatibility only and should not be used",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Poor",
|
||||||
|
"expected": "Electric Storage Heaters Responsiveness >0.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Electric storage heaters (very poor => responsiveness <= 0.2)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Electric storage heaters",
|
||||||
|
"MAIN_FUEL": "electricity (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Very Poor",
|
||||||
|
"expected": "Electric Storage Heaters Responsiveness <=0.2",
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- Electric direct-acting / room heaters ---
|
||||||
|
{
|
||||||
|
"description": "Room heaters, electric (very poor)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Room heaters, electric",
|
||||||
|
"MAIN_FUEL": "electricity (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Very Poor",
|
||||||
|
"expected": "Electric Room Heaters",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Room heaters, electric (poor, unspecified tariff)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Room heaters, electric",
|
||||||
|
"MAIN_FUEL": "Electricity: electricity, unspecified tariff",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Poor",
|
||||||
|
"expected": "Electric Room Heaters",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Portable electric heaters assumed for most rooms (maps to electric room heaters)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Portable electric heaters assumed for most rooms",
|
||||||
|
"MAIN_FUEL": "mains gas (not community)", # weird in EPCs, mapper forces electric room heaters here
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Very Poor",
|
||||||
|
"expected": "Electric Room Heaters",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "No system present: electric heaters assumed",
|
||||||
|
"MAINHEAT_DESCRIPTION": "No system present: electric heaters assumed",
|
||||||
|
"MAIN_FUEL": "To be used only when there is no heating/hot-water system",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Very Poor",
|
||||||
|
"expected": "Electric Room Heaters",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Electric underfloor heating => direct-acting electric",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Electric underfloor heating",
|
||||||
|
"MAIN_FUEL": "electricity (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Average",
|
||||||
|
"expected": "Electric Room Heaters",
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- Gas room heaters ---
|
||||||
|
{
|
||||||
|
"description": "Room heaters, mains gas (average)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Room heaters, mains gas",
|
||||||
|
"MAIN_FUEL": "mains gas (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Average",
|
||||||
|
"expected": "Gas Room Heaters",
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- Electric boiler ---
|
||||||
|
{
|
||||||
|
"description": "Boiler and radiators, electric (very poor => electric boiler)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Boiler and radiators, electric",
|
||||||
|
"MAIN_FUEL": "electricity (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Very Poor",
|
||||||
|
"expected": "Electric Boiler",
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- Gas boiler + UFH (still ‘boiler’ logic) ---
|
||||||
|
{
|
||||||
|
"description": "Boiler and underfloor heating, mains gas (good => condensing)",
|
||||||
|
"MAINHEAT_DESCRIPTION": "Boiler and underfloor heating, mains gas",
|
||||||
|
"MAIN_FUEL": "mains gas (not community)",
|
||||||
|
"MAINHEAT_ENERGY_EFF": "Good",
|
||||||
|
"expected": "Condensing Gas Boiler",
|
||||||
|
},
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,7 @@ import inspect
|
||||||
|
|
||||||
src_file_path = inspect.getfile(lambda: None)
|
src_file_path = inspect.getfile(lambda: None)
|
||||||
|
|
||||||
DATA_DIRECTORY = Path(src_file_path).parent / "local_data" / "20250316 Domna Materials.xlsx"
|
DATA_DIRECTORY = Path(src_file_path).parent / "local_data" / "20250815 Domna Materials.xlsx"
|
||||||
# Environment file is at the same level as this file
|
# Environment file is at the same level as this file
|
||||||
ENV_FILE = Path(src_file_path).parent / "etl" / "costs" / ".env"
|
ENV_FILE = Path(src_file_path).parent / "etl" / "costs" / ".env"
|
||||||
dotenv.load_dotenv(ENV_FILE)
|
dotenv.load_dotenv(ENV_FILE)
|
||||||
|
|
@ -92,6 +92,10 @@ def app():
|
||||||
flat_roof_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="flat_roof_insulation", header=0)
|
flat_roof_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="flat_roof_insulation", header=0)
|
||||||
window_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="window_glazing", header=0)
|
window_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="window_glazing", header=0)
|
||||||
rir_insulation_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="room_roof_insulation", header=0)
|
rir_insulation_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="room_roof_insulation", header=0)
|
||||||
|
solar_pv = pd.read_excel(DATA_DIRECTORY, sheet_name="solar_pv", header=0)
|
||||||
|
hhrsh = pd.read_excel(DATA_DIRECTORY, sheet_name="hhrsh", header=0)
|
||||||
|
scaffolding = pd.read_excel(DATA_DIRECTORY, sheet_name="scaffolding", header=0)
|
||||||
|
fireplaces = pd.read_excel(DATA_DIRECTORY, sheet_name="fireplaces", header=0)
|
||||||
|
|
||||||
# Form a single table to be uploaded
|
# Form a single table to be uploaded
|
||||||
costs = pd.concat(
|
costs = pd.concat(
|
||||||
|
|
@ -107,6 +111,10 @@ def app():
|
||||||
flat_roof_costs,
|
flat_roof_costs,
|
||||||
window_costs,
|
window_costs,
|
||||||
rir_insulation_costs,
|
rir_insulation_costs,
|
||||||
|
solar_pv,
|
||||||
|
hhrsh,
|
||||||
|
scaffolding,
|
||||||
|
fireplaces
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from etl.epc.settings import (
|
||||||
IGNORED_TENURES,
|
IGNORED_TENURES,
|
||||||
FULLY_GLAZED_DESCRIPTIONS,
|
FULLY_GLAZED_DESCRIPTIONS,
|
||||||
AVERAGE_FIXED_FEATURES,
|
AVERAGE_FIXED_FEATURES,
|
||||||
BUILT_FORM_REMAP,
|
|
||||||
COLUMNS_TO_MERGE_ON,
|
COLUMNS_TO_MERGE_ON,
|
||||||
FIXED_FEATURES,
|
FIXED_FEATURES,
|
||||||
COLUMNTYPES,
|
COLUMNTYPES,
|
||||||
|
|
@ -123,7 +122,6 @@ class EPCDataProcessor:
|
||||||
self.confine_data(ignore_step=ignore_step)
|
self.confine_data(ignore_step=ignore_step)
|
||||||
self.remap_anomalies()
|
self.remap_anomalies()
|
||||||
self.remap_floor_level(ignore_step=ignore_step)
|
self.remap_floor_level(ignore_step=ignore_step)
|
||||||
self.remap_build_form()
|
|
||||||
self.cast_data_column_values_to_lower()
|
self.cast_data_column_values_to_lower()
|
||||||
self.standardise_construction_age_band(ignore_step=ignore_step)
|
self.standardise_construction_age_band(ignore_step=ignore_step)
|
||||||
self.clean_missing_rooms(ignore_step=ignore_step)
|
self.clean_missing_rooms(ignore_step=ignore_step)
|
||||||
|
|
@ -240,13 +238,6 @@ class EPCDataProcessor:
|
||||||
for col in convert_to_lower:
|
for col in convert_to_lower:
|
||||||
self.data[col] = self.data[col].str.lower()
|
self.data[col] = self.data[col].str.lower()
|
||||||
|
|
||||||
def remap_build_form(self):
|
|
||||||
"""
|
|
||||||
Remap build form to standard values
|
|
||||||
No Violation mode or newdata modes required
|
|
||||||
"""
|
|
||||||
self.data["BUILT_FORM"] = self.data["BUILT_FORM"].replace(BUILT_FORM_REMAP)
|
|
||||||
|
|
||||||
def remap_anomalies(self):
|
def remap_anomalies(self):
|
||||||
"""
|
"""
|
||||||
Remap anomalies to None
|
Remap anomalies to None
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from etl.epc.ValidationConfiguration import (
|
||||||
)
|
)
|
||||||
from etl.epc.DataProcessor import EPCDataProcessor
|
from etl.epc.DataProcessor import EPCDataProcessor
|
||||||
from recommendations.rdsap_tables import england_wales_age_band_lookup, FLOOR_LEVEL_MAP
|
from recommendations.rdsap_tables import england_wales_age_band_lookup, FLOOR_LEVEL_MAP
|
||||||
from etl.epc.settings import DATA_ANOMALY_MATCHES, BUILT_FORM_REMAP
|
from etl.epc.settings import DATA_ANOMALY_MATCHES
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -748,10 +748,6 @@ class EPCRecord:
|
||||||
if not self.prepared_epc:
|
if not self.prepared_epc:
|
||||||
raise ValueError("EPC Recrod doesn not contain epc data")
|
raise ValueError("EPC Recrod doesn not contain epc data")
|
||||||
|
|
||||||
self.prepared_epc["built-form"] = BUILT_FORM_REMAP.get(
|
|
||||||
self.prepared_epc["built-form"], self.prepared_epc["built-form"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.prepared_epc["built-form"] in DATA_ANOMALY_MATCHES:
|
if self.prepared_epc["built-form"] in DATA_ANOMALY_MATCHES:
|
||||||
if self.prepared_epc["property-type"] in ["Flat", "Maisonette"]:
|
if self.prepared_epc["property-type"] in ["Flat", "Maisonette"]:
|
||||||
self.prepared_epc["built-form"] = "End-Terrace"
|
self.prepared_epc["built-form"] = "End-Terrace"
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import os
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import msgpack
|
import msgpack
|
||||||
import inspect
|
import inspect
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from etl.epc_clean.EpcClean import EpcClean
|
from etl.epc_clean.EpcClean import EpcClean
|
||||||
from etl.epc.settings import EARLIEST_EPC_DATE
|
from etl.epc.settings import EARLIEST_EPC_DATE
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from utils.s3 import save_data_to_s3
|
from utils.s3 import save_data_to_s3, read_from_s3
|
||||||
|
|
||||||
src_file_path = inspect.getfile(lambda: None)
|
src_file_path = inspect.getfile(lambda: None)
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ LAND_REGISTRY_PATHS = [
|
||||||
os.path.abspath(os.path.dirname(src_file_path)) + "/model_data/local_data/pp-2017-part2.csv",
|
os.path.abspath(os.path.dirname(src_file_path)) + "/model_data/local_data/pp-2017-part2.csv",
|
||||||
]
|
]
|
||||||
|
|
||||||
EPC_DIRECTORY = Path(src_file_path).parent / "local_data" / "all-domestic-certificates"
|
EPC_DIRECTORY = Path("/Users/khalimconn-kowlessar/Downloads") / "all-domestic-certificates"
|
||||||
|
|
||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "dev")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "dev")
|
||||||
|
|
||||||
|
|
@ -39,28 +40,35 @@ def app():
|
||||||
cleaned_data = {}
|
cleaned_data = {}
|
||||||
epc_directories = [entry for entry in EPC_DIRECTORY.iterdir() if entry.is_dir()]
|
epc_directories = [entry for entry in EPC_DIRECTORY.iterdir() if entry.is_dir()]
|
||||||
|
|
||||||
|
errors = []
|
||||||
for directory in tqdm(epc_directories):
|
for directory in tqdm(epc_directories):
|
||||||
data = pd.read_csv(directory / "certificates.csv", low_memory=False)
|
try:
|
||||||
# Rename the columns to the same format as the api returns
|
data = pd.read_csv(directory / "certificates.csv", low_memory=False)
|
||||||
data.columns = [c.replace("_", "-").lower() for c in data.columns]
|
# Rename the columns to the same format as the api returns
|
||||||
# Take just date before the date threshold
|
data.columns = [c.replace("_", "-").lower() for c in data.columns]
|
||||||
data = data[data["lodgement-date"] >= "2011-01-01"]
|
# Take just date before the date threshold
|
||||||
|
data = data[data["lodgement-date"] >= "2011-01-01"]
|
||||||
|
|
||||||
# Convert to list of dictioaries as returned by the api
|
# Convert to list of dictioaries as returned by the api
|
||||||
data = data.to_dict("records")
|
data = data.to_dict("records")
|
||||||
|
|
||||||
# Incorporate input data into cleaning
|
# Incorporate input data into cleaning
|
||||||
cleaner = EpcClean(data)
|
cleaner = EpcClean(data)
|
||||||
|
|
||||||
cleaner.clean()
|
cleaner.clean()
|
||||||
# Extended cleaned_data
|
# Extended cleaned_data
|
||||||
for k, data in cleaner.cleaned.items():
|
for k, data in cleaner.cleaned.items():
|
||||||
if k not in cleaned_data:
|
if k not in cleaned_data:
|
||||||
cleaned_data[k] = data
|
cleaned_data[k] = data
|
||||||
else:
|
else:
|
||||||
existing_descriptions = [x["original_description"] for x in cleaned_data[k]]
|
existing_descriptions = [x["original_description"] for x in cleaned_data[k]]
|
||||||
new_data = [x for x in data if x["original_description"] not in existing_descriptions]
|
new_data = [x for x in data if x["original_description"] not in existing_descriptions]
|
||||||
cleaned_data[k].extend(new_data)
|
cleaned_data[k].extend(new_data)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(directory)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise ValueError("We have errors")
|
||||||
|
|
||||||
# Basic check to make sure all descriptions are unique
|
# Basic check to make sure all descriptions are unique
|
||||||
for _, cleaned in cleaned_data.items():
|
for _, cleaned in cleaned_data.items():
|
||||||
|
|
@ -74,6 +82,17 @@ def app():
|
||||||
# data being read in will be extremely small, meaning quicker load times. We'll begin by storing as a single
|
# data being read in will be extremely small, meaning quicker load times. We'll begin by storing as a single
|
||||||
# file and monitor usage patterns to see if it makes sense to split the data up
|
# file and monitor usage patterns to see if it makes sense to split the data up
|
||||||
|
|
||||||
|
cleaned_historic = read_from_s3(
|
||||||
|
s3_file_name="cleaned_epc_data/cleaned.bson",
|
||||||
|
bucket_name=f"retrofit-data-{ENVIRONMENT}"
|
||||||
|
)
|
||||||
|
cleaned_historic = msgpack.unpackb(cleaned_historic, raw=False)
|
||||||
|
save_data_to_s3(
|
||||||
|
data=msgpack.packb(cleaned_historic, use_bin_type=True),
|
||||||
|
s3_file_name=f"cleaned_epc_data/archive/{str(datetime.now())} - cleaned.bson",
|
||||||
|
bucket_name=f"retrofit-data-{ENVIRONMENT}"
|
||||||
|
)
|
||||||
|
|
||||||
save_data_to_s3(
|
save_data_to_s3(
|
||||||
data=msgpack.packb(cleaned_data, use_bin_type=True),
|
data=msgpack.packb(cleaned_data, use_bin_type=True),
|
||||||
s3_file_name="cleaned_epc_data/cleaned.bson",
|
s3_file_name="cleaned_epc_data/cleaned.bson",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ class FloorAttributes(Definitions):
|
||||||
"i ofod heb ei wresogi, dim inswleiddio (rhagdybiaeth)": "to unheated space, no insulation (assumed)",
|
"i ofod heb ei wresogi, dim inswleiddio (rhagdybiaeth)": "to unheated space, no insulation (assumed)",
|
||||||
"i ofod heb ei wresogi, heb ei inswleiddio (rhagdybiaeth)": "to unheated space, no insulation (assumed)",
|
"i ofod heb ei wresogi, heb ei inswleiddio (rhagdybiaeth)": "to unheated space, no insulation (assumed)",
|
||||||
"i ofod heb ei wresogi, dim inswleiddio": "to unheated space, no insulation",
|
"i ofod heb ei wresogi, dim inswleiddio": "to unheated space, no insulation",
|
||||||
|
"igçör awyr y tu allan, wedigçöi inswleiddio (rhagdybiaeth)": "to external air, insulated (assumed)",
|
||||||
|
"crog, inswleiddio cyfyngedig (rhagdybiaeth)": "suspended, limited insulation (assumed)"
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, description: str):
|
def __init__(self, description: str):
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ class HotWaterAttributes(Definitions):
|
||||||
"o r brif system, gydag ynni r haul, dim thermostat ar y silindr": "from main system, plus solar, no cylinder "
|
"o r brif system, gydag ynni r haul, dim thermostat ar y silindr": "from main system, plus solar, no cylinder "
|
||||||
"thermostat",
|
"thermostat",
|
||||||
"o r brif system, gydag ynni r haul": "from main system, plus solar",
|
"o r brif system, gydag ynni r haul": "from main system, plus solar",
|
||||||
|
"pwmp gwres": "heat pump"
|
||||||
}
|
}
|
||||||
|
|
||||||
NODATA_DESCRIPTIONS = [
|
NODATA_DESCRIPTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ class LightingAttributes(Definitions):
|
||||||
"goleuadau ynni-isel ym mhob un ogçör mannau gosod": "low energy lighting in all fixed outlets",
|
"goleuadau ynni-isel ym mhob un ogçör mannau gosod": "low energy lighting in all fixed outlets",
|
||||||
"goleuadau ynni-isel ym mhob un o r mannau gosod": "low energy lighting in all fixed outlets",
|
"goleuadau ynni-isel ym mhob un o r mannau gosod": "low energy lighting in all fixed outlets",
|
||||||
"dim goleuadau ynni-isel": "no low energy lighting",
|
"dim goleuadau ynni-isel": "no low energy lighting",
|
||||||
"goleuadau ynni-isel ym mhob un o'r mannau gosod": 'Low energy lighting in all fixed outlets'
|
"goleuadau ynni-isel ym mhob un o'r mannau gosod": 'Low energy lighting in all fixed outlets',
|
||||||
|
"effeithlonrwydd goleuo da": 'good lighting efficiency',
|
||||||
|
"effeithlonrwydd goleuo is na'r cyfartaledd": 'below average lighting efficiency',
|
||||||
|
"effeithlonrwydd goleuo rhagorol": "excellent lighting efficiency"
|
||||||
}
|
}
|
||||||
|
|
||||||
OBSERVED_ERRORS = []
|
OBSERVED_ERRORS = []
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@ class MainHeatAttributes(Definitions):
|
||||||
"solar assisted heat pump",
|
"solar assisted heat pump",
|
||||||
"exhaust source heat pump",
|
"exhaust source heat pump",
|
||||||
"community heat pump",
|
"community heat pump",
|
||||||
|
"hot-water-only"
|
||||||
]
|
]
|
||||||
FUEL_TYPES = ["electric", "mains gas", "wood logs", "coal", "oil", "wood pellets", "anthracite",
|
FUEL_TYPES = ["electric", "mains gas", "wood logs", "coal", "oil", "wood pellets", "anthracite",
|
||||||
"dual fuel mineral and wood", "smokeless fuel", "lpg", "b30k"]
|
"dual fuel mineral and wood", "smokeless fuel", "lpg", "b30k", "mineral and wood",
|
||||||
|
"dual fuel appliance"]
|
||||||
DISTRIBUTION_SYSTEMS = ["radiators", "fan coil units", "pipes in screed above insulation",
|
DISTRIBUTION_SYSTEMS = ["radiators", "fan coil units", "pipes in screed above insulation",
|
||||||
"pipes in insulated timber floor", "pipes in concrete slab"]
|
"pipes in insulated timber floor", "pipes in concrete slab"]
|
||||||
OTHERS = ["assumed", "electricaire", "assumed for most rooms"]
|
OTHERS = ["assumed", "electricaire", "assumed for most rooms"]
|
||||||
|
|
@ -72,6 +74,10 @@ class MainHeatAttributes(Definitions):
|
||||||
"dim system ar gael, rhagdybir bod gwresogyddion trydan, trydan": "no system present, electric heaters assumed",
|
"dim system ar gael, rhagdybir bod gwresogyddion trydan, trydan": "no system present, electric heaters assumed",
|
||||||
# Should be handled by edge cases
|
# Should be handled by edge cases
|
||||||
", trydan": ", electric",
|
", trydan": ", electric",
|
||||||
|
'awyr gynnes, nwy prif gyflenwad': 'warm air, mains gas',
|
||||||
|
"bwyler a rheiddiaduron, nwy prif gyflenwad, gwresogyddion ystafell, trydan": "Boiler and radiators, "
|
||||||
|
"mains gas, Room heaters, "
|
||||||
|
"electric"
|
||||||
}
|
}
|
||||||
|
|
||||||
REMAP = {
|
REMAP = {
|
||||||
|
|
@ -86,6 +92,9 @@ class MainHeatAttributes(Definitions):
|
||||||
"gas-fired heat pumps, electric": "air source heat pump, electric",
|
"gas-fired heat pumps, electric": "air source heat pump, electric",
|
||||||
"radiator heating, heat from boilers - gas": "boiler and radiators, mains gas",
|
"radiator heating, heat from boilers - gas": "boiler and radiators, mains gas",
|
||||||
"heat pump, warm air, mains gas": "air source heat pump, warm air, mains gas",
|
"heat pump, warm air, mains gas": "air source heat pump, warm air, mains gas",
|
||||||
|
"air sourceheat pump, radiators, electric": "air source heat pump, radiators, electric",
|
||||||
|
"bwyler gyda rheiddiaduron a gwres dan y llawr, nwy prif gyflenwad": "Boiler and radiators, mains gas, "
|
||||||
|
"Boiler and underfloor heating, mains gas",
|
||||||
}
|
}
|
||||||
|
|
||||||
edge_case_result = {}
|
edge_case_result = {}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ class MainheatControlAttributes(Definitions):
|
||||||
TO_REMAP = {
|
TO_REMAP = {
|
||||||
"celect control": 'celect-type control',
|
"celect control": 'celect-type control',
|
||||||
"celect controls": 'celect-type control',
|
"celect controls": 'celect-type control',
|
||||||
|
"celect type controls": 'celect-type control',
|
||||||
"trv's, program & flow switch": 'trvs, programmer & flow switch',
|
"trv's, program & flow switch": 'trvs, programmer & flow switch',
|
||||||
'appliance thermostat': 'appliance thermostats',
|
'appliance thermostat': 'appliance thermostats',
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +119,7 @@ class MainheatControlAttributes(Definitions):
|
||||||
'rheoli r tal a llaw': 'manual charge control',
|
'rheoli r tal a llaw': 'manual charge control',
|
||||||
'tal un gyfradd, thermostat ystafell yn unig': 'flat rate charging, room thermostat only',
|
'tal un gyfradd, thermostat ystafell yn unig': 'flat rate charging, room thermostat only',
|
||||||
"rheoli'r t l llaw": "manual charge control",
|
"rheoli'r t l llaw": "manual charge control",
|
||||||
|
"2205 rhaglennydd ac o leiaf ddau thermostat ystafell": "programmer and at least two room thermostats"
|
||||||
}
|
}
|
||||||
|
|
||||||
NO_DATA_DESCRIPTIONS = [
|
NO_DATA_DESCRIPTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,9 @@ def process_part(result: Dict[str, Union[str, bool]], part: str, attr_list: List
|
||||||
if set(attr_words).issubset(set(part_words)):
|
if set(attr_words).issubset(set(part_words)):
|
||||||
result[f'{prefix}{attr.replace(" ", "_")}'] = True
|
result[f'{prefix}{attr.replace(" ", "_")}'] = True
|
||||||
|
|
||||||
at_least_one_attribute_true = any(result.values())
|
# at_least_one_attribute_true = any(result.values())
|
||||||
if not at_least_one_attribute_true:
|
# if not at_least_one_attribute_true:
|
||||||
raise ValueError("No attribute matches found")
|
# raise ValueError("No attribute matches found")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
tqdm
|
||||||
|
pandas
|
||||||
|
msgpack
|
||||||
|
textblob
|
||||||
|
boto3
|
||||||
|
|
@ -53,12 +53,11 @@ def test_process_part_value_errors():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
attribute_utils.process_part(result, part, attr_list, prefix)
|
attribute_utils.process_part(result, part, attr_list, prefix)
|
||||||
|
|
||||||
|
# Test for no attribute matches found - we don't raise this error any more
|
||||||
# Test for no attribute matches found
|
# def test_process_part_no_matches():
|
||||||
def test_process_part_no_matches():
|
# result = {'has_glazing': False, 'has_glazed': False, 'has_glaze': False}
|
||||||
result = {'has_glazing': False, 'has_glazed': False, 'has_glaze': False}
|
# part = 'high performance coating'
|
||||||
part = 'high performance coating'
|
# attr_list = ['glazing', 'glazed', 'glaze']
|
||||||
attr_list = ['glazing', 'glazed', 'glaze']
|
# prefix = 'has_'
|
||||||
prefix = 'has_'
|
# with pytest.raises(ValueError):
|
||||||
with pytest.raises(ValueError):
|
# attribute_utils.process_part(result, part, attr_list, prefix)
|
||||||
attribute_utils.process_part(result, part, attr_list, prefix)
|
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,7 @@ mainheat_cases = [
|
||||||
'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': True,
|
'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': True,
|
||||||
'has_smokeless_fuel': False, 'has_lpg': False, 'has_assumed': False, 'has_electricaire': False,
|
'has_smokeless_fuel': False, 'has_lpg': False, 'has_assumed': False, 'has_electricaire': False,
|
||||||
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False, "has_electric_heat_pumps": False,
|
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False, "has_electric_heat_pumps": False,
|
||||||
"has_micro-cogeneration": False},
|
"has_micro-cogeneration": False, 'has_mineral_and_wood': True},
|
||||||
{'original_description': 'Room heaters, electric', 'has_radiators': False, 'has_fan_coil_units': False,
|
{'original_description': 'Room heaters, electric', 'has_radiators': False, 'has_fan_coil_units': False,
|
||||||
'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False,
|
'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False,
|
||||||
'has_pipes_in_concrete_slab': False, 'has_boiler': False, 'has_air_source_heat_pump': False,
|
'has_pipes_in_concrete_slab': False, 'has_boiler': False, 'has_air_source_heat_pump': False,
|
||||||
|
|
@ -1455,8 +1455,7 @@ mainheat_cases = [
|
||||||
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
|
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
|
||||||
'has_dual_fuel_mineral_and_wood': True, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_assumed': False,
|
'has_dual_fuel_mineral_and_wood': True, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_assumed': False,
|
||||||
'has_electricaire': False, 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False,
|
'has_electricaire': False, 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False,
|
||||||
"has_electric_heat_pumps": False,
|
"has_electric_heat_pumps": False, "has_micro-cogeneration": False, "has_mineral_and_wood": True},
|
||||||
"has_micro-cogeneration": False},
|
|
||||||
{'original_description': 'Bwyler a rheiddiaduron, dau danwydd (mwynau a choed)', 'has_radiators': True,
|
{'original_description': 'Bwyler a rheiddiaduron, dau danwydd (mwynau a choed)', 'has_radiators': True,
|
||||||
'has_fan_coil_units': False,
|
'has_fan_coil_units': False,
|
||||||
'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False,
|
'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False,
|
||||||
|
|
@ -1468,8 +1467,8 @@ mainheat_cases = [
|
||||||
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
|
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
|
||||||
'has_dual_fuel_mineral_and_wood': True, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_assumed': False,
|
'has_dual_fuel_mineral_and_wood': True, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_assumed': False,
|
||||||
'has_electricaire': False, 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False,
|
'has_electricaire': False, 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False,
|
||||||
"has_electric_heat_pumps": False,
|
"has_electric_heat_pumps": False, "has_micro-cogeneration": False, "has_mineral_and_wood": True
|
||||||
"has_micro-cogeneration": False},
|
},
|
||||||
{'original_description': 'Pwmp gwres syGÇÖn tarddu yn y ddaear, dan y llawr, trydan', 'has_radiators': False,
|
{'original_description': 'Pwmp gwres syGÇÖn tarddu yn y ddaear, dan y llawr, trydan', 'has_radiators': False,
|
||||||
'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False,
|
'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': False,
|
'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': False,
|
||||||
|
|
@ -1541,7 +1540,7 @@ mainheat_cases = [
|
||||||
'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': True,
|
'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': True,
|
||||||
'has_smokeless_fuel': False, 'has_lpg': False, 'has_assumed': False, 'has_electricaire': False,
|
'has_smokeless_fuel': False, 'has_lpg': False, 'has_assumed': False, 'has_electricaire': False,
|
||||||
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False, "has_electric_heat_pumps": False,
|
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False, "has_electric_heat_pumps": False,
|
||||||
"has_micro-cogeneration": False},
|
"has_micro-cogeneration": False, "has_mineral_and_wood": True},
|
||||||
{'original_description': 'Room heaters, wood pellets', 'has_radiators': False, 'has_fan_coil_units': False,
|
{'original_description': 'Room heaters, wood pellets', 'has_radiators': False, 'has_fan_coil_units': False,
|
||||||
'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False,
|
'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False,
|
||||||
'has_pipes_in_concrete_slab': False, 'has_boiler': False, 'has_air_source_heat_pump': False,
|
'has_pipes_in_concrete_slab': False, 'has_boiler': False, 'has_air_source_heat_pump': False,
|
||||||
|
|
@ -1707,6 +1706,52 @@ mainheat_cases = [
|
||||||
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
|
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
|
||||||
'has_assumed': False, 'has_electricaire': False, 'has_assumed_for_most_rooms': False,
|
'has_assumed': False, 'has_electricaire': False, 'has_assumed_for_most_rooms': False,
|
||||||
'has_underfloor_heating': False
|
'has_underfloor_heating': False
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original_description": "Boiler and radiators, dual fuel (mineral and wood)",
|
||||||
|
'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_electric': False, 'has_mains_gas': False, 'has_wood_logs': False,
|
||||||
|
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
|
||||||
|
'has_dual_fuel_mineral_and_wood': True, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
|
||||||
|
'has_mineral_and_wood': True, 'has_assumed': False, 'has_electricaire': False,
|
||||||
|
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original_description": "Room heaters, dual fuel appliance",
|
||||||
|
'has_radiators': False, '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': False,
|
||||||
|
'has_air_source_heat_pump': False, 'has_room_heaters': True, '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_electric': False, 'has_mains_gas': False, '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': True, 'has_assumed': False, 'has_electricaire': False,
|
||||||
|
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original_description": "Hot-Water-Only Systems, electric",
|
||||||
|
'has_radiators': False, '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': False,
|
||||||
|
'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': True, 'has_electric': True, 'has_mains_gas': False,
|
||||||
|
'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
|
||||||
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,6 @@ INSTALLER_SOLAR_COSTS = [
|
||||||
{'n_panels': 17, 'array_kwp': 17 * PANEL_SIZE, 'cost': 6637.95, 'installer': 'CEG'},
|
{'n_panels': 17, 'array_kwp': 17 * PANEL_SIZE, 'cost': 6637.95, 'installer': 'CEG'},
|
||||||
{'n_panels': 18, 'array_kwp': 18 * PANEL_SIZE, 'cost': 6792.57, 'installer': 'CEG'}
|
{'n_panels': 18, 'array_kwp': 18 * PANEL_SIZE, 'cost': 6792.57, 'installer': 'CEG'}
|
||||||
]
|
]
|
||||||
# This is the maximum number of panels that we have a cost from the installers for
|
|
||||||
INSTALLER_MAX_PANELS = 18
|
|
||||||
|
|
||||||
# CEG uses use Solshare as an inverter to provide solar PV to multiple flats. This costs £7500 for the inverter alone
|
# CEG uses use Solshare as an inverter to provide solar PV to multiple flats. This costs £7500 for the inverter alone
|
||||||
# https://midsummerwholesale.co.uk/buy/solshare
|
# https://midsummerwholesale.co.uk/buy/solshare
|
||||||
|
|
@ -101,8 +99,6 @@ INSTALLER_ASHP_COSTS = [
|
||||||
{'capacity_kw': None, 'brand': '2 x cascaded ASHPs', 'tank_size_liters': 500, 'cost': 22950.00, 'installer': 'CEG'}
|
{'capacity_kw': None, 'brand': '2 x cascaded ASHPs', 'tank_size_liters': 500, 'cost': 22950.00, 'installer': 'CEG'}
|
||||||
]
|
]
|
||||||
|
|
||||||
BOILER_UPGRADE_SCHEME_ASHP_VALUE = 7500
|
|
||||||
|
|
||||||
INSTALLER_SOLAR_BATTERY_COSTS = [
|
INSTALLER_SOLAR_BATTERY_COSTS = [
|
||||||
{'capacity_kwh': 5, 'description': 'Battery Add on', 'cost': 3769.89, 'installer': 'JJC'},
|
{'capacity_kwh': 5, 'description': 'Battery Add on', 'cost': 3769.89, 'installer': 'JJC'},
|
||||||
# {'capacity_kwh': 10, 'description': 'Battery Add on', 'cost': 4300.00, 'installer': 'CEG'},
|
# {'capacity_kwh': 10, 'description': 'Battery Add on', 'cost': 4300.00, 'installer': 'CEG'},
|
||||||
|
|
@ -131,19 +127,10 @@ TTZC_ROOM_TEMPERATURE_SENSOR_LABOUR_HOURS = 0.17 # (Assume ~ 10 mins install pe
|
||||||
TTZC_SMART_RADIATOR_VALUES = 50
|
TTZC_SMART_RADIATOR_VALUES = 50
|
||||||
TTZC_SMART_RADIATOR_VALUES_LABOUR_HOURS = 0.37 # (Assume ~ 15-30 mins install per valve)
|
TTZC_SMART_RADIATOR_VALUES_LABOUR_HOURS = 0.37 # (Assume ~ 15-30 mins install per valve)
|
||||||
|
|
||||||
# Low carbon combi boiler - median value based on £2200 - £3000 range
|
|
||||||
LOW_CARBON_COMBI_BOILER = 2200
|
|
||||||
|
|
||||||
# boiler prices based on
|
# boiler prices based on
|
||||||
# https://www.greenmatch.co.uk/boilers/30kw-boiler
|
# This is the cost of a firs time central heating install from The Warm Front rate card
|
||||||
# https://www.greenmatch.co.uk/boilers/35kw-boiler
|
|
||||||
# https://www.greenmatch.co.uk/boilers/40kw-boiler
|
|
||||||
# These are exclusive of installation costs
|
# These are exclusive of installation costs
|
||||||
CONDENSING_BOILER_COSTS = {
|
CONDENSING_BOILER_COST = 2600
|
||||||
"30kw": 1550,
|
|
||||||
"35kw": 1610,
|
|
||||||
"40kw": 1625
|
|
||||||
}
|
|
||||||
|
|
||||||
# Electric boiler prices base on
|
# Electric boiler prices base on
|
||||||
# https://www.greenmatch.co.uk/boilers/combi-boilers/electric-combi-boilers
|
# https://www.greenmatch.co.uk/boilers/combi-boilers/electric-combi-boilers
|
||||||
|
|
@ -155,10 +142,6 @@ ELECTRIC_BOILER_COSTS = 1800
|
||||||
ROOM_HEATER_REMOVAL_COST = 25
|
ROOM_HEATER_REMOVAL_COST = 25
|
||||||
ROOM_HEATER_REMOVAL_LABOUR_HOURS = 3
|
ROOM_HEATER_REMOVAL_LABOUR_HOURS = 3
|
||||||
|
|
||||||
# This is a cost quoted by Jim for a system flush - existig system will run more efficiently
|
|
||||||
SYSTEM_FLUSH_COST = 250
|
|
||||||
|
|
||||||
SINGLE_RADIATOR_COST = 150
|
|
||||||
DOUBLE_RADIATOR_COST = 300
|
DOUBLE_RADIATOR_COST = 300
|
||||||
FLUE_COST = 600
|
FLUE_COST = 600
|
||||||
PIPEWORK_COST = 750 # Min cost is £500
|
PIPEWORK_COST = 750 # Min cost is £500
|
||||||
|
|
@ -185,33 +168,26 @@ class Costs:
|
||||||
# We assume a conservative 10% contingency for all works which is a rate defined by SPONs
|
# We assume a conservative 10% contingency for all works which is a rate defined by SPONs
|
||||||
CONTINGENCY = 0.1
|
CONTINGENCY = 0.1
|
||||||
|
|
||||||
# For flat roof, we assume it's a high risk project as it's very weather dependent and also is heavily
|
# Measure level contingency
|
||||||
# dependent on the quality of the existing roof
|
CONTINGENCIES = {
|
||||||
FLAT_ROOF_CONTINGENCY = 0.15
|
"cavity_wall_insulation": 0.1,
|
||||||
|
"internal_wall_insulation": 0.26,
|
||||||
# We use a higher contingency rate for internal wall insulation because of the potential for issues with moving
|
"external_wall_insulation": 0.26,
|
||||||
# fittings and trimming doors, as well as scope for damage to the existing wall during preparation.
|
"loft_insulation": 0.1,
|
||||||
IWI_CONTINGENCY = 0.2
|
"solar_pv": 0.15,
|
||||||
|
"air_source_heat_pump": 0.25,
|
||||||
# For air source heat pumps, we inflate the assume cost by quite a bit to account for design and installation
|
"flat_roof_insulation": 0.26,
|
||||||
ASHP_CONTINGENCY = 0.25
|
"suspended_floor_insulation": 0.2,
|
||||||
# Where there is more uncertainty, a higher contingency rate is used
|
"solid_floor_insulation": 0.26,
|
||||||
HIGH_RISK_CONTINGENCY = 0.2
|
"low_energy_lighting": 0.26,
|
||||||
# When there is less uncertainty, a lower contingency rate is used
|
"high_heat_retention_storage_heaters": 0.1,
|
||||||
LOW_RISK_CONTINGENCY = 0.05
|
}
|
||||||
|
|
||||||
# Preliminaries are a percentage of the total cost of the work and covers the cost of site-specific costs
|
# Preliminaries are a percentage of the total cost of the work and covers the cost of site-specific costs
|
||||||
# such as site preparation, safety measures and project management. This rate can vary but we'll assume a 10%
|
# such as site preparation, safety measures and project management. This rate can vary but we'll assume a 10%
|
||||||
# rate, on the total cost before VAT, as recommended by SPONs
|
# rate, on the total cost before VAT, as recommended by SPONs
|
||||||
PRELIMINARIES = 0.1
|
PRELIMINARIES = 0.1
|
||||||
|
|
||||||
# For higher risk projects, a higher preliminaries rate is used. SPONs indicates that a higher risk project might
|
|
||||||
# have a preliminaries of 12-14% so we use 12% as the median for the preliminaries rate.
|
|
||||||
# For External wall insulation (EWI), we use 15% as the preliminaries rate if we think the property might
|
|
||||||
# need scaffolding, otherwise we use 12%. This is to account for any site preparation that might be required
|
|
||||||
EWI_NO_SCAFFOLDING_PRELIMINARIES = 0.2
|
|
||||||
EWI_SCAFFOLDING_PRELIMINARIES = 0.25
|
|
||||||
|
|
||||||
VAT_RATE = 0.2
|
VAT_RATE = 0.2
|
||||||
PROFIT_MARGIN = 0.2
|
PROFIT_MARGIN = 0.2
|
||||||
|
|
||||||
|
|
@ -273,33 +249,14 @@ class Costs:
|
||||||
labour_hours = 8
|
labour_hours = 8
|
||||||
labour_days = 1
|
labour_days = 1
|
||||||
|
|
||||||
# if the material is based on an installer cost, we return the flat price
|
total_cost = material["total_cost"] * wall_area
|
||||||
if material["is_installer_quote"]:
|
|
||||||
total_cost = material["total_cost"] * wall_area
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total_cost,
|
|
||||||
"labour_hours": labour_hours,
|
|
||||||
"labour_days": labour_days,
|
|
||||||
}
|
|
||||||
|
|
||||||
total_including_vat = material["total_cost"] * wall_area
|
|
||||||
|
|
||||||
if is_extraction_and_refill:
|
|
||||||
total_including_vat = CAVITY_EXTRACTION_COST * wall_area
|
|
||||||
# Additional 2 days work
|
|
||||||
labour_hours += + (2 * 8)
|
|
||||||
labour_days += + 2
|
|
||||||
|
|
||||||
total_excluding_vat = total_including_vat / (1 + self.VAT_RATE)
|
|
||||||
vat_cost = total_including_vat - total_excluding_vat
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_including_vat,
|
"total": total_cost,
|
||||||
"subtotal": total_excluding_vat,
|
"contingency": self.CONTINGENCIES["cavity_wall_insulation"] * total_cost,
|
||||||
"vat": vat_cost,
|
"contingency_rate": self.CONTINGENCIES["cavity_wall_insulation"],
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
"labour_days": labour_days
|
"labour_days": labour_days,
|
||||||
}
|
}
|
||||||
|
|
||||||
def loft_and_flat_insulation(self, floor_area, material):
|
def loft_and_flat_insulation(self, floor_area, material):
|
||||||
|
|
@ -310,25 +267,20 @@ class Costs:
|
||||||
:return: A dictionary containing detailed cost breakdown.
|
:return: A dictionary containing detailed cost breakdown.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if material["is_installer_quote"]:
|
total_cost = material["total_cost"] * floor_area
|
||||||
total_cost = material["total_cost"] * floor_area
|
if material["type"] == "loft_insulation":
|
||||||
|
contingency_rate = self.CONTINGENCIES["loft_insulation"]
|
||||||
return {
|
contingency = contingency_rate * total_cost
|
||||||
"total": total_cost,
|
else:
|
||||||
"labour_hours": 8,
|
contingency_rate = self.CONTINGENCIES["flat_roof_insulation"]
|
||||||
"labour_days": 1,
|
contingency = contingency_rate * total_cost
|
||||||
}
|
|
||||||
|
|
||||||
total_including_vat = material["total_cost"] * floor_area
|
|
||||||
total_excluding_vat = total_including_vat / (1 + self.VAT_RATE)
|
|
||||||
vat_cost = total_including_vat - total_excluding_vat
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_including_vat,
|
"total": total_cost,
|
||||||
"subtotal": total_excluding_vat,
|
"contingency": contingency,
|
||||||
"vat": vat_cost,
|
"contingency_rate": contingency_rate,
|
||||||
"labour_hours": 8,
|
"labour_hours": 8,
|
||||||
"labour_days": 1
|
"labour_days": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
def solid_wall_insulation(self, wall_area, material):
|
def solid_wall_insulation(self, wall_area, material):
|
||||||
|
|
@ -338,469 +290,155 @@ class Costs:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# if the material is based on an installer cost, we return the flat price
|
# if the material is based on an installer cost, we return the flat price
|
||||||
if material["is_installer_quote"]:
|
total_cost = material["total_cost"] * wall_area
|
||||||
total_cost = material["total_cost"] * wall_area
|
|
||||||
|
|
||||||
labour_hours = material["labour_hours_per_unit"] * wall_area
|
if material["type"] == "internal_wall_insulation":
|
||||||
|
contingency_rate = self.CONTINGENCIES["internal_wall_insulation"]
|
||||||
|
else:
|
||||||
|
contingency_rate = self.CONTINGENCIES["external_wall_insulation"]
|
||||||
|
|
||||||
# To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5
|
labour_hours = material["labour_hours_per_unit"] * wall_area
|
||||||
# people
|
|
||||||
labour_days = (labour_hours / 8) / 4
|
|
||||||
|
|
||||||
return {
|
# To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5
|
||||||
"total": total_cost,
|
# people
|
||||||
"labour_hours": labour_hours,
|
|
||||||
"labour_days": labour_days,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Break out the individual material costs
|
|
||||||
# Since we don't know the exact wall construction, we take an average for demolition costs, since
|
|
||||||
# the cost will depend on the type of wall construction
|
|
||||||
|
|
||||||
total_including_vat = material["total_cost"] * wall_area
|
|
||||||
total_excluding_vat = total_including_vat / (1 + self.VAT_RATE)
|
|
||||||
vat_cost = total_including_vat - total_excluding_vat
|
|
||||||
|
|
||||||
# We estimate 1 weeks worth of work
|
|
||||||
labour_hours = 160
|
|
||||||
# To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5 people
|
|
||||||
labour_days = (labour_hours / 8) / 4
|
labour_days = (labour_hours / 8) / 4
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_including_vat,
|
"total": total_cost,
|
||||||
"subtotal": total_excluding_vat,
|
"contingency": contingency_rate * total_cost,
|
||||||
"vat": vat_cost,
|
"contingency_rate": contingency_rate,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
"labour_days": labour_days,
|
"labour_days": labour_days,
|
||||||
}
|
}
|
||||||
|
|
||||||
def suspended_floor_insulation(self, insulation_floor_area, material, non_insulation_materials):
|
def suspended_floor_insulation(self, insulation_floor_area, material):
|
||||||
"""
|
"""
|
||||||
We characterise the steps for suspended floor insulation as the following tasks:
|
Given an installer cost for the works, produces an estimate for the cost of works.
|
||||||
|
Includes contingency
|
||||||
1) Removal of Carpet and Underfelt: Where necessary, remove existing floor coverings to access the floorboards.
|
|
||||||
2) Removal of Floor Boarding: Carefully remove floorboards to access the space beneath for insulation.
|
|
||||||
3) Installation of Vapour Barrier: Install a vapour barrier to prevent moisture from affecting
|
|
||||||
the insulation and floor structure.
|
|
||||||
4) Installation of Insulation: Fit the chosen insulation material between the joists in the floor void.
|
|
||||||
5) Refixing Floorboards: Replace and secure the floorboards after insulation installation.
|
|
||||||
6) Re-carpeting: Lay down the carpet or other floor coverings once the insulation and floorboards are in place.
|
|
||||||
:return:
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# if the material is based on an installer cost, we return the flat price
|
# if the material is based on an installer cost, we return the flat price
|
||||||
if material["is_installer_quote"]:
|
total_cost = material["total_cost"] * insulation_floor_area
|
||||||
total_cost = material["total_cost"] * insulation_floor_area
|
|
||||||
|
|
||||||
labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
|
labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
|
||||||
# To install suspended floor insulation, a small to medium size project might be conducted by a team of 3
|
# To install suspended floor insulation, a small to medium size project might be conducted by a team of 3
|
||||||
# people
|
# people
|
||||||
labour_days = (labour_hours / 8) / 3
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total_cost,
|
|
||||||
"labour_hours": labour_hours,
|
|
||||||
"labour_days": labour_days,
|
|
||||||
}
|
|
||||||
|
|
||||||
demolition_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_demolition"]
|
|
||||||
vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_vapour_barrier"]
|
|
||||||
redecoration_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_redecoration"]
|
|
||||||
|
|
||||||
if (len(demolition_data) != 2) or (len(vapour_barrier_data) != 1) or (len(redecoration_data) != 2):
|
|
||||||
raise ValueError("Incorrect number of data entries for non-insulation materials")
|
|
||||||
|
|
||||||
# Break out the individual material costs
|
|
||||||
demolition_material_costs = sum([x["material_cost"] * insulation_floor_area for x in demolition_data])
|
|
||||||
insulation_material_costs = material["material_cost"] * insulation_floor_area
|
|
||||||
vapour_barrier_material_costs = vapour_barrier_data[0]["material_cost"] * insulation_floor_area
|
|
||||||
redecoration_material_costs = sum([x["material_cost"] * insulation_floor_area for x in redecoration_data])
|
|
||||||
|
|
||||||
demolition_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in demolition_data])
|
|
||||||
insulation_labour_costs = material["labour_cost"] * insulation_floor_area
|
|
||||||
vapour_barrier_labour_costs = vapour_barrier_data[0]["labour_cost"] * insulation_floor_area
|
|
||||||
redecoration_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in redecoration_data])
|
|
||||||
|
|
||||||
labour_costs = (demolition_labour_costs + insulation_labour_costs + vapour_barrier_labour_costs +
|
|
||||||
redecoration_labour_costs)
|
|
||||||
|
|
||||||
labour_costs = labour_costs * self.labour_adjustment_factor
|
|
||||||
|
|
||||||
materials_costs = (demolition_material_costs + insulation_material_costs + vapour_barrier_material_costs +
|
|
||||||
redecoration_material_costs)
|
|
||||||
|
|
||||||
subtotal_before_profit = labour_costs + materials_costs
|
|
||||||
|
|
||||||
# Because of the possiblity of damage to the existing floor, or difficulties associated to moving fittings,
|
|
||||||
# we use a higher contingency rate
|
|
||||||
contingency_cost = subtotal_before_profit * self.HIGH_RISK_CONTINGENCY
|
|
||||||
preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
|
|
||||||
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
|
|
||||||
|
|
||||||
subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
|
|
||||||
|
|
||||||
vat_cost = subtotal_before_vat * self.VAT_RATE
|
|
||||||
|
|
||||||
total_cost = subtotal_before_vat + vat_cost
|
|
||||||
|
|
||||||
demolition_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in demolition_data])
|
|
||||||
insulation_labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
|
|
||||||
vapour_barrier_labour_hours = vapour_barrier_data[0]["labour_hours_per_unit"] * insulation_floor_area
|
|
||||||
redecoration_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in redecoration_data])
|
|
||||||
|
|
||||||
labour_hours = (demolition_labour_hours + insulation_labour_hours + vapour_barrier_labour_hours +
|
|
||||||
redecoration_labour_hours)
|
|
||||||
|
|
||||||
# Assume a team of 3 people for a small to medium size project
|
|
||||||
labour_days = (labour_hours / 8) / 3
|
labour_days = (labour_hours / 8) / 3
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
"subtotal": subtotal_before_vat,
|
"contengency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost,
|
||||||
"vat": vat_cost,
|
"contingency_rate": self.CONTINGENCIES["suspended_floor_insulation"],
|
||||||
"contingency": contingency_cost,
|
|
||||||
"preliminaries": preliminaries_cost,
|
|
||||||
"material": materials_costs,
|
|
||||||
"profit": profit_cost,
|
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
"labour_days": labour_days,
|
"labour_days": labour_days,
|
||||||
"labour_cost": labour_costs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def solid_floor_insulation(self, insulation_floor_area, material, non_insulation_materials):
|
def solid_floor_insulation(self, insulation_floor_area, material):
|
||||||
"""
|
"""
|
||||||
We characterise the steps for solid floor insulation as the following tasks:
|
based on costing data from installers, produces an estimate for the cost of works. Returns contingency
|
||||||
|
|
||||||
1) Removal of Carpet and Underfelt: This is the initial stage where any existing floor coverings, like carpets,
|
|
||||||
tiles, or linoleum, are carefully removed. This exposes the solid floor beneath, which is typically concrete.
|
|
||||||
|
|
||||||
2) Preparation of Flooring: This step is critical. It involves:
|
|
||||||
- Cleaning the existing floor surface thoroughly to remove debris and ensure a flat surface.
|
|
||||||
- Assessing and repairing any damage to the concrete floor. This might include filling cracks or leveling
|
|
||||||
uneven areas.
|
|
||||||
|
|
||||||
3) Installation of a Damp Proof Membrane (DPM): Before installing insulation, a DPM is often laid down to
|
|
||||||
prevent moisture from rising into the insulation and the interior space. This step is crucial in areas prone to
|
|
||||||
dampness.
|
|
||||||
|
|
||||||
4) Install Insulation: The insulation, often in the form of rigid foam boards, is laid over the DPM.
|
|
||||||
The choice of insulation material will depend on the desired thermal properties and the available floor height.
|
|
||||||
Care is taken to minimize thermal bridges and ensure a snug fit between insulation boards.
|
|
||||||
|
|
||||||
5) Laying a New Subfloor: Over the insulation, a new subfloor is often installed. This could be a layer of
|
|
||||||
screed (a type of concrete) or wooden boarding, depending on the specific requirements and preferences.
|
|
||||||
|
|
||||||
6) Re-decoration and Finishing Touches: Once the subfloor is in place and has set or dried (if necessary),
|
|
||||||
the final floor finish can be applied. This might involve:
|
|
||||||
- Laying new tiles, wooden flooring, or other chosen materials.
|
|
||||||
- If you're planning to re-carpet, this would be the stage to do it.
|
|
||||||
- Skirting boards may need to be refitted or replaced.
|
|
||||||
|
|
||||||
7) Considerations for Doors and Fixtures: It's important to note that raising the floor level can affect door
|
|
||||||
thresholds and other fixtures. Doors may need to be trimmed, and fixtures might need adjustments.
|
|
||||||
|
|
||||||
:param insulation_floor_area: Area of the floor to be insulated
|
:param insulation_floor_area: Area of the floor to be insulated
|
||||||
:param material: Selected insulation material
|
:param material: Selected insulation material
|
||||||
:param non_insulation_materials: Non-insulation materials required for the job
|
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# if the material is based on an installer cost, we return the flat price
|
total_cost = material["total_cost"] * insulation_floor_area
|
||||||
if material["is_installer_quote"]:
|
|
||||||
total_cost = material["total_cost"] * insulation_floor_area
|
|
||||||
|
|
||||||
labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
|
labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
|
||||||
# To install suspended floor insulation, a small to medium size project might be conducted by a team of 3
|
# To install suspended floor insulation, a small to medium size project might be conducted by a team of 3
|
||||||
# people
|
# people
|
||||||
labour_days = (labour_hours / 8) / 3
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total_cost,
|
|
||||||
"labour_hours": labour_hours,
|
|
||||||
"labour_days": labour_days,
|
|
||||||
}
|
|
||||||
|
|
||||||
demolition_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_demolition"]
|
|
||||||
preparation_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_preparation"]
|
|
||||||
vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_vapour_barrier"]
|
|
||||||
redecoration_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_redecoration"]
|
|
||||||
|
|
||||||
if ((len(demolition_data) != 1) or (len(preparation_data) != 2) or (len(vapour_barrier_data) != 1) or
|
|
||||||
(len(redecoration_data) != 3)):
|
|
||||||
raise ValueError("Incorrect number of data entries for non-insulation materials")
|
|
||||||
|
|
||||||
# Break out the individual material costs
|
|
||||||
preparation_material_costs = sum([x["material_cost"] * insulation_floor_area for x in preparation_data])
|
|
||||||
insulation_material_costs = material["material_cost"] * insulation_floor_area
|
|
||||||
vapour_barrier_material_costs = vapour_barrier_data[0]["material_cost"] * insulation_floor_area
|
|
||||||
redecoration_material_costs = sum([x["material_cost"] * insulation_floor_area for x in redecoration_data])
|
|
||||||
|
|
||||||
demolition_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in demolition_data])
|
|
||||||
preparation_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in preparation_data])
|
|
||||||
insulation_labour_costs = material["labour_cost"] * insulation_floor_area
|
|
||||||
vapour_barrier_labour_costs = vapour_barrier_data[0]["labour_cost"] * insulation_floor_area
|
|
||||||
redecoration_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in redecoration_data])
|
|
||||||
|
|
||||||
preparation_plant_costs = sum([x["plant_cost"] * insulation_floor_area for x in preparation_data])
|
|
||||||
|
|
||||||
labour_costs = (demolition_labour_costs + insulation_labour_costs + vapour_barrier_labour_costs +
|
|
||||||
redecoration_labour_costs + preparation_labour_costs)
|
|
||||||
|
|
||||||
labour_costs = labour_costs * self.labour_adjustment_factor
|
|
||||||
|
|
||||||
materials_cost = (preparation_material_costs + insulation_material_costs + vapour_barrier_material_costs +
|
|
||||||
redecoration_material_costs)
|
|
||||||
|
|
||||||
subtotal_before_profit = labour_costs + materials_cost + preparation_plant_costs
|
|
||||||
|
|
||||||
# We use HIGH_RISH_CONTINGENCY because of the potential for issues with moving fittings and trimming doors,
|
|
||||||
# as well as scope for damage to the existing floor during preparation.
|
|
||||||
contingency_cost = subtotal_before_profit * self.HIGH_RISK_CONTINGENCY
|
|
||||||
preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
|
|
||||||
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
|
|
||||||
|
|
||||||
subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
|
|
||||||
vat_cost = subtotal_before_vat * self.VAT_RATE
|
|
||||||
total_cost = subtotal_before_vat + vat_cost
|
|
||||||
|
|
||||||
demolition_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in demolition_data])
|
|
||||||
preparation_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in preparation_data])
|
|
||||||
insulation_labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
|
|
||||||
vapour_barrier_labour_hours = vapour_barrier_data[0]["labour_hours_per_unit"] * insulation_floor_area
|
|
||||||
redecoration_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in redecoration_data])
|
|
||||||
|
|
||||||
labour_hours = (demolition_labour_hours + insulation_labour_hours + vapour_barrier_labour_hours +
|
|
||||||
redecoration_labour_hours + preparation_labour_hours)
|
|
||||||
|
|
||||||
# Assume a team of 3 people for a small to medium size project
|
|
||||||
labour_days = (labour_hours / 8) / 3
|
labour_days = (labour_hours / 8) / 3
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
"subtotal": subtotal_before_vat,
|
"contingency": self.CONTINGENCIES["solid_floor_insulation"] * total_cost,
|
||||||
"vat": vat_cost,
|
"contingency_rate": self.CONTINGENCIES["solid_floor_insulation"],
|
||||||
"contingency": contingency_cost,
|
|
||||||
"preliminaries": preliminaries_cost,
|
|
||||||
"material": materials_cost,
|
|
||||||
"profit": profit_cost,
|
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
"labour_days": labour_days,
|
"labour_days": labour_days,
|
||||||
"labour_cost": labour_costs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def low_energy_lighting(self, number_of_lights, number_current_lel_lights, material):
|
def low_energy_lighting(self, number_of_lights, material):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Calculates the total cost for low energy lighting based on material and labor costs,
|
Calculates the total cost for low energy lighting based on material and labor costs,
|
||||||
including contingency, preliminaries, profit, and VAT.
|
including contingency, preliminaries, profit, and VAT.
|
||||||
|
|
||||||
:param number_of_lights: Int, number of light
|
:param number_of_lights: Int, number of light
|
||||||
:param number_current_lel_lights: Int, number of low energy lights currently installed in the home
|
|
||||||
:material: Dict, material data containing costs of fittings
|
:material: Dict, material data containing costs of fittings
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If there are no lights fitted in the property, we increase the contingency in case there are potential wiring
|
# If there are no lights fitted in the property, we increase the contingency in case there are potential wiring
|
||||||
# blockers
|
# blockers
|
||||||
|
|
||||||
if number_current_lel_lights == 0:
|
total_cost = material["total_cost"] * number_of_lights
|
||||||
contingency = self.HIGH_RISK_CONTINGENCY
|
|
||||||
else:
|
|
||||||
contingency = self.CONTINGENCY
|
|
||||||
|
|
||||||
if material["is_installer_quote"]:
|
labour_hours = 1
|
||||||
total_cost = material["total_cost"] * number_of_lights * (1 + contingency)
|
|
||||||
|
|
||||||
labour_hours = 1
|
|
||||||
labour_days = (labour_hours / 8)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total_cost,
|
|
||||||
"labour_hours": labour_hours,
|
|
||||||
"labour_days": labour_days,
|
|
||||||
}
|
|
||||||
|
|
||||||
material_cost = material["material_cost"] * number_of_lights
|
|
||||||
labour_cost = material["labour_cost"] * number_of_lights * self.labour_adjustment_factor
|
|
||||||
|
|
||||||
subtotal_before_profit = material_cost + labour_cost
|
|
||||||
|
|
||||||
contingency_cost = subtotal_before_profit * contingency
|
|
||||||
|
|
||||||
subtotal_before_vat = subtotal_before_profit + contingency_cost
|
|
||||||
vat_cost = subtotal_before_vat * self.VAT_RATE
|
|
||||||
total_cost = subtotal_before_vat + vat_cost
|
|
||||||
|
|
||||||
labour_hours = material["labour_hours_per_unit"] * number_of_lights
|
|
||||||
# Assume a single electrician installing
|
|
||||||
labour_days = (labour_hours / 8)
|
labour_days = (labour_hours / 8)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
"subtotal": subtotal_before_vat,
|
"contingency": self.CONTINGENCIES["low_energy_lighting"] * total_cost,
|
||||||
"vat": vat_cost,
|
"contingency_rate": self.CONTINGENCIES["low_energy_lighting"],
|
||||||
"contingency": contingency_cost,
|
|
||||||
"material": material_cost,
|
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
"labour_days": labour_days,
|
"labour_days": labour_days,
|
||||||
"labour_cost": labour_cost
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def window_glazing(self, number_of_windows, material, is_secondary_glazing=False):
|
def window_glazing(self, number_of_windows, material, is_secondary_glazing=False):
|
||||||
"""
|
"""
|
||||||
We characterise the jobs to be done for window glazing as the following:
|
Given an isntaller quote, produces an estimate for the cost of works.
|
||||||
1) Initial Assessment and Measurements: Before removing the existing window, it's essential to assess the
|
|
||||||
condition of the window frame and opening. Precise measurements are taken to ensure the new double glazed
|
|
||||||
windows fit perfectly.
|
|
||||||
|
|
||||||
2) Remove the Existing Window: This involves carefully dismantling and removing the old single glazed window. It
|
|
||||||
requires skill to avoid damaging the surrounding wall and the window frame (if it's to be reused).
|
|
||||||
|
|
||||||
3) Dispose of the Existing Window: The old window, especially if it's a single glazed unit, needs to be
|
|
||||||
disposed of responsibly. Glass and other materials should be recycled where possible.
|
|
||||||
|
|
||||||
4) Surface Preparation: The window opening might need some preparation, especially if there's damage or if
|
|
||||||
adjustments are needed to accommodate the new window. This can include repairing or replacing parts of the
|
|
||||||
window frame, sealing gaps, and ensuring the opening is level and square.
|
|
||||||
|
|
||||||
5) Install the Window Frame (if new frames are used): In many cases, double glazed windows come with their
|
|
||||||
frames. These need to be installed securely into the window opening. This process involves aligning, leveling,
|
|
||||||
and fixing the frame in place.
|
|
||||||
|
|
||||||
6) Install the Window Sill: If a new window sill is required, it is installed at this stage. It needs to be
|
|
||||||
correctly aligned with the frame and securely attached.
|
|
||||||
|
|
||||||
7) Install the Double Glazed Glass Units: The glass units are carefully inserted into the frame. This step
|
|
||||||
requires precision to ensure a snug fit without causing stress on the glass, which could lead to cracking or
|
|
||||||
breaking.
|
|
||||||
|
|
||||||
8) Sealing and Weatherproofing: After the glass units are in place, it's crucial to seal around the frame and
|
|
||||||
between the glass and frame to ensure there are no drafts and that the installation is weather-tight. This
|
|
||||||
typically involves applying silicone sealant or other appropriate sealing materials.
|
|
||||||
|
|
||||||
9) Finishing Touches: This includes any cosmetic work, such as trimming, painting, or staining the frame and
|
|
||||||
sill to match the rest of the property. It might also involve cleaning up any mess created during the
|
|
||||||
installation.
|
|
||||||
|
|
||||||
10) Inspection and Testing: Finally, the new windows should be inspected to ensure they open, close, and lock
|
|
||||||
correctly. This is also a good time to check for any gaps or issues with the sealing.
|
|
||||||
|
|
||||||
For this cost estimation process, we factor in initial assement into the preliminaries
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if material["is_installer_quote"]:
|
total_cost = material["total_cost"] * number_of_windows
|
||||||
total_cost = material["total_cost"] * number_of_windows
|
|
||||||
|
|
||||||
labour_hours = material["labour_hours_per_unit"] * number_of_windows
|
|
||||||
# To install windows, a small to medium size project might be conducted by a team of 2-3 people
|
|
||||||
labour_days = (labour_hours / 8) / 2
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total_cost,
|
|
||||||
"labour_hours": labour_hours,
|
|
||||||
"labour_days": labour_days,
|
|
||||||
}
|
|
||||||
|
|
||||||
material_cost = material["material_cost"] * number_of_windows
|
|
||||||
|
|
||||||
labour_cost = (
|
|
||||||
material["labour_cost"] * number_of_windows * self.labour_adjustment_factor
|
|
||||||
)
|
|
||||||
multiplier = self.SECONDARY_GLAZING_SCALING_FACTOR if is_secondary_glazing else (
|
|
||||||
self.SASH_WINDOW_INFLATION_FACTOR)
|
|
||||||
|
|
||||||
subtotal = (material_cost + labour_cost) * multiplier
|
|
||||||
|
|
||||||
contingency_cost = subtotal * self.CONTINGENCY
|
|
||||||
preliminaries_cost = subtotal * self.PRELIMINARIES
|
|
||||||
profit_cost = subtotal * self.PROFIT_MARGIN
|
|
||||||
|
|
||||||
subtotal_before_vat = subtotal + contingency_cost + preliminaries_cost + profit_cost
|
|
||||||
|
|
||||||
vat_cost = subtotal_before_vat * self.VAT_RATE
|
|
||||||
|
|
||||||
total_cost = subtotal_before_vat + vat_cost
|
|
||||||
|
|
||||||
labour_hours = material["labour_hours_per_unit"] * number_of_windows
|
labour_hours = material["labour_hours_per_unit"] * number_of_windows
|
||||||
labour_hours = labour_hours * self.SECONDARY_GLAZING_SCALING_FACTOR if is_secondary_glazing else labour_hours
|
# To install windows, a small to medium size project might be conducted by a team of 2-3 people
|
||||||
|
|
||||||
# Assume a team of 2
|
|
||||||
labour_days = (labour_hours / 8) / 2
|
labour_days = (labour_hours / 8) / 2
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
"subtotal": subtotal_before_vat,
|
"contingency": self.CONTINGENCIES["windows_glazing"] * total_cost,
|
||||||
"vat": vat_cost,
|
"contingency_rate": self.CONTINGENCIES["windows_glazing"],
|
||||||
"contingency": contingency_cost,
|
|
||||||
"preliminaries": preliminaries_cost,
|
|
||||||
"material": material_cost,
|
|
||||||
"profit": profit_cost,
|
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
"labour_cost": labour_cost,
|
"labour_days": labour_days,
|
||||||
"labour_days": labour_days
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def solar_pv(
|
def solar_pv(
|
||||||
cls,
|
cls,
|
||||||
n_panels: int | float,
|
solar_product,
|
||||||
has_battery: bool = False,
|
scaffolding_options,
|
||||||
array_cost=None,
|
n_floors
|
||||||
n_floors: int = 1,
|
|
||||||
battery_kwh: int = 5,
|
|
||||||
needs_inverter=False
|
|
||||||
):
|
):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Calculates the total cost for solar PV based data provided by the MCS dashboard, which contains
|
|
||||||
costing data for installations of renewable and clean energy measures.
|
|
||||||
|
|
||||||
The data in the dashboard is filtered on domestic building installations and then the data across the
|
|
||||||
various regions is manually collected. There is currently no automated way to get the data from the MCS
|
|
||||||
dashboard
|
|
||||||
|
|
||||||
Price can also be benchmarked against this checkatrade article:
|
|
||||||
https://www.checkatrade.com/blog/cost-guides/cost-of-solar-panel-installation/
|
|
||||||
:param n_panels: Number of solar panels
|
|
||||||
:param has_battery: Bool, whether the system includes a battery
|
|
||||||
:param array_cost: float, containing the cost of the solar PV array
|
|
||||||
:param n_floors: int, number of floors in the property, used to estimate the cost of scaffolding
|
|
||||||
:param battery_kwh: int, capacity of the battery in kWh. Defaulted to 5
|
|
||||||
:param needs_inverter: Bool, whether the system needs an inverter, where the solar panels are feeding multiple
|
|
||||||
units
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if n_panels > INSTALLER_MAX_PANELS:
|
system_cost = solar_product["total_cost"]
|
||||||
base_cost = [c for c in INSTALLER_SOLAR_COSTS if c["n_panels"] == INSTALLER_MAX_PANELS][0]["cost"]
|
|
||||||
cost_per_panel = [
|
|
||||||
c for c in INSTALLER_SOLAR_COSTS if c["n_panels"] == (INSTALLER_MAX_PANELS - 1)
|
|
||||||
][0]["cost"]
|
|
||||||
cost_per_panel = base_cost - cost_per_panel
|
|
||||||
system_cost = base_cost + (n_panels - INSTALLER_MAX_PANELS) * cost_per_panel
|
|
||||||
else:
|
|
||||||
system_cost = [c for c in INSTALLER_SOLAR_COSTS if c["n_panels"] == n_panels][0]["cost"]
|
|
||||||
|
|
||||||
subtotal = array_cost if array_cost is not None else system_cost
|
if not solar_product["includes_scaffolding"]:
|
||||||
|
# We base this on the number of floors
|
||||||
|
scaffolding = [x["total_cost"] for x in scaffolding_options if x["size"] == n_floors]
|
||||||
|
if not scaffolding:
|
||||||
|
# If we have no options, handle this
|
||||||
|
if n_floors <= 3:
|
||||||
|
raise ValueError("No scaffolding options available for 3 or fewer floors")
|
||||||
|
# We take the largest scaffolding option available
|
||||||
|
scaffolding_cost = max([x["total_cost"] for x in scaffolding_options])
|
||||||
|
else:
|
||||||
|
scaffolding_cost = min(scaffolding)
|
||||||
|
|
||||||
if has_battery:
|
system_cost += scaffolding_cost
|
||||||
battery_cost = [c for c in INSTALLER_SOLAR_BATTERY_COSTS if c["capacity_kwh"] == battery_kwh][0]["cost"]
|
|
||||||
subtotal += battery_cost
|
|
||||||
|
|
||||||
if needs_inverter:
|
|
||||||
subtotal += INSTALLER_SOLAR_PV_INVERTER_COST
|
|
||||||
# We also add an additional labour cost
|
|
||||||
subtotal += INSTALLER_SOLAR_PV_INVERTER_LABOUR_COST
|
|
||||||
|
|
||||||
# Solar doesn't have VAT but we add a high risk contingency
|
|
||||||
# to account for design variation that we see in practice
|
|
||||||
total_cost = subtotal * (1 + cls.HIGH_RISK_CONTINGENCY)
|
|
||||||
|
|
||||||
# Labour hours are based on estimates from online research but an average team seems to consist of 3 people
|
# Labour hours are based on estimates from online research but an average team seems to consist of 3 people
|
||||||
# and most jobs take around 2 days. Assuming an 8 hour day for 3 people across 2 days, gives us 48 hours of
|
# and most jobs take around 2 days. Assuming an 8 hour day for 3 people across 2 days, gives us 48 hours of
|
||||||
# labour
|
# labour
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": system_cost,
|
||||||
"subtotal": subtotal,
|
"subtotal": system_cost,
|
||||||
|
"contingency": system_cost * cls.CONTINGENCIES["solar_pv"],
|
||||||
|
"contingency_rate": cls.CONTINGENCIES["solar_pv"],
|
||||||
"vat": 0,
|
"vat": 0,
|
||||||
"labour_hours": 48,
|
"labour_hours": 48,
|
||||||
"labour_days": 2,
|
"labour_days": 2,
|
||||||
|
|
@ -826,6 +464,8 @@ class Costs:
|
||||||
# We estimate the cost of an appliance thermostat at £400, which is the upper end of the range
|
# We estimate the cost of an appliance thermostat at £400, which is the upper end of the range
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contengency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
|
|
@ -852,13 +492,19 @@ class Costs:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
"labour_days": labour_days,
|
"labour_days": labour_days,
|
||||||
}
|
}
|
||||||
|
|
||||||
def high_heat_electric_storage_heaters(self, number_heated_rooms, needs_cylinder):
|
def high_heat_electric_storage_heaters(
|
||||||
|
self, number_heated_rooms: int,
|
||||||
|
needs_cylinder: bool,
|
||||||
|
product: dict | None = None
|
||||||
|
):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
We base the estimates for the cost of electric storage heaters on the cost per room as estimated by the
|
We base the estimates for the cost of electric storage heaters on the cost per room as estimated by the
|
||||||
|
|
@ -867,23 +513,27 @@ class Costs:
|
||||||
|
|
||||||
The cost is based on the number of heated rooms
|
The cost is based on the number of heated rooms
|
||||||
:param number_heated_rooms: int, number of rooms to be heated
|
:param number_heated_rooms: int, number of rooms to be heated
|
||||||
|
:param needs_cylinder: bool, whether the property needs a hot water cylinder
|
||||||
|
:param product: dict, product data containing costs of heaters
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if needs_cylinder:
|
if needs_cylinder:
|
||||||
# 1000 is the cost of a new hot water cylinder
|
# 1500 is the cost of a new hot water cylinder
|
||||||
total_cost = 1300 * number_heated_rooms + 1000
|
total_cost = product["total_cost"] * number_heated_rooms + 1500
|
||||||
else:
|
else:
|
||||||
# 500 is the cost of a dual immersion heater - a rough estimate
|
# 500 is the cost of a dual immersion heater - a rough estimate
|
||||||
total_cost = 1300 * number_heated_rooms + 500
|
total_cost = product["total_cost"] * number_heated_rooms + 500
|
||||||
|
|
||||||
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
||||||
vat = total_cost - subtotal_before_vat
|
vat = total_cost - subtotal_before_vat
|
||||||
|
|
||||||
# TODO: Rough estimate to be reviewed
|
|
||||||
labour_hours = 3 * number_heated_rooms
|
labour_hours = 3 * number_heated_rooms
|
||||||
labour_days = np.ceil(labour_hours / 8)
|
labour_days = np.ceil(labour_hours / 8)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCIES["high_heat_retention_storage_heaters"],
|
||||||
|
"contingency_rate": self.CONTINGENCIES["high_heat_retention_storage_heaters"],
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
|
|
@ -903,6 +553,8 @@ class Costs:
|
||||||
# We estimate the labour hours to be 4
|
# We estimate the labour hours to be 4
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": 4,
|
"labour_hours": 4,
|
||||||
|
|
@ -922,6 +574,8 @@ class Costs:
|
||||||
# We estimate the labour hours to be 2
|
# We estimate the labour hours to be 2
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": 2,
|
"labour_hours": 2,
|
||||||
|
|
@ -940,6 +594,8 @@ class Costs:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": 0,
|
"labour_hours": 0,
|
||||||
|
|
@ -974,6 +630,8 @@ class Costs:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
|
|
@ -1006,6 +664,8 @@ class Costs:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
|
|
@ -1034,6 +694,8 @@ class Costs:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
|
|
@ -1056,6 +718,8 @@ class Costs:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": removal_labour_hours,
|
"labour_hours": removal_labour_hours,
|
||||||
|
|
@ -1096,7 +760,7 @@ class Costs:
|
||||||
estimated_radiators = max(total_radiators_based_on_power, base_radiators + additional_radiators)
|
estimated_radiators = max(total_radiators_based_on_power, base_radiators + additional_radiators)
|
||||||
return round(estimated_radiators)
|
return round(estimated_radiators)
|
||||||
|
|
||||||
def boiler(self, size, exising_room_heaters, system_change, n_heated_rooms, n_rooms, is_electric=False):
|
def boiler(self, exising_room_heaters, system_change, n_heated_rooms, n_rooms, is_electric=False):
|
||||||
"""
|
"""
|
||||||
Based on a basic estimate of median value £2600 to install a low carbon combi boiler
|
Based on a basic estimate of median value £2600 to install a low carbon combi boiler
|
||||||
First time central heating vosts can als be found here:
|
First time central heating vosts can als be found here:
|
||||||
|
|
@ -1105,7 +769,7 @@ class Costs:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not is_electric:
|
if not is_electric:
|
||||||
unit_cost = CONDENSING_BOILER_COSTS[size]
|
unit_cost = CONDENSING_BOILER_COST
|
||||||
else:
|
else:
|
||||||
unit_cost = ELECTRIC_BOILER_COSTS
|
unit_cost = ELECTRIC_BOILER_COSTS
|
||||||
# The unit cost is the cost without VAT
|
# The unit cost is the cost without VAT
|
||||||
|
|
@ -1119,7 +783,6 @@ class Costs:
|
||||||
# To be pessimistic, assume 2 days work
|
# To be pessimistic, assume 2 days work
|
||||||
labour_cost = labour_rate * self.labour_adjustment_factor * labour_days
|
labour_cost = labour_rate * self.labour_adjustment_factor * labour_days
|
||||||
# Add contingency and preliminaries
|
# Add contingency and preliminaries
|
||||||
labour_cost = labour_cost * (1 + self.CONTINGENCY + self.PRELIMINARIES)
|
|
||||||
|
|
||||||
vat = labour_cost * self.VAT_RATE
|
vat = labour_cost * self.VAT_RATE
|
||||||
|
|
||||||
|
|
@ -1159,6 +822,8 @@ class Costs:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": total_cost,
|
||||||
|
"contingency": total_cost * self.CONTINGENCY,
|
||||||
|
"contingency_rate": self.CONTINGENCY,
|
||||||
"subtotal": subtotal_before_vat,
|
"subtotal": subtotal_before_vat,
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
|
|
@ -1178,19 +843,18 @@ class Costs:
|
||||||
else:
|
else:
|
||||||
cost = [x for x in INSTALLER_ASHP_COSTS if x][0]["cost"]
|
cost = [x for x in INSTALLER_ASHP_COSTS if x][0]["cost"]
|
||||||
|
|
||||||
# We add some contingency since there are additional costs such as resizing radiators, that could be required
|
|
||||||
subtotal = cost * (1 + self.ASHP_CONTINGENCY)
|
|
||||||
# The costs from installers exclude VAT
|
# The costs from installers exclude VAT
|
||||||
vat = subtotal * self.VAT_RATE
|
vat = cost * self.VAT_RATE
|
||||||
total_cost = subtotal + vat
|
cost = cost + vat
|
||||||
|
|
||||||
# We assume 5 days installation
|
# We assume 5 days installation
|
||||||
labour_days = 5
|
labour_days = 5
|
||||||
labour_hours = labour_days * 8
|
labour_hours = labour_days * 8
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_cost,
|
"total": cost,
|
||||||
"subtotal": subtotal,
|
"contingency": cost * self.CONTINGENCIES["air_source_heat_pump"],
|
||||||
|
"contingency_rate": self.CONTINGENCIES["air_source_heat_pump"],
|
||||||
"vat": vat,
|
"vat": vat,
|
||||||
"labour_hours": labour_hours,
|
"labour_hours": labour_hours,
|
||||||
"labour_days": labour_days,
|
"labour_days": labour_days,
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,18 @@ class FireplaceRecommendations(Definitions):
|
||||||
For properties that have open fireplaces, we recommend sealing the fireplaces
|
For properties that have open fireplaces, we recommend sealing the fireplaces
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# This is our base assumption for the cost of the work
|
|
||||||
COST_OF_WORK = 235
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
property_instance: Property,
|
property_instance: Property,
|
||||||
|
materials: list,
|
||||||
):
|
):
|
||||||
self.property = property_instance
|
self.property = property_instance
|
||||||
|
|
||||||
self.has_ventilaion = None
|
self.has_ventilaion = None
|
||||||
self.recommendation = None
|
self.recommendation = None
|
||||||
|
self.materials = [m for m in materials if m["type"] == "sealing_fireplace"]
|
||||||
|
if len(self.materials) != 1:
|
||||||
|
raise ValueError("Incorrect number of sealing fireplace materials specified")
|
||||||
|
|
||||||
def recommend(self, phase=0):
|
def recommend(self, phase=0):
|
||||||
"""
|
"""
|
||||||
|
|
@ -32,14 +33,16 @@ class FireplaceRecommendations(Definitions):
|
||||||
if number_open_fireplaces == 0:
|
if number_open_fireplaces == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
material = self.materials[0]
|
||||||
|
|
||||||
already_installed = "sealing_open_fireplace" in self.property.already_installed
|
already_installed = "sealing_open_fireplace" in self.property.already_installed
|
||||||
estimated_cost = number_open_fireplaces * self.COST_OF_WORK if not already_installed else 0
|
estimated_cost = number_open_fireplaces * material["total_cost"] if not already_installed else 0
|
||||||
|
|
||||||
# We recommend installing two mechanical ventilation systems
|
# We recommend installing two mechanical ventilation systems
|
||||||
self.recommendation = [
|
self.recommendation = [
|
||||||
{
|
{
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
"parts": [],
|
"parts": [material],
|
||||||
"type": "sealing_open_fireplace",
|
"type": "sealing_open_fireplace",
|
||||||
"measure_type": "sealing_open_fireplace",
|
"measure_type": "sealing_open_fireplace",
|
||||||
"description": "Seal %s open fireplaces" % str(number_open_fireplaces),
|
"description": "Seal %s open fireplaces" % str(number_open_fireplaces),
|
||||||
|
|
@ -53,6 +56,7 @@ class FireplaceRecommendations(Definitions):
|
||||||
"labour_days": 6 * number_open_fireplaces / 8, # Assume 8 hour day
|
"labour_days": 6 * number_open_fireplaces / 8, # Assume 8 hour day
|
||||||
"description_simulation": {
|
"description_simulation": {
|
||||||
"number-open-fireplaces": 0
|
"number-open-fireplaces": 0
|
||||||
}
|
},
|
||||||
|
"innovation_rate": material["innovation_rate"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,6 @@ class FloorRecommendations(Definitions):
|
||||||
cost_result = self.costs.suspended_floor_insulation(
|
cost_result = self.costs.suspended_floor_insulation(
|
||||||
insulation_floor_area=self.property.insulation_floor_area,
|
insulation_floor_area=self.property.insulation_floor_area,
|
||||||
material=material.to_dict(),
|
material=material.to_dict(),
|
||||||
non_insulation_materials=non_insulation_materials
|
|
||||||
)
|
)
|
||||||
|
|
||||||
already_installed = "suspended_floor_insulation" in self.property.already_installed
|
already_installed = "suspended_floor_insulation" in self.property.already_installed
|
||||||
|
|
@ -213,7 +212,6 @@ class FloorRecommendations(Definitions):
|
||||||
cost_result = self.costs.solid_floor_insulation(
|
cost_result = self.costs.solid_floor_insulation(
|
||||||
insulation_floor_area=self.property.insulation_floor_area,
|
insulation_floor_area=self.property.insulation_floor_area,
|
||||||
material=material.to_dict(),
|
material=material.to_dict(),
|
||||||
non_insulation_materials=non_insulation_materials
|
|
||||||
)
|
)
|
||||||
|
|
||||||
already_installed = "solid_floor_insulation" in self.property.already_installed
|
already_installed = "solid_floor_insulation" in self.property.already_installed
|
||||||
|
|
@ -264,6 +262,7 @@ class FloorRecommendations(Definitions):
|
||||||
material["type"] == "solid_floor_insulation"
|
material["type"] == "solid_floor_insulation"
|
||||||
else "Suspended, insulated"
|
else "Suspended, insulated"
|
||||||
},
|
},
|
||||||
**cost_result
|
**cost_result,
|
||||||
|
"innovation_rate": material["innovation_rate"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,8 @@ class HeatingControlRecommender:
|
||||||
"measure_type": "programmer_appliance_thermostat",
|
"measure_type": "programmer_appliance_thermostat",
|
||||||
"description": "upgrade heating controls to Programmer and Appliance or Smart Thermostats",
|
"description": "upgrade heating controls to Programmer and Appliance or Smart Thermostats",
|
||||||
**self.costs.programmer_and_appliance_thermostat(has_programmer=has_programmer),
|
**self.costs.programmer_and_appliance_thermostat(has_programmer=has_programmer),
|
||||||
"simulation_config": simulation_config
|
"simulation_config": simulation_config,
|
||||||
|
"innovation_rate": 0.0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -142,7 +143,8 @@ class HeatingControlRecommender:
|
||||||
"description": "Upgrade heating controls to High Heat Retention Storage Heater Controls",
|
"description": "Upgrade heating controls to High Heat Retention Storage Heater Controls",
|
||||||
**self.costs.celect_type_controls(),
|
**self.costs.celect_type_controls(),
|
||||||
"simulation_config": simulation_config,
|
"simulation_config": simulation_config,
|
||||||
"description_simulation": description_simulation
|
"description_simulation": description_simulation,
|
||||||
|
"innovation_rate": 0.0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -232,7 +234,8 @@ class HeatingControlRecommender:
|
||||||
"sap_points": None,
|
"sap_points": None,
|
||||||
"already_installed": already_installed,
|
"already_installed": already_installed,
|
||||||
"simulation_config": simulation_config,
|
"simulation_config": simulation_config,
|
||||||
"description_simulation": description_simulation
|
"description_simulation": description_simulation,
|
||||||
|
"innovation_rate": 0.0
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -307,7 +310,8 @@ class HeatingControlRecommender:
|
||||||
"sap_points": None,
|
"sap_points": None,
|
||||||
"already_installed": already_installed,
|
"already_installed": already_installed,
|
||||||
"simulation_config": simulation_config,
|
"simulation_config": simulation_config,
|
||||||
"description_simulation": description_simulation
|
"description_simulation": description_simulation,
|
||||||
|
"innovation_rate": 0.0
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import re
|
import re
|
||||||
import backend.app.assumptions as assumptions
|
import backend.app.assumptions as assumptions
|
||||||
from recommendations.Costs import Costs, BOILER_UPGRADE_SCHEME_ASHP_VALUE
|
|
||||||
from recommendations.recommendation_utils import (
|
from recommendations.recommendation_utils import (
|
||||||
check_simulation_difference, override_costs, combine_recommendation_configs
|
check_simulation_difference, override_costs, combine_recommendation_configs
|
||||||
)
|
)
|
||||||
from backend.Property import Property
|
from backend.Property import Property
|
||||||
from backend.app.plan.schemas import MEASURE_MAP
|
from backend.app.plan.schemas import MEASURE_MAP
|
||||||
|
from recommendations.Costs import Costs
|
||||||
from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes
|
from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes
|
||||||
from etl.epc_clean.epc_attributes.HotWaterAttributes import HotWaterAttributes
|
from etl.epc_clean.epc_attributes.HotWaterAttributes import HotWaterAttributes
|
||||||
from etl.epc_clean.epc_attributes.MainFuelAttributes import MainFuelAttributes
|
from etl.epc_clean.epc_attributes.MainFuelAttributes import MainFuelAttributes
|
||||||
|
|
@ -85,7 +85,7 @@ class HeatingRecommender:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, property_instance: Property):
|
def __init__(self, property_instance: Property, materials: list):
|
||||||
self.property = property_instance
|
self.property = property_instance
|
||||||
self.costs = Costs(self.property)
|
self.costs = Costs(self.property)
|
||||||
|
|
||||||
|
|
@ -103,6 +103,11 @@ class HeatingRecommender:
|
||||||
|
|
||||||
self.dual_heating = self.identify_dual_heating()
|
self.dual_heating = self.identify_dual_heating()
|
||||||
|
|
||||||
|
# Split out the different materials
|
||||||
|
self.hhrsh_products = [
|
||||||
|
product for product in materials if product["type"] == "high_heat_retention_storage_heaters"
|
||||||
|
]
|
||||||
|
|
||||||
def identify_dual_heating(self):
|
def identify_dual_heating(self):
|
||||||
# All heat systems are in here so we identify whether two of these are true
|
# All heat systems are in here so we identify whether two of these are true
|
||||||
# MainHeatAttributes.HEAT_SYSTEMS
|
# MainHeatAttributes.HEAT_SYSTEMS
|
||||||
|
|
@ -521,9 +526,8 @@ class HeatingRecommender:
|
||||||
ashp_descriptions = {
|
ashp_descriptions = {
|
||||||
"Time and temperature zone control": (
|
"Time and temperature zone control": (
|
||||||
f"Install two cascaded air source heat pumps, and upgrade heating controls to Smart Thermostats, "
|
f"Install two cascaded air source heat pumps, and upgrade heating controls to Smart Thermostats, "
|
||||||
"room sensors and smart radiator valves (time & temperature zone control). Ensure you have an 18 "
|
"room sensors and smart radiator valves (time & temperature zone control). Ensure you have single "
|
||||||
"or "
|
"tariff"
|
||||||
"24 hour tariff"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
|
@ -531,9 +535,8 @@ class HeatingRecommender:
|
||||||
ashp_descriptions = {
|
ashp_descriptions = {
|
||||||
"Time and temperature zone control": (
|
"Time and temperature zone control": (
|
||||||
f"Install a {ashp_size}KW air source heat pump, and upgrade heating controls to Smart Thermostats, "
|
f"Install a {ashp_size}KW air source heat pump, and upgrade heating controls to Smart Thermostats, "
|
||||||
"room sensors and smart radiator valves (time & temperature zone control). Ensure you have an 18 "
|
"room sensors and smart radiator valves (time & temperature zone control). Ensure you have a "
|
||||||
"or "
|
"single tariff"
|
||||||
"24 hour tariff"
|
|
||||||
),
|
),
|
||||||
"Programmer, TRVs and bypass": (
|
"Programmer, TRVs and bypass": (
|
||||||
f"Install a {ashp_size}KW air source heat pump, with programmer, TRVs and a Bypass valve. Ensure "
|
f"Install a {ashp_size}KW air source heat pump, with programmer, TRVs and a Bypass valve. Ensure "
|
||||||
|
|
@ -555,7 +558,7 @@ class HeatingRecommender:
|
||||||
ashp_costs_with_controls[key] += controls_rec[key]
|
ashp_costs_with_controls[key] += controls_rec[key]
|
||||||
|
|
||||||
if controls_rec is None:
|
if controls_rec is None:
|
||||||
description = f"Install a {ashp_size}KW Air source heat pump. Ensure you have an 18 or 24 hour tariff"
|
description = f"Install a {ashp_size}KW Air source heat pump. Ensure you have a single tariff"
|
||||||
elif already_installed:
|
elif already_installed:
|
||||||
description = "The property already has an air source heat pump, no further action needed."
|
description = "The property already has an air source heat pump, no further action needed."
|
||||||
else:
|
else:
|
||||||
|
|
@ -568,17 +571,18 @@ class HeatingRecommender:
|
||||||
if has_cavity_or_loft_recommendations:
|
if has_cavity_or_loft_recommendations:
|
||||||
description = description + (
|
description = description + (
|
||||||
f" You must ensure that the property has an insulated cavity and "
|
f" You must ensure that the property has an insulated cavity and "
|
||||||
f"270mm+ loft insulation to qualify for the grant, to claim £"
|
f"270mm+ loft insulation to qualify for the grant, to claim £7,500"
|
||||||
f"{BOILER_UPGRADE_SCHEME_ASHP_VALUE} of funding from the boiler upgrade scheme grant. "
|
f" of funding from the boiler upgrade scheme grant. "
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
description = description + (
|
description = description + (
|
||||||
f" £{BOILER_UPGRADE_SCHEME_ASHP_VALUE} of funding can be claimed from the boiler upgrade scheme"
|
f" £7,500 of funding can be claimed from the boiler upgrade scheme"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# These are the impacts based on a single tariff with an ashp
|
||||||
simulation_config = {
|
simulation_config = {
|
||||||
"mainheat_energy_eff_ending": "Very Good",
|
"mainheat_energy_eff_ending": "Good",
|
||||||
"hot_water_energy_eff_ending": "Very Good"
|
"hot_water_energy_eff_ending": "Average"
|
||||||
}
|
}
|
||||||
description_simulation = {
|
description_simulation = {
|
||||||
"mainheat-description": new_heating_description,
|
"mainheat-description": new_heating_description,
|
||||||
|
|
@ -644,7 +648,8 @@ class HeatingRecommender:
|
||||||
"already_installed": already_installed,
|
"already_installed": already_installed,
|
||||||
"simulation_config": simulation_config,
|
"simulation_config": simulation_config,
|
||||||
"description_simulation": description_simulation,
|
"description_simulation": description_simulation,
|
||||||
**ashp_costs_with_controls
|
**ashp_costs_with_controls,
|
||||||
|
"innovation_rate": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
ashp_recommendations.append(ashp_recommendation)
|
ashp_recommendations.append(ashp_recommendation)
|
||||||
|
|
@ -678,7 +683,7 @@ class HeatingRecommender:
|
||||||
heating_controls_only,
|
heating_controls_only,
|
||||||
system_change,
|
system_change,
|
||||||
system_type,
|
system_type,
|
||||||
measure_type,
|
heating_product,
|
||||||
non_intrusive_recommendation=None
|
non_intrusive_recommendation=None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -695,8 +700,7 @@ class HeatingRecommender:
|
||||||
current system. If we have a system change and we have a heat control recommendation, we only recommend
|
current system. If we have a system change and we have a heat control recommendation, we only recommend
|
||||||
both heating and controls together
|
both heating and controls together
|
||||||
:param system_type: The type of heating system we are recommending
|
:param system_type: The type of heating system we are recommending
|
||||||
:param measure_type: The type of measure we are recommending - more granular than the "type" field, allowing us
|
:param heating_product: The heating product we are recommending, used to determine the system type
|
||||||
to distinguish between different types of heating recommendations
|
|
||||||
:param non_intrusive_recommendation: A non-intrusive recommendation, which may specify the number of SAP points
|
:param non_intrusive_recommendation: A non-intrusive recommendation, which may specify the number of SAP points
|
||||||
or a cost for this recommendation
|
or a cost for this recommendation
|
||||||
"""
|
"""
|
||||||
|
|
@ -747,7 +751,7 @@ class HeatingRecommender:
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
"parts": [],
|
"parts": [],
|
||||||
"type": "heating",
|
"type": "heating",
|
||||||
"measure_type": measure_type,
|
"measure_type": heating_product["type"],
|
||||||
"description": recommendation_description,
|
"description": recommendation_description,
|
||||||
"starting_u_value": None,
|
"starting_u_value": None,
|
||||||
"new_u_value": None,
|
"new_u_value": None,
|
||||||
|
|
@ -758,7 +762,10 @@ class HeatingRecommender:
|
||||||
"description_simulation": recommendation_description_simulation,
|
"description_simulation": recommendation_description_simulation,
|
||||||
# We insert the heating system type here
|
# We insert the heating system type here
|
||||||
"system_type": system_type,
|
"system_type": system_type,
|
||||||
"survey": non_intrusive_recommendation.get("survey", False)
|
"survey": non_intrusive_recommendation.get("survey", False),
|
||||||
|
# In this instance, we are recommending an entire heating system so the innovation rate is becased
|
||||||
|
# on the heating system as whole
|
||||||
|
"innovation_rate": heating_product["innovation_rate"],
|
||||||
}
|
}
|
||||||
|
|
||||||
output.append(recommendation)
|
output.append(recommendation)
|
||||||
|
|
@ -921,10 +928,14 @@ class HeatingRecommender:
|
||||||
if (number_heated_rooms == 1) and (self.property.number_of_rooms > 2):
|
if (number_heated_rooms == 1) and (self.property.number_of_rooms > 2):
|
||||||
number_heated_rooms = self.property.number_of_rooms - 1
|
number_heated_rooms = self.property.number_of_rooms - 1
|
||||||
|
|
||||||
|
# We focus on the 700 watt product
|
||||||
|
hhrsh_product = next((x for x in self.hhrsh_products if x["size"] == 700), {})
|
||||||
|
|
||||||
# Upgrade to electric storage heaters
|
# Upgrade to electric storage heaters
|
||||||
costs = self.costs.high_heat_electric_storage_heaters(
|
costs = self.costs.high_heat_electric_storage_heaters(
|
||||||
number_heated_rooms=number_heated_rooms,
|
number_heated_rooms=number_heated_rooms,
|
||||||
needs_cylinder=self.property.hotwater["system_type"] == "from main system"
|
needs_cylinder=self.property.hotwater["system_type"] == "from main system",
|
||||||
|
product=hhrsh_product
|
||||||
)
|
)
|
||||||
if self.dual_heating:
|
if self.dual_heating:
|
||||||
description = self.DUAL_HEATING_DESCRIPTIONS[
|
description = self.DUAL_HEATING_DESCRIPTIONS[
|
||||||
|
|
@ -960,8 +971,8 @@ class HeatingRecommender:
|
||||||
heating_controls_only=heating_controls_only,
|
heating_controls_only=heating_controls_only,
|
||||||
system_change=system_change,
|
system_change=system_change,
|
||||||
system_type="high_heat_retention_storage_heater",
|
system_type="high_heat_retention_storage_heater",
|
||||||
measure_type="high_heat_retention_storage_heater",
|
non_intrusive_recommendation=non_intrusive_recommendation,
|
||||||
non_intrusive_recommendation=non_intrusive_recommendation
|
heating_product=hhrsh_product
|
||||||
)
|
)
|
||||||
if _return:
|
if _return:
|
||||||
return recommendations
|
return recommendations
|
||||||
|
|
@ -1053,13 +1064,6 @@ class HeatingRecommender:
|
||||||
), {})
|
), {})
|
||||||
|
|
||||||
if has_inefficient_space_heating or has_inefficient_water:
|
if has_inefficient_space_heating or has_inefficient_water:
|
||||||
boiler_size = self.estimate_boiler_size(
|
|
||||||
property_type=self.property.data["property-type"],
|
|
||||||
built_form=self.property.data["built-form"],
|
|
||||||
floor_area=self.property.floor_area,
|
|
||||||
floor_height=self.property.floor_height,
|
|
||||||
num_heated_rooms=self.property.data["number-heated-rooms"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.dual_heating:
|
if self.dual_heating:
|
||||||
description = self.DUAL_HEATING_DESCRIPTIONS[
|
description = self.DUAL_HEATING_DESCRIPTIONS[
|
||||||
|
|
@ -1130,7 +1134,6 @@ class HeatingRecommender:
|
||||||
}
|
}
|
||||||
|
|
||||||
boiler_costs = self.costs.boiler(
|
boiler_costs = self.costs.boiler(
|
||||||
size=f"{boiler_size}kw",
|
|
||||||
exising_room_heaters=exising_room_heaters,
|
exising_room_heaters=exising_room_heaters,
|
||||||
system_change=system_change,
|
system_change=system_change,
|
||||||
n_heated_rooms=self.property.data["number-heated-rooms"],
|
n_heated_rooms=self.property.data["number-heated-rooms"],
|
||||||
|
|
@ -1156,7 +1159,8 @@ class HeatingRecommender:
|
||||||
"description_simulation": description_simulation,
|
"description_simulation": description_simulation,
|
||||||
**boiler_costs,
|
**boiler_costs,
|
||||||
"system_type": "boiler_upgrade",
|
"system_type": "boiler_upgrade",
|
||||||
"survey": non_invasive_recommendation.get("survey", None)
|
"survey": non_invasive_recommendation.get("survey", None),
|
||||||
|
"innovation_rate": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# We recommend the heating controls
|
# We recommend the heating controls
|
||||||
|
|
@ -1197,7 +1201,10 @@ class HeatingRecommender:
|
||||||
heating_controls_only=False,
|
heating_controls_only=False,
|
||||||
system_change=True,
|
system_change=True,
|
||||||
system_type="boiler_upgrade",
|
system_type="boiler_upgrade",
|
||||||
measure_type="boiler_upgrade",
|
heating_product={ # temp until we do another product database update
|
||||||
|
"type": "boiler_upgrade",
|
||||||
|
"innovation_rate": 0
|
||||||
|
}
|
||||||
)
|
)
|
||||||
combined_recommendations.extend(combined_recommendation)
|
combined_recommendations.extend(combined_recommendation)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,8 @@ class HotwaterRecommendations:
|
||||||
"description_simulation": {
|
"description_simulation": {
|
||||||
"hot-water-energy-eff": "Poor"
|
"hot-water-energy-eff": "Poor"
|
||||||
},
|
},
|
||||||
"survey": survey
|
"survey": survey,
|
||||||
|
"innovation_rate": 0
|
||||||
}
|
}
|
||||||
if _return:
|
if _return:
|
||||||
return to_append
|
return to_append
|
||||||
|
|
@ -160,7 +161,8 @@ class HotwaterRecommendations:
|
||||||
"hot-water-energy-eff": self.property.data["hot-water-energy-eff"],
|
"hot-water-energy-eff": self.property.data["hot-water-energy-eff"],
|
||||||
"hotwater-description": new_epc_description,
|
"hotwater-description": new_epc_description,
|
||||||
},
|
},
|
||||||
"survey": survey
|
"survey": survey,
|
||||||
|
"innovation_rate": 0
|
||||||
}
|
}
|
||||||
if _return:
|
if _return:
|
||||||
return to_append
|
return to_append
|
||||||
|
|
@ -222,7 +224,8 @@ class HotwaterRecommendations:
|
||||||
"hot-water-energy-eff": simulation_config["hot_water_energy_eff_ending"],
|
"hot-water-energy-eff": simulation_config["hot_water_energy_eff_ending"],
|
||||||
"hotwater-description": new_epc_description,
|
"hotwater-description": new_epc_description,
|
||||||
},
|
},
|
||||||
"survey": False
|
"survey": False,
|
||||||
|
"innovation_rate": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recommendations.append(to_append)
|
self.recommendations.append(to_append)
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,6 @@ class LightingRecommendations:
|
||||||
|
|
||||||
cost_result = self.costs.low_energy_lighting(
|
cost_result = self.costs.low_energy_lighting(
|
||||||
number_of_lights=number_non_lel_outlets,
|
number_of_lights=number_non_lel_outlets,
|
||||||
number_current_lel_lights=number_lighting_outlets - number_non_lel_outlets,
|
|
||||||
material=self.material
|
material=self.material
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -170,6 +169,7 @@ class LightingRecommendations:
|
||||||
"low-energy-lighting": 100,
|
"low-energy-lighting": 100,
|
||||||
},
|
},
|
||||||
**cost_result,
|
**cost_result,
|
||||||
"survey": leds_recommendation_config.get("survey", False)
|
"survey": leds_recommendation_config.get("survey", False),
|
||||||
|
"innovation_rate": self.material["innovation_rate"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,11 @@ class Recommendations:
|
||||||
property_instance=property_instance, materials=materials
|
property_instance=property_instance, materials=materials
|
||||||
)
|
)
|
||||||
self.draught_proofing_recommender = DraughtProofingRecommendations(property_instance=property_instance)
|
self.draught_proofing_recommender = DraughtProofingRecommendations(property_instance=property_instance)
|
||||||
self.fireplace_recommender = FireplaceRecommendations(property_instance=property_instance)
|
self.fireplace_recommender = FireplaceRecommendations(property_instance=property_instance, materials=materials)
|
||||||
self.lighting_recommender = LightingRecommendations(property_instance=property_instance, materials=materials)
|
self.lighting_recommender = LightingRecommendations(property_instance=property_instance, materials=materials)
|
||||||
self.windows_recommender = WindowsRecommendations(property_instance=property_instance, materials=materials)
|
self.windows_recommender = WindowsRecommendations(property_instance=property_instance, materials=materials)
|
||||||
self.solar_recommender = SolarPvRecommendations(property_instance=property_instance)
|
self.solar_recommender = SolarPvRecommendations(property_instance=property_instance, materials=materials)
|
||||||
self.heating_recommender = HeatingRecommender(property_instance=property_instance)
|
self.heating_recommender = HeatingRecommender(property_instance=property_instance, materials=materials)
|
||||||
self.hotwater_recommender = HotwaterRecommendations(property_instance=property_instance)
|
self.hotwater_recommender = HotwaterRecommendations(property_instance=property_instance)
|
||||||
self.secondary_heating_recommender = SecondaryHeating(property_instance=property_instance)
|
self.secondary_heating_recommender = SecondaryHeating(property_instance=property_instance)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,20 +80,6 @@ class RoofRecommendations:
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def mds_loft_insulation(self, phase):
|
|
||||||
"""
|
|
||||||
For usages within the mds report
|
|
||||||
:param phase:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
self.recommendations = []
|
|
||||||
|
|
||||||
u_value = get_roof_u_value(**{**self.property.roof, "age_band": self.property.age_band})
|
|
||||||
|
|
||||||
self.recommend_roof_insulation(u_value, self.insulation_thickness, self.property.roof, phase)
|
|
||||||
|
|
||||||
return self.recommendations
|
|
||||||
|
|
||||||
def is_loft_already_insulated(self, measures):
|
def is_loft_already_insulated(self, measures):
|
||||||
"""
|
"""
|
||||||
Check if the loft is already insulated
|
Check if the loft is already insulated
|
||||||
|
|
@ -459,7 +445,8 @@ class RoofRecommendations:
|
||||||
"roof-energy-eff": new_efficiency
|
"roof-energy-eff": new_efficiency
|
||||||
},
|
},
|
||||||
**cost_result,
|
**cost_result,
|
||||||
"survey": non_invasive_recommendations.get("survey", False)
|
"survey": non_invasive_recommendations.get("survey", False),
|
||||||
|
"innovation_rate": material.to_dict()["innovation_rate"]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -593,7 +580,8 @@ class RoofRecommendations:
|
||||||
},
|
},
|
||||||
**cost_result,
|
**cost_result,
|
||||||
"already_installed": already_installed,
|
"already_installed": already_installed,
|
||||||
"survey": rir_non_invasive_recommendation.get("survey", None)
|
"survey": rir_non_invasive_recommendation.get("survey", None),
|
||||||
|
"innovation_rate": material.to_dict()["innovation_rate"]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ class SecondaryHeating:
|
||||||
},
|
},
|
||||||
"description_simulation": {
|
"description_simulation": {
|
||||||
"secondheat-description": "None"
|
"secondheat-description": "None"
|
||||||
}
|
},
|
||||||
|
"innovation_rate": 0.0, # No innovation rate for this measure
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ class SolarPvRecommendations:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, property_instance):
|
PANEL_SIZES = [400, 435, 440, 445]
|
||||||
|
|
||||||
|
def __init__(self, property_instance, materials: list):
|
||||||
"""
|
"""
|
||||||
:param property_instance: Instance of the Property class, for the home associated to property_id
|
:param property_instance: Instance of the Property class, for the home associated to property_id
|
||||||
"""
|
"""
|
||||||
|
|
@ -44,6 +46,14 @@ class SolarPvRecommendations:
|
||||||
|
|
||||||
self.recommendation = []
|
self.recommendation = []
|
||||||
|
|
||||||
|
self.panels_products = [
|
||||||
|
material for material in materials if material["type"] == "solar_pv"
|
||||||
|
]
|
||||||
|
|
||||||
|
self.scaffolding_options = [
|
||||||
|
material for material in materials if material["type"] == "scaffolding"
|
||||||
|
]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def trim_solar_wattage_options(scenarios_with_wattage):
|
def trim_solar_wattage_options(scenarios_with_wattage):
|
||||||
# Initialize the list with the first element, assuming the list is not empty
|
# Initialize the list with the first element, assuming the list is not empty
|
||||||
|
|
@ -85,17 +95,25 @@ class SolarPvRecommendations:
|
||||||
else:
|
else:
|
||||||
raise Exception("IMPLEMENT ME")
|
raise Exception("IMPLEMENT ME")
|
||||||
|
|
||||||
|
# We get solar PV options
|
||||||
|
solar_product = [x for x in self.panels_products if x["id"] == recommendation_config["solar_product_id"]]
|
||||||
|
if not solar_product:
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"Solar product with id {recommendation_config['solar_product_id']} not found in "
|
||||||
|
"panels_products"
|
||||||
|
)
|
||||||
|
solar_product = solar_product[0]
|
||||||
|
|
||||||
n_floors = (
|
n_floors = (
|
||||||
self.property.number_of_storeys["number_of_storeys"] if
|
self.property.number_of_storeys["number_of_storeys"] if
|
||||||
self.property.number_of_storeys["number_of_storeys"] is not None else 3
|
self.property.number_of_storeys["number_of_storeys"] is not None else 3
|
||||||
)
|
)
|
||||||
|
|
||||||
total_cost = self.costs.solar_pv(
|
total_cost = self.costs.solar_pv(
|
||||||
array_cost=recommendation_config.get("cost", None),
|
solar_product=solar_product,
|
||||||
n_panels=recommendation_config["n_panels"],
|
scaffolding_options=self.scaffolding_options,
|
||||||
n_floors=n_floors,
|
n_floors=n_floors,
|
||||||
needs_inverter=True,
|
)["total"]
|
||||||
)["total"] / n_units
|
|
||||||
|
|
||||||
kw = np.floor(recommendation_config["array_wattage"] / 100) / 10
|
kw = np.floor(recommendation_config["array_wattage"] / 100) / 10
|
||||||
# Default to a weeks work for a team of 3 people doing 8 hour days
|
# Default to a weeks work for a team of 3 people doing 8 hour days
|
||||||
|
|
@ -125,12 +143,34 @@ class SolarPvRecommendations:
|
||||||
# back up here
|
# back up here
|
||||||
"photo_supply": roof_coverage_percent,
|
"photo_supply": roof_coverage_percent,
|
||||||
"has_battery": False,
|
"has_battery": False,
|
||||||
|
"simulation_config": {
|
||||||
|
"photo_supply_ending": roof_coverage_percent
|
||||||
|
},
|
||||||
"initial_ac_kwh_per_year": initial_ac_kwh_per_year,
|
"initial_ac_kwh_per_year": initial_ac_kwh_per_year,
|
||||||
"description_simulation": {"photo-supply": roof_coverage_percent},
|
"description_simulation": {"photo-supply": roof_coverage_percent},
|
||||||
"rank": rank # Rank is used to get the representative recommendation - rank 0 will be chosen
|
"rank": rank, # Rank is used to get the representative recommendation - rank 0 will be chosen
|
||||||
|
"innovation_rate": solar_product["innovation_rate"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_available_products(self, n_panels):
|
||||||
|
"""
|
||||||
|
Utility function to get the available solar PV products based on the number of panels
|
||||||
|
:param n_panels:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
available_products = []
|
||||||
|
for panel_size in self.PANEL_SIZES:
|
||||||
|
system_size = (n_panels * panel_size) / 1000
|
||||||
|
prods = [
|
||||||
|
x for x in self.panels_products if abs(x["size"] - system_size) < 0.01
|
||||||
|
]
|
||||||
|
for x in prods:
|
||||||
|
x["panel_size"] = panel_size
|
||||||
|
available_products.extend(prods)
|
||||||
|
|
||||||
|
return available_products
|
||||||
|
|
||||||
def recommend(self, phase):
|
def recommend(self, phase):
|
||||||
"""
|
"""
|
||||||
We check if a property is potentially suitable for solar PV based on the following criteria:
|
We check if a property is potentially suitable for solar PV based on the following criteria:
|
||||||
|
|
@ -184,37 +224,47 @@ class SolarPvRecommendations:
|
||||||
|
|
||||||
# We combine each of these configurations with estimates with and without a battery
|
# We combine each of these configurations with estimates with and without a battery
|
||||||
for rank, recommendation_config in solar_configurations.iterrows():
|
for rank, recommendation_config in solar_configurations.iterrows():
|
||||||
roof_coverage_percent = round(recommendation_config["panneled_roof_area"] / roof_area * 100)
|
|
||||||
# We round up to the nearest 5
|
|
||||||
roof_coverage_percent = np.ceil(roof_coverage_percent / 5) * 5
|
|
||||||
|
|
||||||
# Typically, we've observed that every 5% of additional roof coverage will result in at least
|
n_panels = recommendation_config["n_panels"]
|
||||||
# an additional 1 SAP points (though often 2 points) Given this, we can add a reasonable minimum
|
|
||||||
# for the number of SAP points we might expect. We've observed that for some cases where properties
|
available_products = self._get_available_products(n_panels)
|
||||||
# are hitting the higher SAP scores (e.g. EPC A and above), the model can sometimes under-predict
|
|
||||||
# the number of SAP points. This appears to be due to a relatively small number of properties
|
# Given the available products in the database, we product the possible array of recommendations
|
||||||
# actually achieving the upper echelons of EPC rating. This can be the case if we're simulating a
|
for solar_pv_product in available_products:
|
||||||
# whole house retrofit where the home is getting complete insulation, a heat pump and solar panels.
|
|
||||||
# Because panels are the final recommendation, they are often the measure that takes the home
|
# we take the paneled roof area and this tells us the roof coverage, based on 400W panels
|
||||||
# into the medium to high EPC A ranges and so because of a lack of training data, this means that
|
# We then look at the equivalent for larger panels, which will produce more energy in the same area
|
||||||
# we might sometime under-predict. This minimum is intended to try and reduce the negative impact
|
|
||||||
# of this. This minimum is used in Recommendations.calculate_recommendation_impact
|
paneled_roof_area = recommendation_config["panneled_roof_area"]
|
||||||
minimum_sap_points = (roof_coverage_percent / 5) * self.SAP_POINTS_PER_5_PERCENT_ROOF_COVERAGE
|
|
||||||
|
roof_coverage_percent = round(
|
||||||
|
((paneled_roof_area / 400) * solar_pv_product["panel_size"]) / roof_area * 100
|
||||||
|
)
|
||||||
|
# We round up to the nearest 5
|
||||||
|
roof_coverage_percent = np.ceil(roof_coverage_percent / 5) * 5
|
||||||
|
|
||||||
|
# Note roof_coverage_percent is based on 400 watt panels, so we need to scale it up based on
|
||||||
|
# largest panels that will produce more energy in the same area
|
||||||
|
|
||||||
|
# Typically, we've observed that every 5% of additional roof coverage will result in at least
|
||||||
|
# an additional 1 SAP points (though often 2 points) Given this, we can add a reasonable minimum
|
||||||
|
# for the number of SAP points we might expect. We've observed that for some cases where properties
|
||||||
|
# are hitting the higher SAP scores (e.g. EPC A and above), the model can sometimes under-predict
|
||||||
|
# the number of SAP points. This appears to be due to a relatively small number of properties
|
||||||
|
# actually achieving the upper echelons of EPC rating. This can be the case if we're simulating a
|
||||||
|
# whole house retrofit where the home is getting complete insulation, a heat pump and solar panels.
|
||||||
|
# Because panels are the final recommendation, they are often the measure that takes the home
|
||||||
|
# into the medium to high EPC A ranges and so because of a lack of training data, this means that
|
||||||
|
# we might sometime under-predict. This minimum is intended to try and reduce the negative impact
|
||||||
|
# of this. This minimum is used in Recommendations.calculate_recommendation_impact
|
||||||
|
minimum_sap_points = (roof_coverage_percent / 5) * self.SAP_POINTS_PER_5_PERCENT_ROOF_COVERAGE
|
||||||
|
|
||||||
for has_battery in [False, True]:
|
|
||||||
cost_result = self.costs.solar_pv(
|
cost_result = self.costs.solar_pv(
|
||||||
has_battery=has_battery,
|
solar_product=solar_pv_product,
|
||||||
array_cost=non_invasive_recommendation.get("cost", None),
|
scaffolding_options=self.scaffolding_options,
|
||||||
n_panels=recommendation_config["n_panels"],
|
|
||||||
n_floors=self.property.number_of_floors
|
n_floors=self.property.number_of_floors
|
||||||
)
|
)
|
||||||
kw = np.floor(recommendation_config["array_wattage"] / 100) / 10
|
description = f"Install a {solar_pv_product['description']}"
|
||||||
if has_battery:
|
|
||||||
description = (
|
|
||||||
f"Install a {kw} kilowatt-peak (kWp) solar panel system, with a battery."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
description = f"Install a {kw} kilowatt-peak (kWp) solar panel system."
|
|
||||||
|
|
||||||
if self.property.in_conservation_area:
|
if self.property.in_conservation_area:
|
||||||
description += " Property is in a consevation area - please check with local planning authority."
|
description += " Property is in a consevation area - please check with local planning authority."
|
||||||
|
|
@ -226,7 +276,7 @@ class SolarPvRecommendations:
|
||||||
self.recommendation.append(
|
self.recommendation.append(
|
||||||
{
|
{
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
"parts": [],
|
"parts": [solar_pv_product],
|
||||||
"type": "solar_pv",
|
"type": "solar_pv",
|
||||||
"measure_type": "solar_pv",
|
"measure_type": "solar_pv",
|
||||||
"description": description,
|
"description": description,
|
||||||
|
|
@ -235,12 +285,12 @@ class SolarPvRecommendations:
|
||||||
"sap_points": minimum_sap_points,
|
"sap_points": minimum_sap_points,
|
||||||
"already_installed": already_installed,
|
"already_installed": already_installed,
|
||||||
**cost_result,
|
**cost_result,
|
||||||
# This is required for simulating the SAP impact. solar_pv_percentage is between 0 & 1 so we
|
"has_battery": solar_pv_product["includes_battery"],
|
||||||
# scale
|
"simulation_config": {
|
||||||
# back up here
|
"photo_supply_ending": roof_coverage_percent
|
||||||
"photo_supply": roof_coverage_percent,
|
},
|
||||||
"has_battery": has_battery,
|
|
||||||
"initial_ac_kwh_per_year": recommendation_config["initial_ac_kwh_per_year"],
|
"initial_ac_kwh_per_year": recommendation_config["initial_ac_kwh_per_year"],
|
||||||
"description_simulation": {"photo-supply": roof_coverage_percent},
|
"description_simulation": {"photo-supply": roof_coverage_percent},
|
||||||
|
"innovation_rate": solar_pv_product["innovation_rate"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ class VentilationRecommendations(Definitions):
|
||||||
self.property = property_instance
|
self.property = property_instance
|
||||||
|
|
||||||
self.recommendation = None
|
self.recommendation = None
|
||||||
self.materials = [part for part in materials if part["type"] == "mechanical_ventilation"]
|
self.mechanical_ventilation_materials = [part for part in materials if part["type"] == "mechanical_ventilation"]
|
||||||
|
self.trickle_vent_materials = [part for part in materials if part["type"] == "trickle_vent"]
|
||||||
|
|
||||||
def recommend(self, phase):
|
def recommend(self, phase):
|
||||||
"""
|
"""
|
||||||
|
|
@ -33,32 +34,32 @@ class VentilationRecommendations(Definitions):
|
||||||
if self.property.has_ventilation:
|
if self.property.has_ventilation:
|
||||||
return
|
return
|
||||||
|
|
||||||
if len(self.materials) != 1:
|
|
||||||
raise NotImplementedError("Only handled the case of having one venilation option")
|
|
||||||
|
|
||||||
# We recommend installing 2 units
|
# We recommend installing 2 units
|
||||||
n_units = 2
|
n_units = 2
|
||||||
|
|
||||||
part = self.materials.copy()
|
parts = self.mechanical_ventilation_materials.copy()
|
||||||
|
|
||||||
already_installed = "cavity_wall_insulation" in self.property.already_installed
|
already_installed = "cavity_wall_insulation" in self.property.already_installed
|
||||||
|
|
||||||
estimated_cost = n_units * part[0]["total_cost"] if not already_installed else 0
|
# TODO: We now have multiple ventilation options - we default to selecting the cheapest option
|
||||||
|
part = min(parts, key=lambda x: x['total_cost'])
|
||||||
|
|
||||||
|
estimated_cost = n_units * part["total_cost"] if not already_installed else 0
|
||||||
labour_hours = 4 * n_units if not already_installed else 0
|
labour_hours = 4 * n_units if not already_installed else 0
|
||||||
labour_days = 4 * n_units / 8.0 if not already_installed else 0
|
labour_days = 4 * n_units / 8.0 if not already_installed else 0
|
||||||
|
|
||||||
part[0]["total"] = estimated_cost
|
part["total"] = estimated_cost
|
||||||
part[0]["quantity"] = n_units
|
part["quantity"] = n_units
|
||||||
part[0]["quantity_unit"] = "part"
|
part["quantity_unit"] = "part"
|
||||||
|
|
||||||
# We recommend installing two mechanical ventilation systems
|
# We recommend installing two mechanical ventilation systems
|
||||||
self.recommendation = [
|
self.recommendation = [
|
||||||
{
|
{
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
"parts": part,
|
"parts": [part],
|
||||||
"type": part[0]["type"],
|
"type": part["type"],
|
||||||
"measure_type": "mechanical_ventilation",
|
"measure_type": "mechanical_ventilation",
|
||||||
"description": f"Install {n_units} {part[0]['description']} units",
|
"description": f"Install {n_units} {part['description']} units",
|
||||||
"starting_u_value": None,
|
"starting_u_value": None,
|
||||||
"new_u_value": None,
|
"new_u_value": None,
|
||||||
"already_installed": already_installed,
|
"already_installed": already_installed,
|
||||||
|
|
@ -76,7 +77,8 @@ class VentilationRecommendations(Definitions):
|
||||||
},
|
},
|
||||||
"description_simulation": {
|
"description_simulation": {
|
||||||
"mechanical-ventilation": "mechanical, extract only"
|
"mechanical-ventilation": "mechanical, extract only"
|
||||||
}
|
},
|
||||||
|
"innovation_rate": part["innovation_rate"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -99,6 +101,10 @@ class VentilationRecommendations(Definitions):
|
||||||
else trickle_vents_recommendation_config["description"]
|
else trickle_vents_recommendation_config["description"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cheapest_trickle_vent = min(
|
||||||
|
self.trickle_vent_materials, key=lambda x: x["total_cost"]
|
||||||
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"phase": None,
|
"phase": None,
|
||||||
|
|
@ -114,7 +120,7 @@ class VentilationRecommendations(Definitions):
|
||||||
"kwh_savings": 0,
|
"kwh_savings": 0,
|
||||||
"co2_equivalent_savings": 0,
|
"co2_equivalent_savings": 0,
|
||||||
"energy_cost_savings": 0,
|
"energy_cost_savings": 0,
|
||||||
"total": trickle_vents_recommendation_config["cost"],
|
"total": cheapest_trickle_vent["total_cost"] * self.property.number_of_windows,
|
||||||
# We use a very simple and rough estimate of 4 hours per unit
|
# We use a very simple and rough estimate of 4 hours per unit
|
||||||
"labour_hours": trickle_vents_recommendation_config.get("labour_hours", 8),
|
"labour_hours": trickle_vents_recommendation_config.get("labour_hours", 8),
|
||||||
"labour_days": trickle_vents_recommendation_config.get("labour_days", 1), # Assume 8 hour day
|
"labour_days": trickle_vents_recommendation_config.get("labour_days", 1), # Assume 8 hour day
|
||||||
|
|
|
||||||
|
|
@ -142,46 +142,6 @@ class WallRecommendations(Definitions):
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def mds_recommend_cavity_wall_insulation(self, phase=None):
|
|
||||||
# Function specifically for cavity wall insulation, for usage in the mds report
|
|
||||||
self.recommendations = []
|
|
||||||
insulation_thickness = self.property.walls["insulation_thickness"]
|
|
||||||
|
|
||||||
u_value = get_wall_u_value(
|
|
||||||
clean_description=self.property.walls["clean_description"],
|
|
||||||
age_band=self.property.age_band,
|
|
||||||
is_granite_or_whinstone=self.property.walls["is_granite_or_whinstone"],
|
|
||||||
is_sandstone_or_limestone=self.property.walls["is_sandstone_or_limestone"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test filling cavity
|
|
||||||
self.find_cavity_insulation(u_value, insulation_thickness, phase, measures)
|
|
||||||
|
|
||||||
return self.recommendations
|
|
||||||
|
|
||||||
def mds_recommend_ewi(self, phase=None):
|
|
||||||
# Function specifically for external wall insulation, for usage in the mds report
|
|
||||||
self.recommendations = []
|
|
||||||
|
|
||||||
u_value = self.property.walls["thermal_transmittance"]
|
|
||||||
|
|
||||||
if u_value is None:
|
|
||||||
u_value = get_wall_u_value(
|
|
||||||
clean_description=self.property.walls["clean_description"],
|
|
||||||
age_band=self.property.age_band,
|
|
||||||
is_granite_or_whinstone=self.property.walls["is_granite_or_whinstone"],
|
|
||||||
is_sandstone_or_limestone=self.property.walls["is_sandstone_or_limestone"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# EWI
|
|
||||||
ewi_recommendations = self._find_insulation(
|
|
||||||
u_value=u_value,
|
|
||||||
insulation_materials=pd.DataFrame(self.external_wall_insulation_materials),
|
|
||||||
phase=phase
|
|
||||||
)
|
|
||||||
|
|
||||||
return ewi_recommendations
|
|
||||||
|
|
||||||
def recommend(self, phase=0, measures=None, default_u_values=False):
|
def recommend(self, phase=0, measures=None, default_u_values=False):
|
||||||
# if building built after 1990 + we're able to identify U-value +
|
# if building built after 1990 + we're able to identify U-value +
|
||||||
# U-value less than 0.18 and if in or close to a conversation area,
|
# U-value less than 0.18 and if in or close to a conversation area,
|
||||||
|
|
@ -478,7 +438,8 @@ class WallRecommendations(Definitions):
|
||||||
"walls-energy-eff": "Good"
|
"walls-energy-eff": "Good"
|
||||||
},
|
},
|
||||||
**cost_result,
|
**cost_result,
|
||||||
"survey": non_invasive_recommendations.get("survey", False)
|
"survey": non_invasive_recommendations.get("survey", False),
|
||||||
|
"innovation_rate": material.to_dict()["innovation_rate"]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -658,7 +619,8 @@ class WallRecommendations(Definitions):
|
||||||
"walls-energy-eff": simulation_config["walls_energy_eff_ending"]
|
"walls-energy-eff": simulation_config["walls_energy_eff_ending"]
|
||||||
},
|
},
|
||||||
**cost_result,
|
**cost_result,
|
||||||
"survey": survey
|
"survey": survey,
|
||||||
|
"innovation_rate": material.to_dict()["innovation_rate"]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,7 @@ class WindowsRecommendations:
|
||||||
"description_simulation": description_simulation,
|
"description_simulation": description_simulation,
|
||||||
"simulation_config": simulation_config,
|
"simulation_config": simulation_config,
|
||||||
"survey": non_invasive_recommendation.get("survey", None),
|
"survey": non_invasive_recommendation.get("survey", None),
|
||||||
|
"innovation_rate": self.glazing_material["innovation_rate"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class CostOptimiser:
|
||||||
# We add an optional buffer to the minimum gain to allow for slack in the optimisation
|
# We add an optional buffer to the minimum gain to allow for slack in the optimisation
|
||||||
BUFFER = 0.2
|
BUFFER = 0.2
|
||||||
|
|
||||||
def __init__(self, components, min_gain):
|
def __init__(self, components, min_gain, verbose=False):
|
||||||
self.components = components
|
self.components = components
|
||||||
self.min_gain = min_gain
|
self.min_gain = min_gain
|
||||||
self.gain_constraint = None
|
self.gain_constraint = None
|
||||||
|
|
@ -22,6 +22,7 @@ class CostOptimiser:
|
||||||
|
|
||||||
self.solution_cost = None
|
self.solution_cost = None
|
||||||
self.solution_gain = None
|
self.solution_gain = None
|
||||||
|
self.verbose = verbose
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def calculate_sap_gain_with_slack(cls, min_gain: int | float):
|
def calculate_sap_gain_with_slack(cls, min_gain: int | float):
|
||||||
|
|
@ -42,6 +43,8 @@ class CostOptimiser:
|
||||||
def setup(self):
|
def setup(self):
|
||||||
# Initialize Model
|
# Initialize Model
|
||||||
self.m = Model("knapsack")
|
self.m = Model("knapsack")
|
||||||
|
# Set the verbosity level
|
||||||
|
self.m.verbose = 1 if self.verbose else 0
|
||||||
|
|
||||||
# Create variables
|
# Create variables
|
||||||
self.variables = [
|
self.variables = [
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class GainOptimiser:
|
||||||
This class is used to maximise gain, given a constrained cost
|
This class is used to maximise gain, given a constrained cost
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, components, max_cost, max_gain, allow_slack=True):
|
def __init__(self, components, max_cost, max_gain, allow_slack=True, verbose=False):
|
||||||
"""
|
"""
|
||||||
This function will try and maximise the gain, given a constrained cost. If we specific a max_gain, then the
|
This function will try and maximise the gain, given a constrained cost. If we specific a max_gain, then the
|
||||||
optimisation routine is constained to try not to exceed a maximum increase
|
optimisation routine is constained to try not to exceed a maximum increase
|
||||||
|
|
@ -23,6 +23,7 @@ class GainOptimiser:
|
||||||
:param max_gain: Maximum gain constraint
|
:param max_gain: Maximum gain constraint
|
||||||
:param allow_slack: If True, allows the model to use slack variables to relax the cost constraint if the model
|
:param allow_slack: If True, allows the model to use slack variables to relax the cost constraint if the model
|
||||||
is infeasible. Defaults to True.
|
is infeasible. Defaults to True.
|
||||||
|
:param verbose: If True, enables verbose logging
|
||||||
"""
|
"""
|
||||||
self.components = components
|
self.components = components
|
||||||
self.max_cost = max_cost
|
self.max_cost = max_cost
|
||||||
|
|
@ -35,11 +36,15 @@ class GainOptimiser:
|
||||||
self.solution_gain = None
|
self.solution_gain = None
|
||||||
self.solution_cost = None
|
self.solution_cost = None
|
||||||
self.allow_slack = allow_slack
|
self.allow_slack = allow_slack
|
||||||
|
self.verbose = verbose
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
# Initialize Model
|
# Initialize Model
|
||||||
self.m = Model("knapsack")
|
self.m = Model("knapsack")
|
||||||
|
|
||||||
|
# Set the verbosity level
|
||||||
|
self.m.verbose = 1 if self.verbose else 0
|
||||||
|
|
||||||
# Create variables
|
# Create variables
|
||||||
self.variables = [
|
self.variables = [
|
||||||
[self.m.add_var(var_type=BINARY, name=str(component["id"])) for component in group] for group in
|
[self.m.add_var(var_type=BINARY, name=str(component["id"])) for component in group] for group in
|
||||||
|
|
|
||||||
864
recommendations/optimiser/funding_optimiser.py
Normal file
864
recommendations/optimiser/funding_optimiser.py
Normal file
|
|
@ -0,0 +1,864 @@
|
||||||
|
"""
|
||||||
|
This script contains a number of functions which are designed to enable optimisation and selection of funding options
|
||||||
|
for individual properties to improve their energy efficiency
|
||||||
|
|
||||||
|
The main entry point to this is optimise_with_funding_paths
|
||||||
|
|
||||||
|
In the future, we will adapt this into a class-based structure to allow for more flexibility and reusability
|
||||||
|
"""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from backend.app.plan.schemas import (
|
||||||
|
WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES
|
||||||
|
)
|
||||||
|
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||||
|
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
||||||
|
from utils.logger import setup_logger
|
||||||
|
from backend.Funding import Funding
|
||||||
|
|
||||||
|
logger = setup_logger()
|
||||||
|
|
||||||
|
# measures we DO NOT treat as fundable in the ECO4 'funded' pass
|
||||||
|
_ECO4_EXCLUDE_TYPES = {"secondary_heating"}
|
||||||
|
|
||||||
|
|
||||||
|
def _path_scheme(path_spec):
|
||||||
|
"""
|
||||||
|
Infer scheme from any 'reference' tag in the path.
|
||||||
|
Defaults to 'eco4' if not specified.
|
||||||
|
"""
|
||||||
|
for elem in path_spec or []:
|
||||||
|
ref = elem.get("reference")
|
||||||
|
if isinstance(ref, str):
|
||||||
|
if ref.endswith(":gbis"):
|
||||||
|
return "gbis"
|
||||||
|
if ref.endswith(":eco4"):
|
||||||
|
return "eco4"
|
||||||
|
return "eco4"
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_fundable_subgroups(groups, scheme):
|
||||||
|
"""
|
||||||
|
Keep only options eligible for the funded pass of the given scheme.
|
||||||
|
- ECO4: drop excluded types (e.g., secondary_heating)
|
||||||
|
- GBIS: funded pass is the GBIS fixed measure only, so return empty sub-groups
|
||||||
|
"""
|
||||||
|
if scheme == "gbis":
|
||||||
|
return [] # we won't optimise 'the rest' under GBIS here
|
||||||
|
|
||||||
|
# ECO4 case
|
||||||
|
filtered = []
|
||||||
|
for grp in groups:
|
||||||
|
kept = [opt for opt in grp
|
||||||
|
if not any(ex in opt["type"] for ex in _ECO4_EXCLUDE_TYPES)]
|
||||||
|
if kept:
|
||||||
|
filtered.append(kept)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _sum_cost_gain_with_scheme(items, scheme):
|
||||||
|
"""
|
||||||
|
Sum cost/gain of fixed items, adjusting for scheme rules.
|
||||||
|
- GBIS: strip innovation uplift from GBIS-funded fixed measures only.
|
||||||
|
"""
|
||||||
|
total_cost = 0.0
|
||||||
|
total_gain = 0.0
|
||||||
|
for it in items:
|
||||||
|
cost = float(it["cost"])
|
||||||
|
if scheme == "gbis":
|
||||||
|
# innovation uplifts are not paid under GBIS
|
||||||
|
cost -= float(it.get("innovation_uplift", 0.0))
|
||||||
|
total_cost += cost
|
||||||
|
total_gain += float(it["gain"])
|
||||||
|
return total_cost, total_gain
|
||||||
|
|
||||||
|
|
||||||
|
def violates_min_insulation(fixed):
|
||||||
|
"""Return True if fixed selection includes a heating/PV measure but no required insulation."""
|
||||||
|
picked_types = {opt["type"] for (_, _, opt) in fixed}
|
||||||
|
|
||||||
|
def has_any(substrs):
|
||||||
|
return any(any(s in t for s in substrs) for t in picked_types)
|
||||||
|
|
||||||
|
# heating (incl. PV) flags
|
||||||
|
is_heating = has_any([
|
||||||
|
"air_source_heat_pump",
|
||||||
|
"high_heat_retention_storage_heater",
|
||||||
|
"boiler_upgrade",
|
||||||
|
"electric_boiler",
|
||||||
|
"time_temperature_zone_control",
|
||||||
|
"secondary_heating",
|
||||||
|
"solar_pv", # PV treated as heating for MIR
|
||||||
|
])
|
||||||
|
|
||||||
|
# MIR insulation (the ones you’re using in path construction)
|
||||||
|
has_insul = has_any([
|
||||||
|
"external_wall_insulation",
|
||||||
|
"internal_wall_insulation",
|
||||||
|
"cavity_wall_insulation",
|
||||||
|
"extension_cavity_wall_insulation",
|
||||||
|
"loft_insulation",
|
||||||
|
"flat_roof_insulation",
|
||||||
|
"room_roof_insulation",
|
||||||
|
])
|
||||||
|
|
||||||
|
return is_heating and not has_insul
|
||||||
|
|
||||||
|
|
||||||
|
# Treat "type" like "external_wall_insulation+mechanical_ventilation" → "external_wall_insulation"
|
||||||
|
def _base_type(s: str) -> str:
|
||||||
|
return s.split("+", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_measures_by_types(input_measures, allowed_types):
|
||||||
|
"""
|
||||||
|
Keep only groups that have ≥1 allowed option; inside each group keep only allowed options.
|
||||||
|
"""
|
||||||
|
allowed_set = set(allowed_types)
|
||||||
|
filtered = []
|
||||||
|
for group in input_measures:
|
||||||
|
kept_opts = [opt for opt in group if _base_type(opt["type"]) in allowed_set]
|
||||||
|
if kept_opts:
|
||||||
|
filtered.append(kept_opts)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _is_eligible_funding_package(scheme, starting_sap, total_gain):
|
||||||
|
if scheme == "eco4":
|
||||||
|
# We check if we meet the upgrade requirements
|
||||||
|
# If the property is an E or above, we need to upgrade to a C or above
|
||||||
|
if starting_sap >= 39: # ie. EPC C or above
|
||||||
|
return starting_sap + total_gain >= 69
|
||||||
|
|
||||||
|
if scheme == "gbis":
|
||||||
|
# GBIS is a fixed measure only, so we don't check the gain
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _prs_solution_ok(items, p, funding):
|
||||||
|
# items: list of picked option dicts (after optimisation)
|
||||||
|
# treat "type" possibly like "x+y" -> split and look at base tokens
|
||||||
|
types = set()
|
||||||
|
for opt in items:
|
||||||
|
for t in opt["type"].split("+"):
|
||||||
|
types.add(t)
|
||||||
|
|
||||||
|
has_solid_wall = ("external_wall_insulation" in types) or ("internal_wall_insulation" in types)
|
||||||
|
|
||||||
|
# renewable set:
|
||||||
|
has_ashp = ("air_source_heat_pump" in types) # ASHP alone is renewable
|
||||||
|
has_solar = ("solar_pv" in types)
|
||||||
|
has_hhrsh = ("high_heat_retention_storage_heater" in types) # only counts *with* solar
|
||||||
|
|
||||||
|
# solar PV qualifies if paired with eligible existing heating
|
||||||
|
solar_ok_existing = has_solar and funding.check_solar_eligible_heating_system(
|
||||||
|
p.main_heating["clean_description"], p.main_heating_controls["clean_description"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# or paired with ASHP/HHRSH in the same package
|
||||||
|
solar_ok_with_installed = has_solar and (has_ashp or has_hhrsh)
|
||||||
|
|
||||||
|
renewable_ok = has_ashp or solar_ok_existing or solar_ok_with_installed
|
||||||
|
|
||||||
|
return has_solid_wall or renewable_ok
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_unfunded_costs(groups):
|
||||||
|
"""Make sure each option’s cost is base+uplift (i.e., no funding).
|
||||||
|
Safe if fields already match; works on a deepcopy.
|
||||||
|
"""
|
||||||
|
for grp in groups:
|
||||||
|
for opt in grp:
|
||||||
|
base = opt.get("cost_minus_uplift")
|
||||||
|
if base is not None:
|
||||||
|
opt["cost"] = opt["raw_cost"]
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def optimise_with_funding_paths(p, input_measures, housing_type, funding: Funding, budget=None, target_gain=None):
|
||||||
|
"""
|
||||||
|
run_optimizer(sub_measures, budget, target_gain) -> (picked_options, sub_cost, sub_gain)
|
||||||
|
"""
|
||||||
|
|
||||||
|
solutions = []
|
||||||
|
|
||||||
|
# unfunded - we utilise all measures
|
||||||
|
unfunded_measures = input_measures.copy()
|
||||||
|
unfunded_measures = _ensure_unfunded_costs(unfunded_measures)
|
||||||
|
picked, total_cost, total_gain = run_optimizer(
|
||||||
|
unfunded_measures,
|
||||||
|
budget=budget,
|
||||||
|
sub_target_gain=target_gain
|
||||||
|
)
|
||||||
|
if picked is not None:
|
||||||
|
solutions.append({
|
||||||
|
"fixed_ids": [],
|
||||||
|
"items": picked,
|
||||||
|
"total_cost": total_cost,
|
||||||
|
"total_gain": total_gain,
|
||||||
|
"path": {"reference": "unfunded:all"},
|
||||||
|
"scheme": "none",
|
||||||
|
"is_eligible": False, # no funding scheme applied
|
||||||
|
"unfunded_items": []
|
||||||
|
})
|
||||||
|
|
||||||
|
# This function will filter down on innovation measures if we are social EPC D
|
||||||
|
funding_paths, optimisation_input_measures = make_funding_paths(p, input_measures, housing_type, funding)
|
||||||
|
|
||||||
|
# We now produce a fabric only path for ECO4
|
||||||
|
# We add in generic insulation funding paths (where there is no fixed measure)
|
||||||
|
# Heating controls are only eligible if installed as part of a heating upgrade and so we do not include them
|
||||||
|
# here
|
||||||
|
if housing_type == "Social":
|
||||||
|
funding_paths = (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'reference': 'fabric-only:eco4',
|
||||||
|
"allowed_types": WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES +
|
||||||
|
ECO4_ELIGIBILE_FABRIC_MEASURES
|
||||||
|
}
|
||||||
|
] + funding_paths
|
||||||
|
)
|
||||||
|
|
||||||
|
for path_spec in funding_paths:
|
||||||
|
|
||||||
|
# ECO4 fabric only path = special case
|
||||||
|
if isinstance(path_spec, dict) and path_spec.get("reference") == "fabric-only:eco4":
|
||||||
|
sub_measures = _filter_measures_by_types(optimisation_input_measures, path_spec["allowed_types"])
|
||||||
|
if not sub_measures:
|
||||||
|
continue
|
||||||
|
|
||||||
|
picked, sub_cost, sub_gain = run_optimizer(
|
||||||
|
sub_measures,
|
||||||
|
budget=budget, # no fixed items; budget unchanged
|
||||||
|
sub_target_gain=target_gain
|
||||||
|
)
|
||||||
|
|
||||||
|
if picked is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
scheme = _path_scheme([path_spec])
|
||||||
|
|
||||||
|
solutions.append(
|
||||||
|
{
|
||||||
|
"fixed_ids": [],
|
||||||
|
"items": picked,
|
||||||
|
"total_cost": sub_cost,
|
||||||
|
"total_gain": sub_gain,
|
||||||
|
"path": path_spec,
|
||||||
|
"scheme": scheme,
|
||||||
|
"is_eligible": _is_eligible_funding_package(scheme, p.data["current-energy-efficiency"], sub_gain),
|
||||||
|
"unfunded_items": []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 1) expand fixed selections for this path
|
||||||
|
fixed_selections = expand_funding_path(optimisation_input_measures, path_spec) if path_spec else [[]]
|
||||||
|
if not fixed_selections:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for fixed in fixed_selections:
|
||||||
|
|
||||||
|
if violates_min_insulation(fixed):
|
||||||
|
# We log an error and skip this - we should not see any errors but we can probably get a reasonable
|
||||||
|
# outcome for the end user without a complete termination of the process
|
||||||
|
logger.error("Skipping fixed selection due to minimum insulation violation: %s", fixed)
|
||||||
|
continue
|
||||||
|
|
||||||
|
scheme = _path_scheme(path_spec)
|
||||||
|
|
||||||
|
# 3) compute fixed cost/gain, and strip those groups from subproblem
|
||||||
|
fixed_items = [opt for (_, _, opt) in fixed]
|
||||||
|
|
||||||
|
if scheme == "gbis":
|
||||||
|
# Re-set costs as the only funding we get is the PPS
|
||||||
|
for x in fixed_items:
|
||||||
|
x["cost"] = x["raw_cost"]
|
||||||
|
|
||||||
|
fixed_ids = [opt['id'] for opt in fixed_items]
|
||||||
|
fixed_cost, fixed_gain = sum_cost_gain(fixed_items)
|
||||||
|
fixed_groups = {gi for (gi, _, _) in fixed}
|
||||||
|
|
||||||
|
sub_measures = deepcopy(
|
||||||
|
[grp for gi, grp in enumerate(optimisation_input_measures) if gi not in fixed_groups]
|
||||||
|
)
|
||||||
|
|
||||||
|
if scheme == "gbis":
|
||||||
|
# Then for the sub-measures, we need to strip the innovation uplift from the GBIS fixed measures. We
|
||||||
|
# do this by adding innovation back onto the cost
|
||||||
|
for grp in sub_measures:
|
||||||
|
for opt in grp:
|
||||||
|
opt["cost"] = opt["raw_cost"]
|
||||||
|
|
||||||
|
if scheme == "eco4":
|
||||||
|
# Need to strip out any measure types that are not eligible for ECO4 funding (e.g. secondary heating)
|
||||||
|
sub_measures = _filter_fundable_subgroups(sub_measures, scheme)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
if housing_type == "Private":
|
||||||
|
if not _prs_solution_ok(total_picks, p, funding):
|
||||||
|
logger.error(
|
||||||
|
"Found a solution that does not meet the PRS requirements: %s - this shouldn't be happening",
|
||||||
|
total_picks
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
scheme = _path_scheme(path_spec)
|
||||||
|
|
||||||
|
unfunded_picked = []
|
||||||
|
if total_gain - target_gain < -0.1:
|
||||||
|
# In this case, we have a funded package that does not meet the target gain, so we look at the remaining
|
||||||
|
# measures and see if we can include them
|
||||||
|
picked_types = {opt["type"] for opt in total_picks}
|
||||||
|
|
||||||
|
# We find the indexes of the picked types
|
||||||
|
picked_group_index = {}
|
||||||
|
for pt in picked_types:
|
||||||
|
for gi, grp in enumerate(optimisation_input_measures):
|
||||||
|
if any(pt in opt["type"] for opt in grp):
|
||||||
|
picked_group_index[pt] = gi
|
||||||
|
break
|
||||||
|
# We get the remaining types
|
||||||
|
# ECO4 case
|
||||||
|
remaining = []
|
||||||
|
for i, grp in enumerate(optimisation_input_measures):
|
||||||
|
if i in picked_group_index.values():
|
||||||
|
continue
|
||||||
|
keep = [x for x in grp if x["type"] not in picked_types]
|
||||||
|
if keep:
|
||||||
|
for x in keep:
|
||||||
|
# Adjust to raw cost (without funding)
|
||||||
|
x["cost"] = x["raw_cost"]
|
||||||
|
remaining.append(keep)
|
||||||
|
|
||||||
|
if remaining:
|
||||||
|
# If we have remaining measures we can optimise, we run them down an unfunded route
|
||||||
|
unfunded_picked, unfunded_cost, unfunded_gain = run_optimizer(
|
||||||
|
remaining,
|
||||||
|
budget - total_cost if budget is not None else None,
|
||||||
|
sub_target_gain=target_gain - total_gain if target_gain is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
|
total_cost += unfunded_cost
|
||||||
|
total_gain += unfunded_gain
|
||||||
|
|
||||||
|
solutions.append({
|
||||||
|
"fixed_ids": fixed_ids,
|
||||||
|
"items": total_picks,
|
||||||
|
"total_cost": total_cost,
|
||||||
|
"total_gain": total_gain,
|
||||||
|
"path": path_spec,
|
||||||
|
"scheme": scheme,
|
||||||
|
"is_eligible": _is_eligible_funding_package(scheme, p.data["current-energy-efficiency"], total_gain),
|
||||||
|
"unfunded_items": unfunded_picked,
|
||||||
|
})
|
||||||
|
|
||||||
|
solutions = pd.DataFrame(solutions)
|
||||||
|
|
||||||
|
# Given the scheme, we now check if the packages are eligible. If they *are* eligible, but they don't meet the
|
||||||
|
# final upgrade target, we then look to perform a final optimisation pass to meet the target gain.
|
||||||
|
solutions["meets_upgrade_target"] = solutions["total_gain"] >= target_gain - 0.1
|
||||||
|
|
||||||
|
# If we have packages that are fundable, but do not meet the upgrade target, we can run a final optimisation pass
|
||||||
|
if not solutions[solutions["is_eligible"] & ~solutions["meets_upgrade_target"]].empty:
|
||||||
|
raise NotImplementedError("Implement me")
|
||||||
|
|
||||||
|
# We now can calculate the project ABS, which subtracts from the cost, but this is only relevant for ECO4
|
||||||
|
solutions["starting_sap"] = p.data["current-energy-efficiency"]
|
||||||
|
solutions["floor_area"] = p.floor_area
|
||||||
|
solutions["ending_sap"] = solutions["starting_sap"] + solutions["total_gain"]
|
||||||
|
solutions["starting_band"] = solutions["starting_sap"].apply(funding.get_sap_band)
|
||||||
|
solutions["ending_band"] = solutions["ending_sap"].apply(funding.get_sap_band)
|
||||||
|
solutions["floor_area_band"] = solutions["floor_area"].apply(funding.get_floor_area_band)
|
||||||
|
solutions["project_score"] = solutions.apply(
|
||||||
|
lambda x: funding._calculate_full_project_abs(
|
||||||
|
floor_area_band=x["floor_area_band"],
|
||||||
|
starting_sap_band=x["starting_band"],
|
||||||
|
ending_sap_band=x["ending_band"],
|
||||||
|
),
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
rate = funding.get_eco4_abs_rate(is_cavity=p.walls["is_cavity_wall"])
|
||||||
|
solutions["full_project_funding"] = solutions["project_score"] * rate
|
||||||
|
# if the scheme is not ECO4, we set the funding to 0 with iloc
|
||||||
|
solutions.loc[solutions["scheme"] != "eco4", "full_project_funding"] = 0.0
|
||||||
|
solutions["partial_project_funding"] = solutions.apply(lambda x: get_gbis_pp_funding(x), axis=1)
|
||||||
|
solutions["partial_project_score"] = solutions.apply(lambda x: get_gbis_pps(x), axis=1)
|
||||||
|
|
||||||
|
# We pull out uplifts
|
||||||
|
solutions["total_uplift"] = solutions.apply(lambda x: get_total_uplift(x), axis=1)
|
||||||
|
solutions["total_uplift_score"] = solutions.apply(lambda x: get_total_innovation_score(x), axis=1)
|
||||||
|
|
||||||
|
return solutions
|
||||||
|
|
||||||
|
|
||||||
|
# ---- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_gbis_pp_funding(x):
|
||||||
|
if x["scheme"] != "gbis":
|
||||||
|
return 0
|
||||||
|
fixed_ids = x["fixed_ids"]
|
||||||
|
if len(fixed_ids) != 1:
|
||||||
|
raise ValueError("More than one fixed ID for GBIS")
|
||||||
|
return [x for x in x["items"] if x["id"] in fixed_ids][0]["partial_project_funding"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_gbis_pps(x):
|
||||||
|
if x["scheme"] != "gbis":
|
||||||
|
return 0
|
||||||
|
fixed_ids = x["fixed_ids"]
|
||||||
|
if len(fixed_ids) != 1:
|
||||||
|
raise ValueError("More than one fixed ID for GBIS")
|
||||||
|
return [x for x in x["items"] if x["id"] in fixed_ids][0]["partial_project_score"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_total_uplift(x):
|
||||||
|
return sum([y["innovation_uplift"] for y in x["items"]])
|
||||||
|
|
||||||
|
|
||||||
|
def get_total_innovation_score(x):
|
||||||
|
return sum([y["uplift_project_score"] for y in x["items"]])
|
||||||
|
|
||||||
|
|
||||||
|
def sum_cost_gain(items):
|
||||||
|
c = sum(float(x['cost']) for x in items)
|
||||||
|
g = sum(float(x['gain']) for x in items)
|
||||||
|
return c, g
|
||||||
|
|
||||||
|
|
||||||
|
# ---- candidate expansion -------------------------------------------------
|
||||||
|
def type_matches(option_type: str, required: str) -> bool:
|
||||||
|
# substring match so "external_wall_insulation+mechanical_ventilation" satisfies "external_wall_insulation"
|
||||||
|
return required in option_type
|
||||||
|
|
||||||
|
|
||||||
|
def candidates_for_type(input_measures, required_type):
|
||||||
|
"""
|
||||||
|
Return a list of (gi, oi, opt) where opt['type'] contains required_type.
|
||||||
|
gi = group index, oi = option index inside that group.
|
||||||
|
"""
|
||||||
|
cands = []
|
||||||
|
for gi, group in enumerate(input_measures):
|
||||||
|
for oi, opt in enumerate(group):
|
||||||
|
if type_matches(opt["type"], required_type):
|
||||||
|
cands.append((gi, oi, opt))
|
||||||
|
return cands
|
||||||
|
|
||||||
|
|
||||||
|
def iter_or_candidates(input_measures, types_list):
|
||||||
|
"""
|
||||||
|
For OR: pick exactly ONE candidate whose type matches ANY in types_list.
|
||||||
|
Return a list of dicts: {"fixed": [(gi, oi, opt)]}
|
||||||
|
"""
|
||||||
|
union = []
|
||||||
|
seen_ids = set()
|
||||||
|
for t in types_list:
|
||||||
|
for tup in candidates_for_type(input_measures, t):
|
||||||
|
# de-dupe by the option id so the same physical option (with multi-type name) isn’t repeated
|
||||||
|
if tup[2]["id"] not in seen_ids:
|
||||||
|
seen_ids.add(tup[2]["id"])
|
||||||
|
union.append(tup)
|
||||||
|
return [{"fixed": [t]} for t in union]
|
||||||
|
|
||||||
|
|
||||||
|
def iter_and_candidates(input_measures, types_list):
|
||||||
|
"""
|
||||||
|
For AND: we must cover ALL required types.
|
||||||
|
We allow a single option to satisfy multiple types.
|
||||||
|
We build a simple product but collapse duplicates by (gi, oi).
|
||||||
|
"""
|
||||||
|
# Build candidate pools per required type
|
||||||
|
pools = [candidates_for_type(input_measures, t) for t in types_list]
|
||||||
|
if any(len(pool) == 0 for pool in pools):
|
||||||
|
return [] # impossible to satisfy AND
|
||||||
|
|
||||||
|
# Start with one empty selection; accumulate per pool
|
||||||
|
selections = [[]] # each selection is a list of (gi, oi, opt)
|
||||||
|
for pool in pools:
|
||||||
|
new_selections = []
|
||||||
|
for sel in selections:
|
||||||
|
for cand in pool:
|
||||||
|
# Try adding cand; collapse duplicates by (gi,oi)
|
||||||
|
gi, oi, opt = cand
|
||||||
|
replaced = False
|
||||||
|
conflict = False
|
||||||
|
merged = []
|
||||||
|
for (sgi, soi, sopt) in sel:
|
||||||
|
if (sgi, soi) == (gi, oi):
|
||||||
|
# same exact option already in selection (satisfies another required type) – keep one
|
||||||
|
replaced = True
|
||||||
|
# keep the existing one (identical)
|
||||||
|
merged.append((sgi, soi, sopt))
|
||||||
|
else:
|
||||||
|
merged.append((sgi, soi, sopt))
|
||||||
|
if not replaced:
|
||||||
|
merged.append(cand)
|
||||||
|
if not conflict:
|
||||||
|
new_selections.append(merged)
|
||||||
|
selections = new_selections
|
||||||
|
if not selections:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# After accumulation, we may still have duplicate groups with different options (conflict). Drop those.
|
||||||
|
cleaned = []
|
||||||
|
for sel in selections:
|
||||||
|
seen_by_group = {}
|
||||||
|
ok = True
|
||||||
|
for gi, oi, opt in sel:
|
||||||
|
if gi in seen_by_group and seen_by_group[gi] != oi:
|
||||||
|
# same group, different option -> conflict for AND; invalid selection
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
seen_by_group[gi] = oi
|
||||||
|
if ok:
|
||||||
|
# ensure stable order and unique by (gi,oi)
|
||||||
|
uniq = {}
|
||||||
|
for gi, oi, opt in sel:
|
||||||
|
uniq[(gi, oi)] = opt
|
||||||
|
cleaned.append([(gi, oi, opt) for (gi, oi), opt in uniq.items()])
|
||||||
|
return [{"fixed": c} for c in cleaned]
|
||||||
|
|
||||||
|
|
||||||
|
def expand_funding_path(input_measures, path_spec):
|
||||||
|
"""
|
||||||
|
path_spec is a list of elements; each element is either:
|
||||||
|
{"OR": [type1, type2, ...], "reference": "..."} or
|
||||||
|
{"AND": [type1, type2, ...], "reference": "..."}
|
||||||
|
We cross-product across elements (all required), and produce selections as lists of (gi, oi, opt).
|
||||||
|
"""
|
||||||
|
selections = [[]] # list[list[(gi,oi,opt)]]
|
||||||
|
for elem in path_spec:
|
||||||
|
if "OR" in elem:
|
||||||
|
cands = iter_or_candidates(input_measures, elem["OR"])
|
||||||
|
elif "AND" in elem:
|
||||||
|
cands = iter_and_candidates(input_measures, elem["AND"])
|
||||||
|
else:
|
||||||
|
raise ValueError("unknown path element; expected 'OR' or 'AND'")
|
||||||
|
|
||||||
|
if not cands:
|
||||||
|
return []
|
||||||
|
|
||||||
|
new_selections = []
|
||||||
|
for base in selections:
|
||||||
|
for cand in cands:
|
||||||
|
# merge base + cand["fixed"], collapsing duplicate same-option picks
|
||||||
|
combined = list(base)
|
||||||
|
# reject if combined picks two different options from the same group
|
||||||
|
groups_to_oi = {(gi,): oi for gi, oi, _ in combined} # temporary; we’ll refactor below
|
||||||
|
conflict = False
|
||||||
|
# simpler: build a dict by group -> (oi, opt), conflict if group exists with different oi
|
||||||
|
gmap = {gi: (oi, opt) for gi, oi, opt in combined}
|
||||||
|
for gi, oi, opt in cand["fixed"]:
|
||||||
|
if gi in gmap:
|
||||||
|
prev_oi, _ = gmap[gi]
|
||||||
|
if prev_oi != oi:
|
||||||
|
conflict = True
|
||||||
|
break
|
||||||
|
gmap[gi] = (oi, opt)
|
||||||
|
if conflict:
|
||||||
|
continue
|
||||||
|
# back to list
|
||||||
|
merged = [(gi, oi, opt) for gi, (oi, opt) in gmap.items()]
|
||||||
|
new_selections.append(merged)
|
||||||
|
selections = new_selections
|
||||||
|
if not selections:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Final tidy: ensure no duplicate groups with different options (already protected), keep stable ordering
|
||||||
|
deduped = []
|
||||||
|
for sel in selections:
|
||||||
|
gmap = {}
|
||||||
|
for gi, oi, opt in sel:
|
||||||
|
# keep the first occurrence
|
||||||
|
if gi not in gmap:
|
||||||
|
gmap[gi] = (oi, opt)
|
||||||
|
else:
|
||||||
|
# same group, different oi would have been filtered; if same oi, ignore duplicate
|
||||||
|
pass
|
||||||
|
deduped.append([(gi, oi, opt) for gi, (oi, opt) in gmap.items()])
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
# ---- tiny utilities ----------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_types(t):
|
||||||
|
# e.g. "external_wall_insulation+mechanical_ventilation" -> {"external_wall_insulation","mechanical_ventilation"}
|
||||||
|
return set(map(str.strip, t.split("+"))) if isinstance(t, str) else set()
|
||||||
|
|
||||||
|
|
||||||
|
def includes_heating(opt_types):
|
||||||
|
return any(x in opt_types for x in {
|
||||||
|
"air_source_heat_pump",
|
||||||
|
"high_heat_retention_storage_heater",
|
||||||
|
"time_temperature_zone_control", # controls count as a heating measure in your pipeline
|
||||||
|
"solar_pv" # you treat PV as heating for funding logic
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def contributes_min_insulation(opt_types):
|
||||||
|
# MIR satisfiers you mentioned (extend as needed)
|
||||||
|
return any(x in opt_types for x in {
|
||||||
|
"external_wall_insulation",
|
||||||
|
"internal_wall_insulation",
|
||||||
|
"loft_insulation",
|
||||||
|
"cavity_wall_insulation",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def run_optimizer(input_measures, budget=None, sub_target_gain=None, allow_slack=False):
|
||||||
|
"""
|
||||||
|
Thin wrapper over your optimisers.
|
||||||
|
Returns: list[dict] selected_options
|
||||||
|
"""
|
||||||
|
if budget is not None:
|
||||||
|
opt = GainOptimiser(
|
||||||
|
input_measures, max_cost=budget, max_gain=(sub_target_gain or float("inf")),
|
||||||
|
allow_slack=allow_slack
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if sub_target_gain is None:
|
||||||
|
raise ValueError("Either budget or target_gain must be provided.")
|
||||||
|
opt = CostOptimiser(input_measures, min_gain=sub_target_gain)
|
||||||
|
|
||||||
|
opt.setup()
|
||||||
|
opt.solve()
|
||||||
|
cost = sum([x["cost"] for x in opt.solution])
|
||||||
|
return opt.solution, cost, opt.solution_gain
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Define optimisation paths ----------------------------------------------------------
|
||||||
|
|
||||||
|
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_solar_heating_funding_paths(
|
||||||
|
p, input_measures, funding_paths, remaining_insulation_type, housing_type, funding: Funding
|
||||||
|
):
|
||||||
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
# Solar PV with existing eligible heating system
|
||||||
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
has_eligible_heating_system = funding.check_solar_eligible_heating_system(
|
||||||
|
mainheat_description=p.main_heating["clean_description"],
|
||||||
|
heating_control_description=p.main_heating_controls["clean_description"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_eligible_heating_system and _find_measure(input_measures, "solar_pv"):
|
||||||
|
single_solar_template = [{"AND": ["solar_pv"], "reference": None}]
|
||||||
|
# We now look to pair this with any lingering insulation measures
|
||||||
|
solar_paths = []
|
||||||
|
for insulation_measure in remaining_insulation_type:
|
||||||
|
new_solar_path = deepcopy(single_solar_template)
|
||||||
|
new_solar_path[0]["AND"].append(insulation_measure)
|
||||||
|
# Make a specific reference for this path
|
||||||
|
new_solar_path[0]["reference"] = "solar_pv+" + insulation_measure + ":eco4"
|
||||||
|
solar_paths.append(new_solar_path)
|
||||||
|
|
||||||
|
if solar_paths:
|
||||||
|
funding_paths.extend(solar_paths)
|
||||||
|
else:
|
||||||
|
# If we have no insulation measures, we just add the solar PV path
|
||||||
|
funding_paths.append([{"AND": ["solar_pv"], "reference": "solar_pv:eco4"}])
|
||||||
|
|
||||||
|
# For each of these, because there is a heating measure begin implemented, we check for minimum insulation
|
||||||
|
# requirements.
|
||||||
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
# Solar PV + Heating Upgrade combos
|
||||||
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
# We don't include electric boilers as they are not eligible for ECO4 funding
|
||||||
|
solar_heating_combos = [
|
||||||
|
("high_heat_retention_storage_heater", "solar_pv+hhrsh:eco4"),
|
||||||
|
("air_source_heat_pump", "solar_pv+ashp:eco4"),
|
||||||
|
]
|
||||||
|
if _find_measure(input_measures, "solar_pv"):
|
||||||
|
for heat_type, ref in solar_heating_combos:
|
||||||
|
if _find_measure(input_measures, heat_type):
|
||||||
|
if remaining_insulation_type:
|
||||||
|
for insulation_measure in remaining_insulation_type:
|
||||||
|
funding_paths.append(
|
||||||
|
[{"AND": ["solar_pv", heat_type, insulation_measure],
|
||||||
|
"reference": f"{ref[:-5]}+{insulation_measure}:eco4"}] # keeps naming consistent
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
funding_paths.append([{"AND": ["solar_pv", heat_type], "reference": ref}])
|
||||||
|
|
||||||
|
# We've actually covered all possible options where solar PV can be included in a funded package, so where
|
||||||
|
# solar PV is not in a reference, we can exclude it
|
||||||
|
|
||||||
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
# Heating Upgrades
|
||||||
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
# Must have an existing eligible heating system
|
||||||
|
|
||||||
|
# For private, HHRSH alone, or a boiler upgrade is NOT eligible for ECO4 funding. Boiler upgrade also doesn't
|
||||||
|
# count as an eligible heating system
|
||||||
|
if housing_type == "Private":
|
||||||
|
single_heating_measures = ["air_source_heat_pump"]
|
||||||
|
else:
|
||||||
|
single_heating_measures = [
|
||||||
|
"boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump"
|
||||||
|
]
|
||||||
|
measure_references = {
|
||||||
|
"boiler_upgrade": "boiler_upgrade",
|
||||||
|
"high_heat_retention_storage_heater": "hhrsh",
|
||||||
|
"air_source_heat_pump": "ashp"
|
||||||
|
}
|
||||||
|
for heating_upgrade in single_heating_measures:
|
||||||
|
if _find_measure(input_measures, heating_upgrade):
|
||||||
|
if remaining_insulation_type:
|
||||||
|
for insulation_measure in remaining_insulation_type:
|
||||||
|
path = [
|
||||||
|
{
|
||||||
|
"AND": [heating_upgrade, insulation_measure],
|
||||||
|
"reference": f"{measure_references[heating_upgrade]}+{insulation_measure}:eco4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
funding_paths.append(path)
|
||||||
|
else:
|
||||||
|
funding_paths.append(
|
||||||
|
[{"AND": [heating_upgrade], "reference": f"{measure_references[heating_upgrade]}:eco4"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
return funding_paths
|
||||||
|
|
||||||
|
|
||||||
|
def _make_generic_gbis_funding_paths(input_gbis_measures, funding_paths):
|
||||||
|
"""
|
||||||
|
For GBIS, the packages are single insulation measure.
|
||||||
|
|
||||||
|
We also have potential GBIS packages that allow heating controls as a secondary measure, however this
|
||||||
|
is not currently implemented in the optimiser due to not being certain about the heating controls pre conditions
|
||||||
|
:param input_gbis_measures:
|
||||||
|
:param funding_paths:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
|
gbis_funding_paths = []
|
||||||
|
for input_measure in input_gbis_measures:
|
||||||
|
for measure in input_measure:
|
||||||
|
# We create a path for each measure
|
||||||
|
gbis_funding_paths.append([{"AND": [measure["type"]], "reference": measure["type"] + ":gbis"}])
|
||||||
|
|
||||||
|
return funding_paths + gbis_funding_paths
|
||||||
|
|
||||||
|
|
||||||
|
def make_funding_paths(p, input_measures, housing_type, funding: Funding):
|
||||||
|
"""
|
||||||
|
This function generates funding paths based on the input measures and the tenure of the property.
|
||||||
|
It checks for the presence of specific measures and creates paths that include necessary insulation measures
|
||||||
|
to meet minimum insulation requirements, particularly when a heating system is recommended.
|
||||||
|
|
||||||
|
Remaining measures that are not fixed as part of the package are then optimised
|
||||||
|
:param p: The property object containing details about the property, including main heating and controls.
|
||||||
|
:param input_measures:
|
||||||
|
:param housing_type:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
# We handle the case of minimum insulation requirements. Whenever we have a heating system recommendation,
|
||||||
|
# we *must* include an additional insulation measure, unless the property already has sufficient insulation.
|
||||||
|
|
||||||
|
# We determine which insulation measures need to be included
|
||||||
|
wall_insulation_measures = [
|
||||||
|
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
||||||
|
"extension_cavity_wall_insulation"
|
||||||
|
]
|
||||||
|
roof_insulation_measures = [
|
||||||
|
"loft_insulation", "flat_roof_insulation", "room_roof_insulation"
|
||||||
|
]
|
||||||
|
other_gbis_insulation_measures = [
|
||||||
|
"suspended_floor_insulation", "solid_floor_insulation",
|
||||||
|
]
|
||||||
|
# These are the insulation measures that the property still needs and so will be considered for
|
||||||
|
# filling the minimum insulation requirements
|
||||||
|
remaining_insulation_type = []
|
||||||
|
for insulation_measure in wall_insulation_measures + roof_insulation_measures:
|
||||||
|
if _find_measure(input_measures, insulation_measure):
|
||||||
|
remaining_insulation_type.append(insulation_measure)
|
||||||
|
|
||||||
|
remaining_insulation_type = list(set(remaining_insulation_type))
|
||||||
|
|
||||||
|
funding_paths = []
|
||||||
|
|
||||||
|
if housing_type == "Social" and p.data["current-energy-rating"] == "D":
|
||||||
|
# If the property is currently EPC D, we can only include innovation measures or measures to meet the
|
||||||
|
# minimum insulation requirements
|
||||||
|
input_measures_innovation = []
|
||||||
|
input_gbis_measures_innovation = []
|
||||||
|
for measures in input_measures:
|
||||||
|
for measure in measures:
|
||||||
|
if measure["innovation_uplift"] or measure["type"] in remaining_insulation_type:
|
||||||
|
input_measures_innovation.append([measure])
|
||||||
|
|
||||||
|
if measure["innovation_uplift"] and measure["type"] in (
|
||||||
|
remaining_insulation_type + other_gbis_insulation_measures
|
||||||
|
):
|
||||||
|
input_gbis_measures_innovation.append([measure])
|
||||||
|
|
||||||
|
funding_paths = _make_solar_heating_funding_paths(
|
||||||
|
p, input_measures_innovation, funding_paths, remaining_insulation_type, housing_type, funding
|
||||||
|
)
|
||||||
|
|
||||||
|
# Can only be innovation GBIS measures
|
||||||
|
funding_paths = _make_generic_gbis_funding_paths(input_gbis_measures_innovation, funding_paths)
|
||||||
|
return funding_paths, input_measures_innovation
|
||||||
|
|
||||||
|
if housing_type == "Private":
|
||||||
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
# EWI or IWI
|
||||||
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
# 1) The package must include EWI or IWI if the property is private rental sector
|
||||||
|
# We check if we have any EWI or IWI measures available
|
||||||
|
ewi_or_iwi = [{"OR": []}]
|
||||||
|
reference_measures = []
|
||||||
|
# If we have EWI we add it in
|
||||||
|
if _find_measure(input_measures, "external_wall_insulation"):
|
||||||
|
ewi_or_iwi[0]["OR"].append("external_wall_insulation")
|
||||||
|
reference_measures.append("ewi")
|
||||||
|
|
||||||
|
if _find_measure(input_measures, "internal_wall_insulation"):
|
||||||
|
ewi_or_iwi[0]["OR"].append("internal_wall_insulation")
|
||||||
|
reference_measures.append("iwi")
|
||||||
|
|
||||||
|
if ewi_or_iwi[0]["OR"]:
|
||||||
|
ewi_or_iwi[0]["reference"] = "+".join(reference_measures) + ":eco4"
|
||||||
|
funding_paths.append(ewi_or_iwi)
|
||||||
|
|
||||||
|
funding_paths = _make_solar_heating_funding_paths(
|
||||||
|
p, input_measures, funding_paths, remaining_insulation_type, housing_type, funding
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we have any remaining insulation measures, we add them to the funding paths
|
||||||
|
input_gbis_measures = []
|
||||||
|
for measures in input_measures:
|
||||||
|
for measure in measures:
|
||||||
|
type_to_check = measure["type"].split("+")[0] if "+" in measure["type"] else measure["type"]
|
||||||
|
if type_to_check in remaining_insulation_type + other_gbis_insulation_measures:
|
||||||
|
input_gbis_measures.append([measure])
|
||||||
|
|
||||||
|
funding_paths = _make_generic_gbis_funding_paths(input_gbis_measures, funding_paths)
|
||||||
|
|
||||||
|
return funding_paths, input_measures
|
||||||
|
|
@ -6,7 +6,7 @@ from backend.app.utils import epc_to_sap_lower_bound
|
||||||
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
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.
|
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".
|
Optimisation goal, one of: "Increasing EPC", "Energy Savings", "Reducing CO2 emissions".
|
||||||
needs_ventilation : bool
|
needs_ventilation : bool
|
||||||
Whether the property requires mechanical ventilation to accompany certain measures.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -75,24 +78,49 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation):
|
||||||
# Build enriched measure data
|
# Build enriched measure data
|
||||||
to_append = []
|
to_append = []
|
||||||
for rec in recs:
|
for rec in recs:
|
||||||
total = (
|
|
||||||
rec["total"] + ventilation_recommendation["total"]
|
raw_cost = rec["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"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the innovation uplift being removed make this negative, we keep the total so we can re-engineer
|
||||||
|
# the original cost
|
||||||
|
non_negative_total = 0 if total < 0 else total
|
||||||
gain = (
|
gain = (
|
||||||
rec[goal_key] + ventilation_recommendation[goal_key]
|
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]
|
else rec[goal_key]
|
||||||
)
|
)
|
||||||
rec_type = (
|
rec_type = (
|
||||||
f"{rec['type']}+{ventilation_recommendation['type']}"
|
f"{rec['measure_type']}+{ventilation_recommendation['measure_type']}"
|
||||||
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["type"]
|
else rec["measure_type"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# We also include the innovation uplift
|
||||||
to_append.append(
|
to_append.append(
|
||||||
{"id": rec["recommendation_id"], "cost": total, "gain": gain, "type": rec_type}
|
{
|
||||||
|
"id": rec["recommendation_id"],
|
||||||
|
"cost": non_negative_total,
|
||||||
|
"gain": gain, "type": rec_type,
|
||||||
|
"innovation_uplift": rec["innovation_uplift"] if funding else 0,
|
||||||
|
"cost_minus_uplift": total,
|
||||||
|
"raw_cost": raw_cost,
|
||||||
|
"partial_project_funding": rec["partial_project_funding"],
|
||||||
|
"partial_project_score": rec["partial_project_score"],
|
||||||
|
"uplift_project_score": rec["uplift_project_score"],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
input_measures.append(to_append)
|
input_measures.append(to_append)
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@ class TestHeatingRecommendations:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# We patch an old version of cleaned which is missing some attributes for 'mainheat-description'
|
||||||
|
for x in cleaned['mainheat-description']:
|
||||||
|
x["has_hot-water-only"] = False
|
||||||
|
x["has_mineral_and_wood"] = False
|
||||||
|
x["has_dual_fuel_appliance"] = False
|
||||||
|
|
||||||
epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []}
|
epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []}
|
||||||
|
|
||||||
epc_record = EPCRecord(
|
epc_record = EPCRecord(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class TestPrepareInputMeasures:
|
||||||
recs = [
|
recs = [
|
||||||
[ # loft insulation measure
|
[ # loft insulation measure
|
||||||
{"recommendation_id": "loft1", "type": "loft_insulation", "total": 100, "kwh_savings": 200,
|
{"recommendation_id": "loft1", "type": "loft_insulation", "total": 100, "kwh_savings": 200,
|
||||||
"energy_cost_savings": 10, "has_battery": False},
|
"energy_cost_savings": 10, "has_battery": False, "measure_type": "loft_insulation"},
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=False)
|
measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=False)
|
||||||
|
|
@ -27,9 +27,9 @@ class TestPrepareInputMeasures:
|
||||||
["internal_wall_insulation"])
|
["internal_wall_insulation"])
|
||||||
recs = [
|
recs = [
|
||||||
[{"recommendation_id": "wall1", "type": "internal_wall_insulation", "total": 500, "kwh_savings": 300,
|
[{"recommendation_id": "wall1", "type": "internal_wall_insulation", "total": 500, "kwh_savings": 300,
|
||||||
"energy_cost_savings": 5, "has_battery": False}],
|
"energy_cost_savings": 5, "has_battery": False, "measure_type": "internal_wall_insulation"}],
|
||||||
[{"recommendation_id": "vent1", "type": "mechanical_ventilation", "total": 50, "kwh_savings": 30,
|
[{"recommendation_id": "vent1", "type": "mechanical_ventilation", "total": 50, "kwh_savings": 30,
|
||||||
"energy_cost_savings": 5, "has_battery": False}]
|
"energy_cost_savings": 5, "has_battery": False, "measure_type": "mechanical_ventilation"}],
|
||||||
]
|
]
|
||||||
measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=True)
|
measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=True)
|
||||||
wall_option = measures[0][0]
|
wall_option = measures[0][0]
|
||||||
|
|
|
||||||
665
recommendations/tests/test_optimisers.py
Normal file
665
recommendations/tests/test_optimisers.py
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
import numpy as np
|
||||||
|
# import pandas as pd
|
||||||
|
from pandas import Timestamp
|
||||||
|
from numpy import nan
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
# import backend.app.assumptions as assumptions
|
||||||
|
# import recommendations.optimiser.optimiser_functions as optimiser_functions
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# # Insert the funding uplifts
|
||||||
|
# for recs in property_recommendations:
|
||||||
|
# for r in recs:
|
||||||
|
# # Insert randomly
|
||||||
|
# # Select one of 0, 0.25 or 0.45
|
||||||
|
# r["uplift"] = np.random.choice([0, 0.25, 0.45])
|
||||||
|
#
|
||||||
|
# # We calculate the innovation uplift against each measure
|
||||||
|
# for recs in property_recommendations:
|
||||||
|
# for r in recs:
|
||||||
|
# if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]:
|
||||||
|
# r["innovation_uplift"] = 0
|
||||||
|
# continue
|
||||||
|
# r["innovation_uplift"] = funding.get_innovation_uplift(
|
||||||
|
# measure=r,
|
||||||
|
# starting_sap=p.data["current-energy-efficiency"],
|
||||||
|
# floor_area=p.floor_area,
|
||||||
|
# is_cavity=False,
|
||||||
|
# current_wall_uvalue=1.7,
|
||||||
|
# is_partial=False,
|
||||||
|
# existing_li_thickness=150,
|
||||||
|
# mainheating=p.main_heating,
|
||||||
|
# main_fuel=p.main_fuel,
|
||||||
|
# mainheat_energy_eff=p.data["mainheat-energy-eff"],
|
||||||
|
# )
|
||||||
|
# print(r["innovation_uplift"])
|
||||||
|
#
|
||||||
|
# property_measure_types = {rec["type"] for recs in property_recommendations for rec in recs}
|
||||||
|
# property_required_measures = [m for m in property_recommendations if m[0]["type"] in []]
|
||||||
|
# measures_to_optimise = [m for m in property_recommendations if m[0]["type"] not in []]
|
||||||
|
#
|
||||||
|
# # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore
|
||||||
|
# # its inclusion
|
||||||
|
# needs_ventilation = any(
|
||||||
|
# x in property_measure_types for x in assumptions.measures_needing_ventilation
|
||||||
|
# ) and not p.has_ventilation
|
||||||
|
#
|
||||||
|
# input_measures = optimiser_functions.prepare_input_measures(
|
||||||
|
# measures_to_optimise, "Increasing EPC", needs_ventilation, True
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# # ---- main wrapper around your optimiser ----------------------------------
|
||||||
|
#
|
||||||
|
# # Run inputs:
|
||||||
|
# target_gain = 18.5
|
||||||
|
#
|
||||||
|
# # Run the optimiser with these inouts
|
||||||
|
|
||||||
|
|
||||||
|
# tests/test_social_fabric_only.py
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from recommendations.optimiser import optimiser_functions
|
||||||
|
from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths # wherever you defined it
|
||||||
|
from backend.Funding import Funding
|
||||||
|
from backend.app.plan.schemas import WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES
|
||||||
|
|
||||||
|
ALLOWED_FABRIC_TYPES = set(WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_project_scores_matrix():
|
||||||
|
data = []
|
||||||
|
floor_segments = ["0-72", "73-97", "98-199", "200"]
|
||||||
|
bands = [
|
||||||
|
"Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C", "Low_B",
|
||||||
|
"High_B", "Low_A", "High_A"
|
||||||
|
]
|
||||||
|
|
||||||
|
cost = 50.0
|
||||||
|
for floor in floor_segments:
|
||||||
|
for start in bands:
|
||||||
|
for finish in bands:
|
||||||
|
if start != finish: # skip identical start/finish (no SAP movement)
|
||||||
|
data.append({
|
||||||
|
"Floor Area Segment": floor,
|
||||||
|
"Starting Band": start,
|
||||||
|
"Finishing Band": finish,
|
||||||
|
"Cost Savings": cost
|
||||||
|
})
|
||||||
|
cost += 5.0 # increment to create variety
|
||||||
|
|
||||||
|
return pd.DataFrame(data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_partial_scores_matrix():
|
||||||
|
df = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv")
|
||||||
|
df.columns = ['Measure category', 'Measure_Type', 'Pre_Main_Heating_Source',
|
||||||
|
'Post_Main_Heating_Source', 'Total Floor Area Band', 'Starting Band',
|
||||||
|
'Average Treatable Factor', 'Cost Savings', 'SAP Savings']
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class DummyProp:
|
||||||
|
"""Minimal property stub exposing just what your code reads."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.data = {
|
||||||
|
"current-energy-rating": "E", # or "D" for the special Social+D path
|
||||||
|
"current-energy-efficiency": 55, # numeric SAP points used in eligibility calc
|
||||||
|
"mainheat-energy-eff": "Very Good",
|
||||||
|
}
|
||||||
|
self.has_ventilation = False
|
||||||
|
self.floor_area = 70.0
|
||||||
|
self.main_heating_controls = {"clean_description": "time and temperature zone control"}
|
||||||
|
|
||||||
|
self.main_heating = {
|
||||||
|
'original_description': 'Boiler and radiators, mains gas',
|
||||||
|
'clean_description': 'Boiler and radiators, mains gas',
|
||||||
|
'has_radiators': True, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False,
|
||||||
|
'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': True,
|
||||||
|
'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False,
|
||||||
|
'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False,
|
||||||
|
'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False,
|
||||||
|
'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, 'has_electric_heat_pump':
|
||||||
|
False,
|
||||||
|
'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, 'has_exhaust_source_heat_pump':
|
||||||
|
False,
|
||||||
|
'has_community_heat_pump': False, 'has_hot-water-only': False, 'has_electric': False, 'has_mains_gas':
|
||||||
|
True,
|
||||||
|
'has_wood_logs': False, 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False,
|
||||||
|
'has_anthracite': False,
|
||||||
|
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
|
||||||
|
'has_mineral_and_wood': False, 'has_dual_fuel_appliance': False, 'has_assumed': False,
|
||||||
|
'has_electricaire': False,
|
||||||
|
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False
|
||||||
|
}
|
||||||
|
|
||||||
|
self.main_fuel = {
|
||||||
|
'original_description': 'mains gas (not community)', 'clean_description': 'Mains gas not community',
|
||||||
|
'fuel_type': 'mains gas', 'tariff_type': None, 'is_community': False,
|
||||||
|
'no_individual_heating_or_community_network': False, 'complex_fuel_type': None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def p():
|
||||||
|
return DummyProp()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def funding(monkeypatch, mock_partial_scores_matrix, mock_project_scores_matrix):
|
||||||
|
"""Simple Funding that returns zero uplift so costs stay as provided."""
|
||||||
|
# Build the Funding with tiny in-memory frames (avoid test I/O)
|
||||||
|
|
||||||
|
f = Funding(
|
||||||
|
project_scores_matrix=mock_project_scores_matrix,
|
||||||
|
partial_project_scores_matrix=mock_partial_scores_matrix,
|
||||||
|
whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]),
|
||||||
|
eco4_social_cavity_abs_rate=13.5, eco4_social_solid_abs_rate=17,
|
||||||
|
eco4_private_cavity_abs_rate=13.5, eco4_private_solid_abs_rate=17,
|
||||||
|
gbis_social_cavity_abs_rate=21, gbis_social_solid_abs_rate=25,
|
||||||
|
gbis_private_cavity_abs_rate=22, gbis_private_solid_abs_rate=28,
|
||||||
|
tenure="Social"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep innovation_uplift simple for the first test
|
||||||
|
# monkeypatch.setattr(f, "get_innovation_uplift", lambda *args, **kwargs: 0.0)
|
||||||
|
|
||||||
|
# If your solar precondition matters, you can force True/False here:
|
||||||
|
# monkeypatch.setattr(
|
||||||
|
# __import__("backend").Funding, "check_solar_eligible_heating_system",
|
||||||
|
# staticmethod(lambda mainheat_description, heating_control_description: False)
|
||||||
|
# )
|
||||||
|
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def property_recommendations():
|
||||||
|
"""Short sample; replace with your full block if you want."""
|
||||||
|
recs = [
|
||||||
|
[{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation',
|
||||||
|
'description': 'EWI Pro EPS external wall insulation system with '
|
||||||
|
'Brick Slip finish',
|
||||||
|
'depth': 150.0, 'depth_unit': 'mm', 'cost': None,
|
||||||
|
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
|
||||||
|
'r_value_unit': 'square_meter_kelvin_per_watt',
|
||||||
|
'thermal_conductivity': 0.038,
|
||||||
|
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
|
||||||
|
'link': 'SCIS',
|
||||||
|
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
|
||||||
|
'is_active': True, 'prime_material_cost': None,
|
||||||
|
'material_cost': 0.0, 'labour_cost': 0.0,
|
||||||
|
'labour_hours_per_unit': 0.0, 'plant_cost': 0.0,
|
||||||
|
'total_cost': 298.35,
|
||||||
|
'notes': 'This is the quoted value from SCIS',
|
||||||
|
'is_installer_quote': True, 'quantity': 63.98796761892035,
|
||||||
|
'quantity_unit': 'm2', 'total': 19090.810139104888,
|
||||||
|
'labour_hours': 0.0, 'labour_days': 0.0}],
|
||||||
|
'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation',
|
||||||
|
'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)}]
|
||||||
|
]
|
||||||
|
return recs
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_costs_and_uplifts(recs, funding, p):
|
||||||
|
"""Mimic what your script did: add cost fields & innovation uplift."""
|
||||||
|
out = deepcopy(recs)
|
||||||
|
for group in out:
|
||||||
|
for r in group:
|
||||||
|
if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]:
|
||||||
|
r["innovation_uplift"] = 0
|
||||||
|
continue
|
||||||
|
r["uplift"] = 0.0 # fixed for determinism in test
|
||||||
|
r["innovation_uplift"] = funding.get_innovation_uplift(
|
||||||
|
measure=r,
|
||||||
|
starting_sap=55,
|
||||||
|
floor_area=70.0,
|
||||||
|
is_cavity=False,
|
||||||
|
current_wall_uvalue=1.7,
|
||||||
|
is_partial=False,
|
||||||
|
existing_li_thickness=150,
|
||||||
|
mainheating=p.main_heating,
|
||||||
|
main_fuel=p.main_fuel,
|
||||||
|
mainheat_energy_eff="Very Good",
|
||||||
|
)
|
||||||
|
# the optimiser_functions.prepare_input_measures will translate these to input format; but
|
||||||
|
# for safety add explicit cost fields some downstream code expects:
|
||||||
|
r["total"] = float(r["total"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _to_input_measures(recs, p):
|
||||||
|
"""Use your own helper so we test the full pipeline."""
|
||||||
|
property_measure_types = {rec["type"] for grp in recs for rec in grp}
|
||||||
|
needs_ventilation = any(
|
||||||
|
x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation
|
||||||
|
) and not getattr(p, "has_ventilation", False)
|
||||||
|
|
||||||
|
# goal="Increasing EPC", add_uplift=True for Social path
|
||||||
|
return optimiser_functions.prepare_input_measures(
|
||||||
|
recs, goal="Increasing EPC", needs_ventilation=needs_ventilation, funding=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _types_of(picked_items):
|
||||||
|
return {item["type"] for item in picked_items}
|
||||||
|
|
||||||
|
|
||||||
|
def test_social_fabric_only_returns_only_fabric_types(p, funding, property_recommendations, monkeypatch):
|
||||||
|
# 1) prepare data like your script
|
||||||
|
recs = _attach_costs_and_uplifts(property_recommendations, funding, p)
|
||||||
|
input_measures = _to_input_measures(recs, p)
|
||||||
|
|
||||||
|
# 2) run optimiser wrapper (budget and target_gain can be modest for the test)
|
||||||
|
budget = 30000.0
|
||||||
|
target_gain = 8.0
|
||||||
|
|
||||||
|
solutions = optimise_with_funding_paths(
|
||||||
|
p=p,
|
||||||
|
input_measures=input_measures,
|
||||||
|
housing_type="Social",
|
||||||
|
budget=budget,
|
||||||
|
target_gain=target_gain,
|
||||||
|
funding=funding
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) basic shape assertions
|
||||||
|
assert isinstance(solutions, pd.DataFrame)
|
||||||
|
assert not solutions.empty
|
||||||
|
|
||||||
|
# 4) find the fabric-only ECO4 row
|
||||||
|
fabric_rows = solutions[
|
||||||
|
solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "fabric-only:eco4")]
|
||||||
|
assert not fabric_rows.empty, "Expected a fabric-only:eco4 solution for Social tenure"
|
||||||
|
|
||||||
|
# 5) ensure only fabric measure types are present in that solution
|
||||||
|
picked_types = _types_of(fabric_rows.iloc[0]["items"])
|
||||||
|
assert picked_types == {'internal_wall_insulation+mechanical_ventilation',
|
||||||
|
'suspended_floor_insulation'}, "incorrect types selected"
|
||||||
|
|
||||||
|
# 6) respect budget
|
||||||
|
assert fabric_rows.iloc[0]["total_cost"] <= budget + 1e-9
|
||||||
|
|
||||||
|
# (optional) ensure unfunded baseline also appears
|
||||||
|
unfunded_rows = solutions[
|
||||||
|
solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "unfunded:all")]
|
||||||
|
assert not unfunded_rows.empty
|
||||||
Loading…
Add table
Reference in a new issue