mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
commit
6c6a44abfe
20 changed files with 1967 additions and 253 deletions
|
|
@ -887,6 +887,9 @@ 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: {
|
||||||
|
|
@ -1099,6 +1102,13 @@ class AssetList:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Estimate the perimeter
|
# Estimate the perimeter
|
||||||
|
# Handle funky edge case
|
||||||
|
self.standardised_asset_list[self.EPC_API_DATA_NAMES["total-floor-area"]] = np.where(
|
||||||
|
(self.standardised_asset_list[self.EPC_API_DATA_NAMES["total-floor-area"]] == 0),
|
||||||
|
self.standardised_asset_list[self.EPC_API_DATA_NAMES["total-floor-area"]].mean(),
|
||||||
|
self.standardised_asset_list[self.EPC_API_DATA_NAMES["total-floor-area"]]
|
||||||
|
)
|
||||||
|
|
||||||
self.standardised_asset_list[self.ATTRIBUTE_ESTIMATED_PERIMETER] = self.standardised_asset_list.apply(
|
self.standardised_asset_list[self.ATTRIBUTE_ESTIMATED_PERIMETER] = self.standardised_asset_list.apply(
|
||||||
lambda x: estimate_perimeter(
|
lambda x: estimate_perimeter(
|
||||||
floor_area=x[self.EPC_API_DATA_NAMES["total-floor-area"]] / x[self.ATTRIBUTE_NUMBER_OF_FLOORS],
|
floor_area=x[self.EPC_API_DATA_NAMES["total-floor-area"]] / x[self.ATTRIBUTE_NUMBER_OF_FLOORS],
|
||||||
|
|
@ -1753,7 +1763,9 @@ class AssetList:
|
||||||
# It's empty cavity
|
# It's empty cavity
|
||||||
self.standardised_asset_list["cavity_is_empty"] |
|
self.standardised_asset_list["cavity_is_empty"] |
|
||||||
# It's a cavity wall
|
# It's a cavity wall
|
||||||
(self.standardised_asset_list[self.STANDARD_WALL_CONSTRUCTION].str.contains("cavity"))
|
self.standardised_asset_list[self.STANDARD_WALL_CONSTRUCTION].isin(
|
||||||
|
["filled cavity", "partial insulated cavity"]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
not_a_flat = (
|
not_a_flat = (
|
||||||
|
|
@ -2097,6 +2109,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"
|
||||||
|
|
||||||
expanded_rows = []
|
expanded_rows = []
|
||||||
|
|
||||||
|
|
@ -2121,11 +2134,12 @@ class AssetList:
|
||||||
|
|
||||||
# 1 ─ Range (e.g. 1-7)
|
# 1 ─ Range (e.g. 1-7)
|
||||||
m_range = RANGE_RE.search(addr)
|
m_range = RANGE_RE.search(addr)
|
||||||
if m_range:
|
to_range = TO_RANGE_RE.search(addr)
|
||||||
|
|
||||||
start, end = m_range.groups()
|
if m_range or to_range:
|
||||||
|
start, end = m_range.groups() if m_range else to_range.groups()
|
||||||
start, end = int(re.match(r'\d+', start)[0]), int(re.match(r'\d+', end)[0])
|
start, end = int(re.match(r'\d+', start)[0]), int(re.match(r'\d+', end)[0])
|
||||||
if start > end or (end - start) > 100:
|
if start > end or (end - start) > 200:
|
||||||
raise ValueError(f"Suspicious range '{addr}'")
|
raise ValueError(f"Suspicious range '{addr}'")
|
||||||
|
|
||||||
# We define the looping range on whether we have odd, even or all numbers
|
# We define the looping range on whether we have odd, even or all numbers
|
||||||
|
|
@ -2137,10 +2151,12 @@ class AssetList:
|
||||||
|
|
||||||
for n in house_number_range:
|
for n in house_number_range:
|
||||||
new = row.copy()
|
new = row.copy()
|
||||||
new_addr = RANGE_RE.sub(str(n), addr, count=1)
|
range_text = m_range.group(0) if m_range else to_range.group(0)
|
||||||
|
new_addr = addr.replace(range_text, str(n))
|
||||||
|
# Build the new full address by also swapping out the range_text
|
||||||
original_full_address = new[self.STANDARD_FULL_ADDRESS]
|
original_full_address = new[self.STANDARD_FULL_ADDRESS]
|
||||||
new_full_address = original_full_address.replace(addr, new_addr)
|
new_full_address = original_full_address.replace(range_text, str(n))
|
||||||
new[self.STANDARD_ADDRESS_1] = new_addr
|
new[self.STANDARD_ADDRESS_1] = str(n)
|
||||||
new[self.STANDARD_FULL_ADDRESS] = new_full_address
|
new[self.STANDARD_FULL_ADDRESS] = new_full_address
|
||||||
new[self.STANDARD_PROPERTY_TYPE] = "flat"
|
new[self.STANDARD_PROPERTY_TYPE] = "flat"
|
||||||
# Keep a record of the previous address 1
|
# Keep a record of the previous address 1
|
||||||
|
|
@ -2155,7 +2171,7 @@ class AssetList:
|
||||||
|
|
||||||
# 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)
|
||||||
nums = NUM_RE.findall(addr)
|
nums = NUM_RE.findall(addr)
|
||||||
if len(nums) > 1 and (',' in addr or '&' in addr):
|
if len(nums) > 1 and (',' in addr or '&' in addr or ' and ' in addr.lower()):
|
||||||
for n in nums:
|
for n in nums:
|
||||||
new = row.copy()
|
new = row.copy()
|
||||||
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
|
||||||
|
|
@ -2174,6 +2190,10 @@ class AssetList:
|
||||||
|
|
||||||
expanded_blocks = pd.DataFrame(expanded_rows)
|
expanded_blocks = pd.DataFrame(expanded_rows)
|
||||||
|
|
||||||
|
# Check for duplicated domna ids
|
||||||
|
if expanded_blocks[self.DOMNA_PROPERTY_ID].duplicated().sum():
|
||||||
|
raise ValueError("expanded blocks has duplicated IDs")
|
||||||
|
|
||||||
# We drop the blocks from the standardised asset list and append on the expanded blocks
|
# We drop the blocks from the standardised asset list and append on the expanded blocks
|
||||||
self.standardised_asset_list = self.standardised_asset_list[
|
self.standardised_asset_list = self.standardised_asset_list[
|
||||||
self.standardised_asset_list[self.STANDARD_PROPERTY_TYPE] != "block of flats"
|
self.standardised_asset_list[self.STANDARD_PROPERTY_TYPE] != "block of flats"
|
||||||
|
|
@ -2318,18 +2338,37 @@ class AssetList:
|
||||||
(~group["cavity_reason"].str.contains("(unlikely to quality)", case=False, na=False, regex=False))
|
(~group["cavity_reason"].str.contains("(unlikely to quality)", case=False, na=False, regex=False))
|
||||||
).sum()
|
).sum()
|
||||||
|
|
||||||
|
n_empties_high_confidence = (
|
||||||
|
(group["identified_empty_cavity"] == True) &
|
||||||
|
(~group["SAP Category"].isin(["SAP Rating 69-75", "SAP Rating 76 or more"])) &
|
||||||
|
(~pd.isnull(group["cavity_reason"])) &
|
||||||
|
(~group["cavity_reason"].str.contains("(unlikely to quality)", case=False, na=False, regex=False))
|
||||||
|
).sum()
|
||||||
|
|
||||||
|
# Average age of the EPCs
|
||||||
|
group["time_since_epc"] = (
|
||||||
|
pd.to_datetime("now") - pd.to_datetime(
|
||||||
|
group[self.EPC_API_DATA_NAMES["inspection-date"]])
|
||||||
|
).dt.days
|
||||||
|
|
||||||
|
average_age_of_epc = group["time_since_epc"].mean()
|
||||||
|
|
||||||
works = group["hubspot_status"]
|
works = group["hubspot_status"]
|
||||||
above_threshold = works.map(LABEL_TO_ENUM.get).dropna()
|
above_threshold = works.map(LABEL_TO_ENUM.get).dropna()
|
||||||
count_above = (above_threshold >= threshold).sum()
|
count_above = (above_threshold >= threshold).sum()
|
||||||
proportion_surveyed = count_above / len(works)
|
proportion_surveyed = count_above / len(works)
|
||||||
proportion_empty = n_empties / len(works)
|
proportion_empty = n_empties / len(works)
|
||||||
|
proportion_empty_high_confidence = n_empties_high_confidence / len(works)
|
||||||
# We auto-populate any blocks that have greater than 50% proportion empty
|
# We auto-populate any blocks that have greater than 50% proportion empty
|
||||||
|
|
||||||
block_analysis.append(
|
block_analysis.append(
|
||||||
{
|
{
|
||||||
"Block Reference": block_reference,
|
"Block Reference": block_reference,
|
||||||
|
"Block Size": len(group),
|
||||||
|
"average_age_of_epc": average_age_of_epc,
|
||||||
"Proportion of properties suryeyed": proportion_surveyed,
|
"Proportion of properties suryeyed": proportion_surveyed,
|
||||||
"Percentage of Empties": proportion_empty,
|
"Percentage of Empties": proportion_empty,
|
||||||
|
"Percentage of Empties (high confidence)": proportion_empty_high_confidence,
|
||||||
**cavity_breakdown.to_dict(),
|
**cavity_breakdown.to_dict(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -3345,6 +3384,8 @@ class AssetList:
|
||||||
property_type_col = "PROPERTY TYPE As per table emailed"
|
property_type_col = "PROPERTY TYPE As per table emailed"
|
||||||
elif "PROPERTY TYPE" in master_data.columns:
|
elif "PROPERTY TYPE" in master_data.columns:
|
||||||
property_type_col = "PROPERTY TYPE"
|
property_type_col = "PROPERTY TYPE"
|
||||||
|
elif 'Property Type' in master_data.columns:
|
||||||
|
property_type_col = 'Property Type'
|
||||||
else:
|
else:
|
||||||
property_type_col = "PROPERTY TYPE (SEE DEEMED SCORES SHEET) Eg. 3W_Flat_1 (As per Matrix)"
|
property_type_col = "PROPERTY TYPE (SEE DEEMED SCORES SHEET) Eg. 3W_Flat_1 (As per Matrix)"
|
||||||
|
|
||||||
|
|
@ -3496,8 +3537,20 @@ class AssetList:
|
||||||
]
|
]
|
||||||
|
|
||||||
if df.shape[0] != 1:
|
if df.shape[0] != 1:
|
||||||
# We have multiple matches
|
# We have multiple matches - it's likely because the landlord has a duplicate
|
||||||
raise NotImplementedError("FIX ME")
|
# that has been referenced in totally different ways so we just match to both
|
||||||
|
for _, x in df.iterrows():
|
||||||
|
matched.append(
|
||||||
|
{
|
||||||
|
"row_id": row["row_id"],
|
||||||
|
"original_house_no": original_house_no,
|
||||||
|
"original_street": original_street,
|
||||||
|
"original_postcode": original_postcode,
|
||||||
|
self.STANDARD_LANDLORD_PROPERTY_ID: x[self.STANDARD_LANDLORD_PROPERTY_ID],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
matched.append(
|
matched.append(
|
||||||
{
|
{
|
||||||
"row_id": row["row_id"],
|
"row_id": row["row_id"],
|
||||||
|
|
@ -3594,6 +3647,10 @@ class AssetList:
|
||||||
self.master_surveyed, how="left", on=self.STANDARD_LANDLORD_PROPERTY_ID
|
self.master_surveyed, how="left", on=self.STANDARD_LANDLORD_PROPERTY_ID
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Make sure no dupes
|
||||||
|
if self.standardised_asset_list[self.DOMNA_PROPERTY_ID].duplicated().sum():
|
||||||
|
raise ValueError("duplicated ids!")
|
||||||
|
|
||||||
# Finally, we keep a record of the unmatched
|
# Finally, we keep a record of the unmatched
|
||||||
if unmatched_submissions:
|
if unmatched_submissions:
|
||||||
self.unmatched_submissions = pd.concat(
|
self.unmatched_submissions = pd.concat(
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,110 @@ def app():
|
||||||
Property UPRN
|
Property UPRN
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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:
|
||||||
|
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Community Housing/New Programme"
|
||||||
|
data_filename = "SUB EPC C to DOMNA - 24.07.25.xlsx"
|
||||||
|
sheet_name = "Sheet1"
|
||||||
|
postcode_column = 'POSTCODE'
|
||||||
|
fulladdress_column = "ADDRESS"
|
||||||
|
address1_column = None
|
||||||
|
address1_method = "house_number_extraction"
|
||||||
|
address_cols_to_concat = []
|
||||||
|
missing_postcodes_method = None
|
||||||
|
landlord_year_built = "BUILD DATE"
|
||||||
|
landlord_os_uprn = None
|
||||||
|
landlord_property_type = "PROPERTY TYPE"
|
||||||
|
landlord_built_form = "Archetype" # Using the inspections archetype
|
||||||
|
landlord_wall_construction = "CONSTRUCTION TYPE"
|
||||||
|
landlord_roof_construction = None
|
||||||
|
landlord_heating_system = None
|
||||||
|
landlord_existing_pv = None
|
||||||
|
landlord_property_id = "UPRN"
|
||||||
|
landlord_sap = None
|
||||||
|
outcomes_filename = []
|
||||||
|
outcomes_sheetname = []
|
||||||
|
outcomes_postcode = []
|
||||||
|
outcomes_houseno = []
|
||||||
|
outcomes_id = []
|
||||||
|
outcomes_address = []
|
||||||
|
master_filepaths = []
|
||||||
|
master_to_asset_list_filepath = None
|
||||||
|
phase = False
|
||||||
|
ecosurv_landlords = None
|
||||||
|
asset_list_header = 1
|
||||||
|
landlord_block_reference = None
|
||||||
|
master_id_colnames = []
|
||||||
|
|
||||||
|
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Ealing/Programme Analysis"
|
||||||
|
data_filename = "EalingProjectRebuildJW210725.xlsx"
|
||||||
|
sheet_name = "Refine & Houses"
|
||||||
|
postcode_column = 'Postcode'
|
||||||
|
fulladdress_column = "Address"
|
||||||
|
address1_column = None
|
||||||
|
address1_method = "house_number_extraction"
|
||||||
|
address_cols_to_concat = []
|
||||||
|
missing_postcodes_method = None
|
||||||
|
landlord_year_built = None
|
||||||
|
landlord_os_uprn = None
|
||||||
|
landlord_property_type = None # Using the inspections property type
|
||||||
|
landlord_built_form = None
|
||||||
|
landlord_wall_construction = None
|
||||||
|
landlord_roof_construction = None
|
||||||
|
landlord_heating_system = None
|
||||||
|
landlord_existing_pv = None
|
||||||
|
landlord_property_id = "Property ref"
|
||||||
|
landlord_sap = None
|
||||||
|
outcomes_filename = []
|
||||||
|
outcomes_sheetname = []
|
||||||
|
outcomes_postcode = []
|
||||||
|
outcomes_houseno = []
|
||||||
|
outcomes_id = []
|
||||||
|
outcomes_address = []
|
||||||
|
master_filepaths = []
|
||||||
|
master_to_asset_list_filepath = None
|
||||||
|
phase = False
|
||||||
|
ecosurv_landlords = None
|
||||||
|
asset_list_header = 0
|
||||||
|
landlord_block_reference = "Block Reference"
|
||||||
|
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"
|
||||||
|
|
@ -148,7 +252,7 @@ def app():
|
||||||
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 = ["OUTCOMESs"]
|
outcomes_sheetname = ["OUTCOMES"]
|
||||||
outcomes_postcode = ["POSTCODE"]
|
outcomes_postcode = ["POSTCODE"]
|
||||||
outcomes_houseno = ["NO"]
|
outcomes_houseno = ["NO"]
|
||||||
outcomes_address = ["ADDRESS"]
|
outcomes_address = ["ADDRESS"]
|
||||||
|
|
@ -221,15 +325,15 @@ def app():
|
||||||
outcomes_houseno = []
|
outcomes_houseno = []
|
||||||
outcomes_address = []
|
outcomes_address = []
|
||||||
outcomes_id = []
|
outcomes_id = []
|
||||||
master_filepaths = []
|
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 = []
|
master_id_colnames = [None]
|
||||||
landlord_roof_construction = None
|
landlord_roof_construction = None
|
||||||
phase = False
|
phase = False
|
||||||
landlord_sap = None
|
landlord_sap = None
|
||||||
ecosurv_landlords = None
|
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/"
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,59 @@ BUILT_FORM_MAPPINGS = {
|
||||||
'Maisonette Over Shop': 'mid-floor',
|
'Maisonette Over Shop': 'mid-floor',
|
||||||
'Medium Rise Flat': 'mid-floor',
|
'Medium Rise Flat': 'mid-floor',
|
||||||
'Maisonette Medium Rise': 'unknown',
|
'Maisonette Medium Rise': 'unknown',
|
||||||
'End-terraced house': 'end-terrace'
|
'End-terraced house': 'end-terrace',
|
||||||
|
|
||||||
|
'Ground floor study bedroom': 'ground floor',
|
||||||
|
'End terrace bungalow': 'end-terrace',
|
||||||
|
'End terrace house': 'end-terrace',
|
||||||
|
'Ground floor bedsit': 'ground floor',
|
||||||
|
'Detached bungalow': 'detached',
|
||||||
|
'Lower ground floor flat': 'ground floor',
|
||||||
|
'Mid terrace bungalow': 'mid-terrace',
|
||||||
|
'Mid terrace house': 'mid-terrace',
|
||||||
|
'Basement bedsit': 'basement',
|
||||||
|
'Ground floor flat': 'ground floor',
|
||||||
|
'Ground floor flat with study': 'ground floor',
|
||||||
|
'Basement flat': 'basement',
|
||||||
|
'Semi bungalow': 'semi-detached',
|
||||||
|
'2nd floor flat': 'mid-floor',
|
||||||
|
'General/Communal': 'unknown',
|
||||||
|
'Semi house': 'semi-detached',
|
||||||
|
'2nd floor flat with study': 'mid-floor',
|
||||||
|
'1st floor flat with study room': 'mid-floor',
|
||||||
|
'Cluster House': 'detached',
|
||||||
|
'Utility pod': 'unknown',
|
||||||
|
'3rd floor flat': 'mid-floor',
|
||||||
|
'4th floor flat': 'mid-floor',
|
||||||
|
'2nd floor study bedroom': 'mid-floor',
|
||||||
|
'1st floor study bedroom': 'mid-floor',
|
||||||
|
'Dormer bungalow': 'detached',
|
||||||
|
'1st floor flat': 'mid-floor',
|
||||||
|
'Block property': 'unknown',
|
||||||
|
'Utility pod - DDA compliant': 'unknown',
|
||||||
|
'2nd floor bedsit': 'mid-floor',
|
||||||
|
'1st floor bedsit': 'mid-floor',
|
||||||
|
'2nd/3rd floor duplex flat': 'mid-floor',
|
||||||
|
|
||||||
|
'Bungalow - Detached': 'detached',
|
||||||
|
'Maisonette - Detached': 'detached',
|
||||||
|
'Bedsit - Mid Terrace': 'mid-terrace',
|
||||||
|
'House - End Terrace': 'end-terrace',
|
||||||
|
'House - Mid Terrace': 'mid-terrace',
|
||||||
|
'Bungalow - End Terrace': 'end-terrace',
|
||||||
|
'Maisonette - End Terrace': 'end-terrace',
|
||||||
|
'Maisonette - Semi Detached': 'semi-detached',
|
||||||
|
'House - Detached': 'detached',
|
||||||
|
'Bedsit - End Terrace': 'end-terrace',
|
||||||
|
'House - Semi detached': 'semi-detached',
|
||||||
|
'Studio Flat - Mid Terrace': 'mid-terrace',
|
||||||
|
'Bungalow - Semi detached': 'semi-detached',
|
||||||
|
'Amenity Block - Detached': 'detached',
|
||||||
|
'Bungalow - Mid Terrace': 'mid-terrace',
|
||||||
|
'Amenity Block - Semi detached': 'semi-detached',
|
||||||
|
'Maisonette - Mid Terrace': 'mid-terrace',
|
||||||
|
'Chalet - Wheelchair': 'unknown',
|
||||||
|
'Studio Flat': 'unknown',
|
||||||
|
'Bungalow - Attached': 'semi-detached'
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,60 @@ HEATING_MAPPINGS = {
|
||||||
'Warm air Electricity': 'warm air heating',
|
'Warm air Electricity': 'warm air heating',
|
||||||
'None': 'no heating',
|
'None': 'no heating',
|
||||||
'Boiler None': 'unknown',
|
'Boiler None': 'unknown',
|
||||||
'Storage heaters Electricity': 'electric storage heaters'
|
'Storage heaters Electricity': 'electric storage heaters',
|
||||||
|
|
||||||
|
'Unknown when old solid fuel system was removed': 'solid fuel',
|
||||||
|
'Storage Heater': 'electric storage heaters',
|
||||||
|
'Combi': 'gas condensing combi',
|
||||||
|
'Combi condensing': 'gas condensing combi',
|
||||||
|
'Combi Condensing': 'gas condensing combi',
|
||||||
|
'Tenant Burner': 'unknown',
|
||||||
|
'Wall Mounted Condens': 'gas condensing boiler',
|
||||||
|
'Gas Pipework': 'unknown',
|
||||||
|
'Open Fire Bck Boiler': 'solid fuel',
|
||||||
|
'Back Boiler Unit': 'solid fuel',
|
||||||
|
'Sharedgasboiler': 'communal gas boiler',
|
||||||
|
'Wall Mntd Condensing': 'gas condensing boiler',
|
||||||
|
'Flr Standing Combi': 'gas combi boiler',
|
||||||
|
'Oil - Tenant': 'oil boiler',
|
||||||
|
'Open Flue Fire': 'solid fuel',
|
||||||
|
'Wall Mounted Fire': 'room heaters',
|
||||||
|
'Gas - Unvented Cylinder': 'gas boiler, radiators',
|
||||||
|
'Commercial Pipework': 'unknown',
|
||||||
|
'Wall Mntd Condensin': 'gas condensing boiler',
|
||||||
|
'Offpeakelectric': 'electric storage heaters',
|
||||||
|
'Closed Burner': 'unknown',
|
||||||
|
'Domesticgasboiler': 'gas boiler, radiators',
|
||||||
|
'Elec - Storage': 'electric storage heaters',
|
||||||
|
'Share Common Boiler': 'communal heating',
|
||||||
|
'Down Flow Heater': 'electric radiators',
|
||||||
|
'Inset Flame Effect': 'electric radiators',
|
||||||
|
'Closedmulti': 'unknown',
|
||||||
|
'Open Fire': 'solid fuel',
|
||||||
|
'Lpgas - Domesticgasboiler': 'gas boiler, radiators',
|
||||||
|
'Solarpvpanels': 'other',
|
||||||
|
'Renew - Ashp': 'air source heat pump',
|
||||||
|
'Room Sealed App': 'unknown',
|
||||||
|
'5 Year Periodic Insp': 'unknown',
|
||||||
|
'Solarthermal': 'other',
|
||||||
|
'Wall Mounted Combi': 'gas combi boiler',
|
||||||
|
'Woodburner': 'solid fuel',
|
||||||
|
'Sealed System Wl Mtd': 'unknown',
|
||||||
|
'Room Seal App': 'unknown',
|
||||||
|
'Shared Gas Boiler': 'communal gas boiler',
|
||||||
|
'Heating Distribution': 'unknown',
|
||||||
|
'Flr Standing Boiler': 'boiler - other fuel',
|
||||||
|
'Multifuel Burner': 'solid fuel',
|
||||||
|
'Gas - Shared': 'communal gas boiler',
|
||||||
|
'Wall Mounted Boiler': 'gas boiler, radiators',
|
||||||
|
'Tenant Boiler': 'gas boiler, radiators',
|
||||||
|
'Gas - Domesticgasboiler': 'gas boiler, radiators',
|
||||||
|
'Domestic gas boiler': 'gas boiler, radiators',
|
||||||
|
'Combination': 'unknown',
|
||||||
|
|
||||||
|
'Mains Electric': 'electric fuel',
|
||||||
|
'Unvented cylinder': 'other',
|
||||||
|
'MVHR & Heat Recovery': 'other',
|
||||||
|
'Solar': 'other'
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,59 @@ PROPERTY_MAPPING = {
|
||||||
'Flat Over Shop': 'flat',
|
'Flat Over Shop': 'flat',
|
||||||
'Medium Rise Flat': 'flat',
|
'Medium Rise Flat': 'flat',
|
||||||
'End Terraced Town House': 'house',
|
'End Terraced Town House': 'house',
|
||||||
'Maisonette Medium Rise': 'maisonette'
|
'Maisonette Medium Rise': 'maisonette',
|
||||||
|
|
||||||
|
'Semi bungalow': 'bungalow',
|
||||||
|
'2nd floor flat': 'flat',
|
||||||
|
'End terrace bungalow': 'bungalow',
|
||||||
|
'End terrace house': 'house',
|
||||||
|
'Ground floor bedsit': 'bedsit',
|
||||||
|
'Detached bungalow': 'bungalow',
|
||||||
|
'Semi house': 'house',
|
||||||
|
'2nd floor flat with study': 'flat',
|
||||||
|
'1st floor flat with study room': 'flat',
|
||||||
|
'Lower ground floor flat': 'flat',
|
||||||
|
'Cluster House': 'house',
|
||||||
|
'Mid terrace bungalow': 'bungalow',
|
||||||
|
'Mid terrace house': 'house',
|
||||||
|
'Basement bedsit': 'bedsit',
|
||||||
|
'Detached house': 'house',
|
||||||
|
'3rd floor flat': 'flat',
|
||||||
|
'4th floor flat': 'flat',
|
||||||
|
'Dormer bungalow': 'bungalow',
|
||||||
|
'1st floor flat': 'flat',
|
||||||
|
'Ground floor flat': 'flat',
|
||||||
|
'Ground floor flat with study': 'flat',
|
||||||
|
'Basement flat': 'flat',
|
||||||
|
'2nd floor bedsit': 'bedsit',
|
||||||
|
'1st floor bedsit': 'bedsit',
|
||||||
|
'2nd/3rd floor duplex flat': 'flat',
|
||||||
|
'Ground floor study bedroom': 'other',
|
||||||
|
'General/Communal': 'other',
|
||||||
|
'Utility pod': 'other',
|
||||||
|
'2nd floor study bedroom': 'other',
|
||||||
|
'1st floor study bedroom': 'other',
|
||||||
|
'Block property': 'block of flats',
|
||||||
|
'Utility pod - DDA compliant': 'other',
|
||||||
|
|
||||||
|
'Bungalow - Detached': 'bungalow',
|
||||||
|
'Maisonette - Detached': 'maisonette',
|
||||||
|
'Bedsit - Mid Terrace': 'bedsit',
|
||||||
|
'Studio Flat': 'flat',
|
||||||
|
'House - End Terrace': 'house',
|
||||||
|
'House - Mid Terrace': 'house',
|
||||||
|
'Bungalow - End Terrace': 'bungalow',
|
||||||
|
'Bungalow - Attached': 'bungalow',
|
||||||
|
'Maisonette - End Terrace': 'maisonette',
|
||||||
|
'Maisonette - Semi Detached': 'maisonette',
|
||||||
|
'House - Detached': 'house',
|
||||||
|
'Bedsit - End Terrace': 'bedsit',
|
||||||
|
'House - Semi detached': 'house',
|
||||||
|
'Studio Flat - Mid Terrace': 'flat',
|
||||||
|
'Bungalow - Semi detached': 'bungalow',
|
||||||
|
'Bungalow - Mid Terrace': 'bungalow',
|
||||||
|
'Maisonette - Mid Terrace': 'maisonette',
|
||||||
|
'Chalet - Wheelchair': 'other',
|
||||||
|
'Amenity Block - Detached': 'other',
|
||||||
|
'Amenity Block - Semi detached': 'other'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from enum import Enum
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
@ -413,6 +414,10 @@ class FundingOld:
|
||||||
self.whlg()
|
self.whlg()
|
||||||
|
|
||||||
|
|
||||||
|
class EligibilityCaveats(Enum):
|
||||||
|
TENANT_ON_BENEFITS_OR_LOW_INCOME = "tenant_on_benefits_or_low_income"
|
||||||
|
|
||||||
|
|
||||||
class Funding:
|
class Funding:
|
||||||
"""
|
"""
|
||||||
New class to handle funding calculation
|
New class to handle funding calculation
|
||||||
|
|
@ -440,6 +445,9 @@ class Funding:
|
||||||
self.project_scores_matrix = project_scores_matrix
|
self.project_scores_matrix = project_scores_matrix
|
||||||
self.whlg_eligible_postcodes = whlg_eligible_postcodes
|
self.whlg_eligible_postcodes = whlg_eligible_postcodes
|
||||||
|
|
||||||
|
self.eco4_eligible = False
|
||||||
|
self.eligbility_caveat = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_sap_band(sap_score_number):
|
def get_sap_band(sap_score_number):
|
||||||
bands = [
|
bands = [
|
||||||
|
|
@ -478,9 +486,8 @@ class Funding:
|
||||||
|
|
||||||
return "200"
|
return "200"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def eco4_prs_eligibility(
|
def eco4_prs_eligibility(
|
||||||
starting_sap: int, measures: List, mainheat_description: str, heating_control_description: str
|
self, starting_sap: int, measures: List, mainheat_description: str, heating_control_description: str
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Handles the eligibility criteria for private rental properties under eco
|
Handles the eligibility criteria for private rental properties under eco
|
||||||
|
|
@ -509,11 +516,19 @@ class Funding:
|
||||||
# Is a renewable heating
|
# Is a renewable heating
|
||||||
ashp = "air_source_heat_pump" in measures
|
ashp = "air_source_heat_pump" in measures
|
||||||
|
|
||||||
|
# Meets the EPC criteria, has the measure requirement and tenant must be on benefits
|
||||||
if meets_epc & (solar_renweable_heating or ashp or has_solid_wall):
|
if meets_epc & (solar_renweable_heating or ashp or has_solid_wall):
|
||||||
return True
|
self.eco4_eligible = True
|
||||||
|
self.eligbility_caveat = EligibilityCaveats.TENANT_ON_BENEFITS_OR_LOW_INCOME
|
||||||
|
return
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def gbis_prs_eligibiltiy(self):
|
||||||
|
"""
|
||||||
|
Determines if a project is eligible for GBIS funding for private rental properties
|
||||||
|
"""
|
||||||
|
|
||||||
def calculate_full_project_abs(self):
|
def calculate_full_project_abs(self):
|
||||||
|
|
||||||
# Filter the project scores matrix
|
# Filter the project scores matrix
|
||||||
|
|
@ -568,7 +583,7 @@ class Funding:
|
||||||
# 2) GBIS
|
# 2) GBIS
|
||||||
|
|
||||||
if self.tenure == "Private":
|
if self.tenure == "Private":
|
||||||
is_eco4_eligible = self.eco4_prs_eligibility(
|
self.eco4_prs_eligibility(
|
||||||
starting_sap=starting_sap,
|
starting_sap=starting_sap,
|
||||||
measures=measures,
|
measures=measures,
|
||||||
mainheat_description=mainheat_description,
|
mainheat_description=mainheat_description,
|
||||||
|
|
@ -578,7 +593,8 @@ class Funding:
|
||||||
# Need to implement
|
# Need to implement
|
||||||
# 1) Package has to include an insulation measure
|
# 1) Package has to include an insulation measure
|
||||||
# 2) We should use the funding for the measure that has the largest partial project score
|
# 2) We should use the funding for the measure that has the largest partial project score
|
||||||
is_gbis_eligible = ()
|
# TODO: check the rules around GBIS eligibility and heating controls
|
||||||
|
self.gbis_prs_eligibiltiy()
|
||||||
|
|
||||||
if not is_eco4_eligible:
|
if not is_eco4_eligible:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ SPECIFIC_MEASURES = [
|
||||||
"cylinder_thermostat"
|
"cylinder_thermostat"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
INSULATION_MEASURES = [
|
||||||
|
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
|
||||||
|
"loft_insulation", "flat_roof_insulation", "room_roof_insulation",
|
||||||
|
"suspended_floor_insulation", "solid_floor_insulation",
|
||||||
|
]
|
||||||
|
|
||||||
NON_INVASIVE_SPECIFIC_MEASURES = [
|
NON_INVASIVE_SPECIFIC_MEASURES = [
|
||||||
"trickle_vents", "draught_proofing", "mixed_glazing", "cavity_extract_and_refill",
|
"trickle_vents", "draught_proofing", "mixed_glazing", "cavity_extract_and_refill",
|
||||||
"extension_cavity_wall_insulation"
|
"extension_cavity_wall_insulation"
|
||||||
|
|
@ -36,7 +42,7 @@ MEASURE_MAP = {
|
||||||
"heating_controls": ["roomstat_programmer_trvs", "time_temperature_zone_control"]
|
"heating_controls": ["roomstat_programmer_trvs", "time_temperature_zone_control"]
|
||||||
}
|
}
|
||||||
|
|
||||||
VALID_GOALS = ["Increasing EPC"]
|
VALID_GOALS = ["Increasing EPC", "Energy Savings", "Reducing CO2 emissions"]
|
||||||
VALID_HOUSING_TYPES = ["Social", "Private"]
|
VALID_HOUSING_TYPES = ["Social", "Private"]
|
||||||
VALID_EVENT_TYPES = ["remote_assessment"]
|
VALID_EVENT_TYPES = ["remote_assessment"]
|
||||||
|
|
||||||
|
|
@ -74,7 +80,7 @@ class PlanTriggerRequest(BaseModel):
|
||||||
budget: Optional[float] = None
|
budget: Optional[float] = None
|
||||||
goal: Goal
|
goal: Goal
|
||||||
housing_type: HousingType
|
housing_type: HousingType
|
||||||
goal_value: str
|
goal_value: Optional[str] = None
|
||||||
portfolio_id: int
|
portfolio_id: int
|
||||||
trigger_file_path: str
|
trigger_file_path: str
|
||||||
already_installed_file_path: Optional[str] = None
|
already_installed_file_path: Optional[str] = None
|
||||||
|
|
@ -118,3 +124,10 @@ class PlanTriggerRequest(BaseModel):
|
||||||
if (self.index_start is None) != (self.index_end is None):
|
if (self.index_start is None) != (self.index_end is None):
|
||||||
raise ValueError("Both index_start and index_end must be set or both must be None")
|
raise ValueError("Both index_start and index_end must be set or both must be None")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def check_goal_value_requirement(self):
|
||||||
|
# Make sure that goal_value is set when goal is "Increasing EPC"
|
||||||
|
if self.goal == "Increasing EPC" and not self.goal_value:
|
||||||
|
raise ValueError("goal_value is required when goal is 'Increasing EPC'")
|
||||||
|
return self
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from backend.app.db.functions.energy_assessment_functions import get_latest_asse
|
||||||
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
|
||||||
from backend.app.plan.utils import get_cleaned
|
from backend.app.plan.utils import get_cleaned
|
||||||
from backend.app.utils import epc_to_sap_lower_bound, sap_to_epc
|
from backend.app.utils import sap_to_epc
|
||||||
import backend.app.assumptions as assumptions
|
import backend.app.assumptions as assumptions
|
||||||
|
|
||||||
from backend.ml_models.api import ModelApi
|
from backend.ml_models.api import ModelApi
|
||||||
|
|
@ -35,7 +35,7 @@ from backend.apis.GoogleSolarApi import GoogleSolarApi
|
||||||
|
|
||||||
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||||
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
||||||
from recommendations.optimiser.optimiser_functions import prepare_input_measures
|
import recommendations.optimiser.optimiser_functions as optimiser_functions
|
||||||
from recommendations.Recommendations import Recommendations
|
from recommendations.Recommendations import Recommendations
|
||||||
from utils.logger import setup_logger
|
from utils.logger import setup_logger
|
||||||
from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3
|
from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3
|
||||||
|
|
@ -798,157 +798,59 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
|
|
||||||
# we need to double unlist because we have a list of lists
|
# we need to double unlist because we have a list of lists
|
||||||
property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs}
|
property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs}
|
||||||
|
property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures]
|
||||||
|
measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures]
|
||||||
|
|
||||||
property_required_measures = [
|
# If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore
|
||||||
m for m in recommendations[p.id] if m[0]["type"] in body.required_measures
|
# its inclusion
|
||||||
]
|
|
||||||
measures_to_optimise = [
|
|
||||||
m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures
|
|
||||||
]
|
|
||||||
|
|
||||||
# If we have a wall insulation measure, we MUST include mechanical ventilation
|
|
||||||
# Additionally, if we have required measures, they should also be included. Therefore
|
|
||||||
# we can discount the number of points required to get to the target SAP band (or increase)
|
|
||||||
# in the case of ventilation
|
|
||||||
needs_ventilation = any(
|
needs_ventilation = any(
|
||||||
x in property_measure_types for x in assumptions.measures_needing_ventilation) and not p.has_ventilation
|
x in property_measure_types for x in assumptions.measures_needing_ventilation
|
||||||
|
) and not p.has_ventilation
|
||||||
|
|
||||||
input_measures = prepare_input_measures(measures_to_optimise, body.goal, needs_ventilation)
|
input_measures = optimiser_functions.prepare_input_measures(
|
||||||
|
measures_to_optimise, body.goal, needs_ventilation
|
||||||
|
)
|
||||||
|
|
||||||
if not input_measures[0]:
|
if not input_measures[0]:
|
||||||
# This means that we have no defaults
|
# Nothing to do, we just reshape the recommendations
|
||||||
selected_recommendations = {}
|
recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
|
||||||
solution = []
|
p.id, recommendations, set()
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
fixed_gain = optimiser_functions.calculate_fixed_gain(
|
||||||
|
property_required_measures, recommendations, p, needs_ventilation
|
||||||
|
)
|
||||||
|
gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
|
||||||
|
|
||||||
|
if not body.optimise:
|
||||||
|
if body.goal != "Increasing EPC":
|
||||||
|
raise NotImplementedError("Only EPC optimisation is currently supported")
|
||||||
|
solution = [max(sub_list, key=lambda x: (x['gain'], -x['cost'])) for sub_list in input_measures]
|
||||||
else:
|
else:
|
||||||
|
optimiser = (
|
||||||
|
GainOptimiser(
|
||||||
|
input_measures, max_cost=body.budget, max_gain=gain, allow_slack=body.goal == "Increasing EPC"
|
||||||
|
) if body.budget else CostOptimiser(input_measures, min_gain=gain)
|
||||||
|
)
|
||||||
|
optimiser.setup()
|
||||||
|
optimiser.solve()
|
||||||
|
solution = optimiser.solution
|
||||||
|
|
||||||
fixed_gain = 0
|
selected = {r["id"] for r in solution}
|
||||||
if property_required_measures:
|
|
||||||
# We get the SAP points for the required measures
|
|
||||||
if body.goal != "Increasing EPC":
|
|
||||||
raise NotImplementedError("Only EPC optimisation is currently supported")
|
|
||||||
sap_by_type = [
|
|
||||||
{"type": rec["type"], "sap_points": rec["sap_points"]} for recs in property_required_measures
|
|
||||||
for rec in recs
|
|
||||||
]
|
|
||||||
# We get a MAX sap points per type
|
|
||||||
max_per_type = (
|
|
||||||
pd.DataFrame(sap_by_type).groupby("type")["sap_points"].max().to_dict()
|
|
||||||
)
|
|
||||||
fixed_gain = sum(max_per_type.values())
|
|
||||||
|
|
||||||
property_required_measure_types = {rec["type"] for rec in sap_by_type}
|
|
||||||
|
|
||||||
# if the property needs ventilation, but the measure we optimise didn't include
|
|
||||||
# venilation we add the points for ventilation as a fixed gain
|
|
||||||
if needs_ventilation and any(
|
|
||||||
r in property_required_measure_types for r in assumptions.measures_needing_ventilation
|
|
||||||
):
|
|
||||||
fixed_gain += next(
|
|
||||||
(r[0]["sap_points"] for r in recommendations[p.id] if
|
|
||||||
r[0]["type"] == "mechanical_ventilation"),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
current_sap_points = int(p.data["current-energy-efficiency"])
|
|
||||||
|
|
||||||
sap_gain = CostOptimiser.calculate_sap_gain_with_slack(
|
|
||||||
epc_to_sap_lower_bound(body.goal_value) - current_sap_points
|
|
||||||
) - fixed_gain
|
|
||||||
|
|
||||||
if body.simulate_sap_10:
|
|
||||||
# We add 3 additional SAP points to the required gain to account for SAP 10
|
|
||||||
sap_gain += 3
|
|
||||||
|
|
||||||
if not body.optimise:
|
|
||||||
if body.goal != "Increasing EPC":
|
|
||||||
raise NotImplementedError("Only EPC optimisation is currently supported")
|
|
||||||
solution = []
|
|
||||||
for sub_list in input_measures:
|
|
||||||
# Select the entry with the highest gain, and if tied, choose the one with the lowest cost
|
|
||||||
best_measure = max(sub_list, key=lambda x: (x['gain'], -x['cost']))
|
|
||||||
solution.append(best_measure)
|
|
||||||
else:
|
|
||||||
|
|
||||||
if body.budget:
|
|
||||||
optimiser = GainOptimiser(
|
|
||||||
input_measures, max_cost=body.budget, max_gain=sap_gain if sap_gain > 0 else 0
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# The minimum gain is the minimum number of SAP points required to get to the target SAP band
|
|
||||||
# If the gain is negative, the optimiser will return an empty solution
|
|
||||||
optimiser = CostOptimiser(
|
|
||||||
input_measures,
|
|
||||||
min_gain=sap_gain
|
|
||||||
)
|
|
||||||
|
|
||||||
optimiser.setup()
|
|
||||||
optimiser.solve()
|
|
||||||
solution = optimiser.solution
|
|
||||||
|
|
||||||
selected_recommendations = {r["id"] for r in solution}
|
|
||||||
|
|
||||||
if property_required_measures:
|
if property_required_measures:
|
||||||
# We select the cheapest of the required measures, into selected
|
solution = optimiser_functions.add_required_measures(
|
||||||
for recs in property_required_measures:
|
property_id=p.id, property_required_measures=property_required_measures,
|
||||||
# We select the cheapest of the required measures
|
recommendations=recommendations, selected=selected,
|
||||||
cost_to_id = {
|
|
||||||
rec["recommendation_id"]: rec["total"] for rec in recs
|
|
||||||
if rec["recommendation_id"] not in selected_recommendations
|
|
||||||
}
|
|
||||||
# Take the recommendation id with the lowers cost
|
|
||||||
|
|
||||||
selected_recommendations.add(min(cost_to_id, key=cost_to_id.get))
|
|
||||||
# Update the solution with the selected recommendaitons
|
|
||||||
solution = []
|
|
||||||
for recs in recommendations[p.id]:
|
|
||||||
for rec in recs:
|
|
||||||
if rec["recommendation_id"] in selected_recommendations:
|
|
||||||
solution.append(
|
|
||||||
{
|
|
||||||
"id": rec["recommendation_id"],
|
|
||||||
"cost": rec["total"],
|
|
||||||
"gain": rec["sap_points"],
|
|
||||||
"type": rec["type"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# If wall insulation is selected, we also include mechanical ventilation as a best practice measure
|
|
||||||
ventilation_selected = [
|
|
||||||
r for r in solution if "+mechanical_ventilation" in r["type"]
|
|
||||||
]
|
|
||||||
if (any(x in [r["type"] for r in solution] for x in assumptions.measures_needing_ventilation) or
|
|
||||||
len(ventilation_selected)):
|
|
||||||
ventilation_rec = next(
|
|
||||||
(r[0] for r in recommendations[p.id] if r[0]["type"] == "mechanical_ventilation"),
|
|
||||||
None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# If a matching recommendation was found, add its ID to the selected recommendations
|
# Add best practice measures (ventilation/trickle vents)
|
||||||
if ventilation_rec:
|
selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)
|
||||||
selected_recommendations.add(ventilation_rec["recommendation_id"])
|
# Final flattening
|
||||||
|
recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
|
||||||
# If we have a trickle vents recommendation, we also switch it on. We don't just check the solution
|
p.id, recommendations, selected
|
||||||
trickle_vents_rec = next(
|
|
||||||
(r[0] for r in recommendations[p.id] if r[0]["type"] == "trickle_vents"),
|
|
||||||
None
|
|
||||||
)
|
)
|
||||||
# If a matching recommendation was found, add its ID to the selected recommendations
|
|
||||||
if trickle_vents_rec:
|
|
||||||
selected_recommendations.add(trickle_vents_rec["recommendation_id"])
|
|
||||||
|
|
||||||
# We'll use the set of selected recommendations to filter the recommendations to upload
|
|
||||||
final_recommendations = [
|
|
||||||
[
|
|
||||||
{**rec, "default": True if rec["recommendation_id"] in selected_recommendations else False}
|
|
||||||
for rec in recommendations_by_type
|
|
||||||
]
|
|
||||||
for recommendations_by_type in recommendations[p.id]
|
|
||||||
]
|
|
||||||
|
|
||||||
# We'll also unlist the recommendations so they're a bit easier to handle from here onwards
|
|
||||||
recommendations[p.id] = [
|
|
||||||
rec for recommendations_by_type in final_recommendations for rec in recommendations_by_type
|
|
||||||
]
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -1111,6 +1013,8 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
[sum(r["labour_days"] for r in rec_group if r["default"]) for p_id, rec_group in recommendations.items()]
|
[sum(r["labour_days"] for r in rec_group if r["default"]) for p_id, rec_group in recommendations.items()]
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# TODO - This code only pulls in the properties that have been updated in this run, but we need to
|
||||||
|
# aggregate all properties in the portfolio. We likely need to trigger a re-aggregation
|
||||||
aggregated_data = extract_portfolio_aggregation_data(
|
aggregated_data = extract_portfolio_aggregation_data(
|
||||||
input_properties=input_properties,
|
input_properties=input_properties,
|
||||||
total_valuation_increase=total_valuation_increase,
|
total_valuation_increase=total_valuation_increase,
|
||||||
|
|
|
||||||
|
|
@ -25,28 +25,27 @@ def get_funding_data():
|
||||||
|
|
||||||
return project_scores_matrix, whlg_eligible_postcodes
|
return project_scores_matrix, whlg_eligible_postcodes
|
||||||
|
|
||||||
|
# class TestFunding:
|
||||||
class TestFunding:
|
#
|
||||||
|
# def test_prs(self):
|
||||||
def test_prs(self):
|
# eco_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
|
||||||
eco_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
|
# funding = Funding(
|
||||||
funding = Funding(
|
# project_scores_matrix=eco_project_scores_matrix,
|
||||||
project_scores_matrix=eco_project_scores_matrix,
|
# whlg_eligible_postcodes=whlg_eligible_postcodes,
|
||||||
whlg_eligible_postcodes=whlg_eligible_postcodes,
|
# social_cavity_abs_rate=13.5,
|
||||||
social_cavity_abs_rate=13.5,
|
# social_solid_abs_rate=17,
|
||||||
social_solid_abs_rate=17,
|
# private_cavity_abs_rate=13.5,
|
||||||
private_cavity_abs_rate=13.5,
|
# private_solid_abs_rate=17,
|
||||||
private_solid_abs_rate=17,
|
# tenure="Private",
|
||||||
tenure="Private",
|
# )
|
||||||
)
|
#
|
||||||
|
# measures_1 = ["internal_wall_insulation", "solar_pv"]
|
||||||
measures_1 = ["internal_wall_insulation", "solar_pv"]
|
# funding.check_funding(
|
||||||
funding.check_funding(
|
# measures=measures_1,
|
||||||
measures=measures_1,
|
# starting_sap=54,
|
||||||
starting_sap=54,
|
# ending_sap=69,
|
||||||
ending_sap=69,
|
# floor_area=73,
|
||||||
floor_area=73,
|
# mainheat_description="Boiler and radiators, mains gas",
|
||||||
mainheat_description="Boiler and radiators, mains gas",
|
# heating_control_description="Programmer, room thermostat and TRVs",
|
||||||
heating_control_description="Programmer, room thermostat and TRVs",
|
# is_cavity=True
|
||||||
is_cavity=True
|
# )
|
||||||
)
|
|
||||||
|
|
|
||||||
567
epr_data_exports/app.py
Normal file
567
epr_data_exports/app.py
Normal file
|
|
@ -0,0 +1,567 @@
|
||||||
|
"""
|
||||||
|
This is a placeholder script to extract epr data from files, where we can
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
July 2025 LiveWest Heating Upgrades
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import PyPDF2
|
||||||
|
import pandas as pd
|
||||||
|
from tqdm import tqdm
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
def extract_window_age_description(windows_text):
|
||||||
|
"""
|
||||||
|
Extracts the most common window age description and its proportion.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
windows_text (str): The text section containing window data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary with the most common window age description and its proportion.
|
||||||
|
"""
|
||||||
|
# Clean up windows_text by removing line breaks for better pattern matching
|
||||||
|
windows_text = windows_text.replace("\n", "")
|
||||||
|
|
||||||
|
# Define possible window age descriptions
|
||||||
|
window_descriptions = [
|
||||||
|
"Double post or during 2002",
|
||||||
|
"Double pre 2002",
|
||||||
|
"Double with unknown install date",
|
||||||
|
"Secondary glazing",
|
||||||
|
"Triple glazing",
|
||||||
|
"Single glazing",
|
||||||
|
"Double between 2002 \nand 2021",
|
||||||
|
"Double between 2002 and 2021"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Count occurrences of each description
|
||||||
|
description_counts = Counter()
|
||||||
|
for description in window_descriptions:
|
||||||
|
matches = re.findall(re.escape(description), windows_text)
|
||||||
|
description_counts[description] = len(matches)
|
||||||
|
|
||||||
|
if not description_counts or not sum(description_counts.values()):
|
||||||
|
raise ValueError("Failed to extract window data.")
|
||||||
|
|
||||||
|
# Determine the most common description and calculate its proportion
|
||||||
|
most_common_description, window_count = description_counts.most_common(1)[0]
|
||||||
|
window_proportion = window_count / sum(description_counts.values()) * 100
|
||||||
|
|
||||||
|
# Get the second most common and the proportion
|
||||||
|
if window_proportion == 100:
|
||||||
|
second_most_common_description = None
|
||||||
|
second_most_common_proportion = 0
|
||||||
|
else:
|
||||||
|
second_most_common_description, second_window_count = description_counts.most_common(2)[1]
|
||||||
|
second_most_common_proportion = second_window_count / sum(description_counts.values()) * 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Window Age Description": most_common_description,
|
||||||
|
"Window Age Description Proportion (%)": window_proportion,
|
||||||
|
"Secondary Window Age Description": second_most_common_description,
|
||||||
|
"Secondary Window Age Description Proportion (%)": second_most_common_proportion,
|
||||||
|
"Number of Windows": sum(description_counts.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_building_parts_summary(text):
|
||||||
|
"""
|
||||||
|
Extracts building parts and associated dimensions from the summary report PDF.
|
||||||
|
This includes Main Property, multiple extensions if they exist, and Room in Roof areas.
|
||||||
|
"""
|
||||||
|
data = []
|
||||||
|
|
||||||
|
# Locate the Dimensions section
|
||||||
|
dimensions_section = re.search(
|
||||||
|
r"Dimensions:\s*Dimension type: Internal\n(.*?)\n5\.0 Conservatory:", text, re.DOTALL
|
||||||
|
)
|
||||||
|
if not dimensions_section:
|
||||||
|
dimensions_section = re.search(
|
||||||
|
r"Dimensions:\s*Dimension type: External\n(.*?)\n5\.0 Conservatory:", text, re.DOTALL
|
||||||
|
)
|
||||||
|
if not dimensions_section:
|
||||||
|
raise ValueError("Failed to locate dimensions section in the text.")
|
||||||
|
|
||||||
|
dimensions_text = dimensions_section.group(1)
|
||||||
|
|
||||||
|
# Pattern to extract each building part, starting from Main Property and including extensions
|
||||||
|
building_part_pattern = re.compile(
|
||||||
|
r"(Main Property|\d+(?:st|nd|rd|th) Extension)\s*"
|
||||||
|
r"(.*?)(?=\d+(?:st|nd|rd|th) Extension|5\.0 Conservatory|$)",
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Loop through each building part match, including Main Property and extensions
|
||||||
|
for match in building_part_pattern.finditer(dimensions_text):
|
||||||
|
part_name = match.group(1)
|
||||||
|
floor_data = match.group(2)
|
||||||
|
|
||||||
|
# Pattern to extract floor details: Floor Level, Floor Area, Room Height, Perimeter, Party Wall Length
|
||||||
|
floor_pattern = re.compile(
|
||||||
|
r"(1st Floor|Lowest Floor|Second floor):\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract data for each floor within the building part
|
||||||
|
for floor_match in floor_pattern.finditer(floor_data):
|
||||||
|
floor_level = floor_match.group(1)
|
||||||
|
floor_area = float(floor_match.group(2))
|
||||||
|
room_height = float(floor_match.group(3))
|
||||||
|
perimeter = float(floor_match.group(4))
|
||||||
|
party_wall_length = float(floor_match.group(5))
|
||||||
|
|
||||||
|
# Append to data list
|
||||||
|
data.append({
|
||||||
|
"Building Part": part_name,
|
||||||
|
"Floor Level": floor_level,
|
||||||
|
"Floor Area (m2)": floor_area,
|
||||||
|
"Room Height (m)": room_height,
|
||||||
|
"Perimeter (m)": perimeter,
|
||||||
|
"Party Wall Length (m)": party_wall_length
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check specifically for "Room(s) in Roof" entries, which only have Floor Area
|
||||||
|
room_in_roof_pattern = re.compile(r"Room\(s\) in Roof:\s*([\d.]+)")
|
||||||
|
room_in_roof_match = room_in_roof_pattern.search(floor_data)
|
||||||
|
if room_in_roof_match:
|
||||||
|
floor_area = float(room_in_roof_match.group(1))
|
||||||
|
data.append({
|
||||||
|
"Building Part": part_name,
|
||||||
|
"Floor Level": "Room in Roof",
|
||||||
|
"Floor Area (m2)": floor_area,
|
||||||
|
"Room Height (m)": None, # Placeholder for missing data
|
||||||
|
"Perimeter (m)": None, # Placeholder for missing data
|
||||||
|
"Party Wall Length (m)": None # Placeholder for missing data
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculate aggregated dimensions
|
||||||
|
main_property = [part for part in data if "Main Property" in part["Building Part"]]
|
||||||
|
first_extensions = [part for part in data if "1st Extension" in part["Building Part"]]
|
||||||
|
dimensions = {
|
||||||
|
"Total Floor Area (m2)": sum([part["Floor Area (m2)"] for part in data]),
|
||||||
|
"Total Ground Floor Area (m2)": sum(
|
||||||
|
[part["Floor Area (m2)"] for part in data if "Lowest Floor" in part["Floor Level"]]
|
||||||
|
),
|
||||||
|
"RIR Floor Area": sum(
|
||||||
|
[part["Floor Area (m2)"] for part in data if "Room in Roof" in part["Floor Level"]]
|
||||||
|
),
|
||||||
|
"Main Building Wall Area (m2)": sum([x["Perimeter (m)"] * x["Room Height (m)"] for x in main_property if
|
||||||
|
x["Perimeter (m)"] and x["Room Height (m)"]]),
|
||||||
|
"First Extension Wall Area (m2)": sum(
|
||||||
|
[x["Perimeter (m)"] * x["Room Height (m)"] for x in first_extensions if
|
||||||
|
x["Perimeter (m)"] and x["Room Height (m)"]]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return dimensions
|
||||||
|
|
||||||
|
|
||||||
|
def extract_roof_details_summary(text):
|
||||||
|
"""
|
||||||
|
Extracts roof type, insulation, and insulation thickness for each building part
|
||||||
|
in the 8.0 Roofs section of the summary report.
|
||||||
|
"""
|
||||||
|
# Define data structure to hold results
|
||||||
|
roof_data = []
|
||||||
|
|
||||||
|
# Locate the entire 8.0 Roofs section
|
||||||
|
roof_section_match = re.search(r"8\.0 Roofs:\n(.*?)(?=\n9\.0 Floors:|$)", text, re.DOTALL)
|
||||||
|
if not roof_section_match:
|
||||||
|
return roof_data # Return empty if no roof section is found
|
||||||
|
|
||||||
|
# Extract the roof section and append "9.0 Floors:" as the boundary
|
||||||
|
roof_section = roof_section_match.group(1).strip() + "\n9.0 Floors:"
|
||||||
|
|
||||||
|
# Define pattern to match each building part's roof entry
|
||||||
|
building_part_pattern = re.compile(
|
||||||
|
r"(Main Property|1st Extension|2nd Extension|[\w\s]+)\n" # Matches each building part label
|
||||||
|
r"Type\s+(.*?)(?=\n(?:Insulation|9\.0 Floors:|[A-Z]))" # Matches Roof Type until the next field, label, or end
|
||||||
|
r"(?:\nInsulation\s+(.*?)(?=\n(?:Insulation Thickness|9\.0 Floors:|[A-Z])))?" # Optional Insulation
|
||||||
|
r"(?:\nInsulation Thickness\s+(.*?)(?=\n(?:9\.0 Floors:|[A-Z])))?", # Optional Insulation Thickness
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract each building part's data
|
||||||
|
for match in building_part_pattern.finditer(roof_section):
|
||||||
|
part_name = match.group(1).strip() # Building part label
|
||||||
|
roof_type = match.group(2).strip() # Roof Type
|
||||||
|
roof_insulation = match.group(3).strip() if match.group(3) else None # Optional Insulation
|
||||||
|
roof_insulation_thickness = match.group(4).strip() if match.group(4) else None # Optional Thickness
|
||||||
|
|
||||||
|
# Cleaning to handle annoying cases when it comes out like this:
|
||||||
|
# 'A Another dwelling above\n1st Extension'
|
||||||
|
if roof_type.startswith("A Another dwelling above"):
|
||||||
|
roof_type = "A Another dwelling above"
|
||||||
|
|
||||||
|
# Store results for this building part
|
||||||
|
roof_data.append({
|
||||||
|
"Building Part": part_name,
|
||||||
|
"Roof Type": roof_type,
|
||||||
|
"Roof Insulation": roof_insulation,
|
||||||
|
"Roof Insulation Thickness": roof_insulation_thickness,
|
||||||
|
})
|
||||||
|
|
||||||
|
return roof_data
|
||||||
|
|
||||||
|
|
||||||
|
def extract_wall_details_summary(text):
|
||||||
|
"""
|
||||||
|
Extracts wall type, insulation, dry-lining, and thickness for each building part,
|
||||||
|
including any alternative wall details within the 7.0 Walls section of the summary PDF text.
|
||||||
|
"""
|
||||||
|
# Define data structure to hold all building part wall entries
|
||||||
|
wall_data = []
|
||||||
|
|
||||||
|
# Locate the entire 7.0 Walls section
|
||||||
|
wall_section = re.search(r"7\.0 Walls:\n(.*?)\n8\.0 Roofs:", text, re.DOTALL).group(1)
|
||||||
|
|
||||||
|
# Define pattern to match each building part's wall entry within the section
|
||||||
|
building_part_pattern = re.compile(
|
||||||
|
r"(Main Property|1st Extension|2nd Extension|[\w\s]+)\n" # Matches each building part label
|
||||||
|
r"Type\s+(.*?)\n" # Matches main wall Type
|
||||||
|
r"Insulation\s+(.*?)\n", # Matches main wall Insulation
|
||||||
|
# r"(Dry-lining\s+(.*?)\n)?" # Optional main wall Dry-lining
|
||||||
|
# r"Wall Thickness Unknown\s+(.*?)\n" # Matches main wall Thickness Unknown
|
||||||
|
# r"Wall Thickness \[mm\]\s+(\d+)", # Matches main wall Thickness
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define pattern to capture alternative wall details, if present
|
||||||
|
alternative_wall_pattern = re.compile(
|
||||||
|
r"Alternative Wall Area.*?\n" # Matches start of alternative wall section
|
||||||
|
r"Alternative Type\s+(.*?)\n" # Matches alternative wall Type
|
||||||
|
r"Alternative Insulation\s+(.*?)\n" # Matches alternative wall Insulation
|
||||||
|
r"(Alternative Dry-lining\s+(.*?)\n)?" # Optional Alternative Dry-lining
|
||||||
|
r"Alternative Wall Thickness Unknown\s+(.*?)\n" # Matches alternative wall Thickness Unknown
|
||||||
|
r"Alternative Wall Thickness\s+(\d+)", # Matches alternative wall Thickness
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find all building part entries within the 7.0 Walls section
|
||||||
|
for match in building_part_pattern.finditer(wall_section):
|
||||||
|
|
||||||
|
wall_label = match.group(1).strip()
|
||||||
|
main_wall_type = match.group(2).strip()
|
||||||
|
main_wall_insulation = match.group(3).strip()
|
||||||
|
# main_wall_dry_lining = match.group(5).strip() if match.group(5) else "N/A"
|
||||||
|
# main_wall_thickness_unknown = match.group(6).strip()
|
||||||
|
# main_wall_thickness = int(match.group(7))
|
||||||
|
|
||||||
|
# Initialize dictionary for this wall entry
|
||||||
|
wall_entry = {
|
||||||
|
"Building Part": wall_label,
|
||||||
|
"Wall Type": main_wall_type,
|
||||||
|
"Wall Insulation": main_wall_insulation,
|
||||||
|
# "Wall Dry-lining": main_wall_dry_lining,
|
||||||
|
# "Wall Thickness Unknown": main_wall_thickness_unknown,
|
||||||
|
# "Wall Thickness (mm)": main_wall_thickness,
|
||||||
|
"Alternative Wall Type": None,
|
||||||
|
"Alternative Wall Insulation": None,
|
||||||
|
"Alternative Wall Dry-lining": "N/A",
|
||||||
|
"Alternative Wall Thickness Unknown": None,
|
||||||
|
"Alternative Wall Thickness (mm)": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if there's an alternative wall section following this wall entry
|
||||||
|
alt_match = alternative_wall_pattern.search(wall_section, match.end())
|
||||||
|
if alt_match:
|
||||||
|
wall_entry["Alternative Wall Type"] = alt_match.group(1).strip()
|
||||||
|
wall_entry["Alternative Wall Insulation"] = alt_match.group(2).strip()
|
||||||
|
wall_entry["Alternative Wall Dry-lining"] = alt_match.group(4).strip() if alt_match.group(4) else "N/A"
|
||||||
|
wall_entry["Alternative Wall Thickness Unknown"] = alt_match.group(5).strip()
|
||||||
|
wall_entry["Alternative Wall Thickness (mm)"] = int(alt_match.group(6))
|
||||||
|
|
||||||
|
# Append each building part as a dictionary in the wall_data list
|
||||||
|
wall_data.append(wall_entry)
|
||||||
|
|
||||||
|
return wall_data
|
||||||
|
|
||||||
|
|
||||||
|
def extract_summary_report(pdf_path):
|
||||||
|
"""
|
||||||
|
Extracts specific data from the provided PDF file.
|
||||||
|
Data includes:
|
||||||
|
- Current SAP rating
|
||||||
|
- Fuel Bill
|
||||||
|
- Address
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Address": None,
|
||||||
|
"Postcode": None,
|
||||||
|
"Current SAP Rating": None,
|
||||||
|
"Current EPC Band": None,
|
||||||
|
"Fuel Bill": None,
|
||||||
|
"Main Building Age Band": None,
|
||||||
|
"Number of Storeys": None,
|
||||||
|
"Window Age Description": None,
|
||||||
|
"Window Age Description Proportion (%)": None,
|
||||||
|
"Secondary Window Age Description": None,
|
||||||
|
"Secondary Window Age Description Proportion (%)": None,
|
||||||
|
"Number of Windows": None,
|
||||||
|
"Total Number of Doors": None,
|
||||||
|
"Number of Insulated Doors": None,
|
||||||
|
"Existing Primary Heating System": None,
|
||||||
|
"Existing Primary Heating PCDF Reference": None,
|
||||||
|
"Existing Primary Heating Controls": None,
|
||||||
|
"Existing Primary Heating % of Heat": None,
|
||||||
|
"Existing Secondary Heating System": None,
|
||||||
|
"Existing Secondary Heating PCDF Reference": None,
|
||||||
|
"Existing Secondary Heating Controls": None,
|
||||||
|
"Existing Secondary Heating % of Heat": None,
|
||||||
|
"Secondary Heating Code": None,
|
||||||
|
"Water Heating Code": None,
|
||||||
|
'Total Floor Area (m2)': None,
|
||||||
|
'Total Ground Floor Area (m2)': None,
|
||||||
|
'RIR Floor Area': None,
|
||||||
|
'Main Building Wall Area (m2)': None,
|
||||||
|
'First Extension Wall Area (m2)': None,
|
||||||
|
"Number of Light Fittings": None,
|
||||||
|
"Number of LEL Fittings": None,
|
||||||
|
"Number of fittings needing LEL": None,
|
||||||
|
"Main Roof Type": None,
|
||||||
|
"Main Roof Insulation": None,
|
||||||
|
"Main Roof Insulation Thickness": None,
|
||||||
|
"Main Wall Type": None,
|
||||||
|
"Main Wall Insulation": None,
|
||||||
|
"Main Wall Dry-lining": None,
|
||||||
|
"Main Wall Thickness": None,
|
||||||
|
"Main Building Alternative Wall Type": None,
|
||||||
|
"Main Building Alternative Wall Insulation": None,
|
||||||
|
"Main Building Alternative Wall Dry-lining": None,
|
||||||
|
"Main Building Alternative Wall Thickness": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (open(pdf_path, "rb") as file):
|
||||||
|
reader = PyPDF2.PdfReader(file)
|
||||||
|
text = ""
|
||||||
|
for page in reader.pages:
|
||||||
|
text += page.extract_text()
|
||||||
|
|
||||||
|
# Extract Current SAP rating
|
||||||
|
sap_match = re.search(r"Current SAP rating:\s*([A-Z] \d+)", text)
|
||||||
|
data["Current SAP Rating"] = sap_match.group(1).split(" ")[1]
|
||||||
|
|
||||||
|
data["Property Type"] = (
|
||||||
|
re.search(r"Property type:\s*(.*?)\n2\.0", text, re.DOTALL)
|
||||||
|
.group(1).replace('\n', ' ').strip().replace(" ", " ")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract age
|
||||||
|
age_band_match = re.search(
|
||||||
|
r"3\.0 Date Built:\s*Main Property\s*[A-Z]?\s*(\d{4}-\d{4}|before \d{4}|\d{4} onwards)",
|
||||||
|
text
|
||||||
|
)
|
||||||
|
data["Main Building Age Band"] = age_band_match.group(1)
|
||||||
|
|
||||||
|
# Number of storeys
|
||||||
|
storeys_match = re.search(r"Number of Storeys:\s*(\d+)", text)
|
||||||
|
data["Number of Storeys"] = int(storeys_match.group(1))
|
||||||
|
|
||||||
|
# Grab number of heated rooms, number of habitable rooms
|
||||||
|
data["Number of Heated Rooms"] = int(re.search(r"Heated Habitable Rooms:\s*(\d+)", text).group(1))
|
||||||
|
data["Number of Habitable rooms"] = int(re.search(r"Habitable Rooms:\s*(\d+)", text).group(1))
|
||||||
|
|
||||||
|
# Extract Carbon Emissions
|
||||||
|
# carbon_match = re.search(r"Emissions \(t/year\):\s*([\d.]+)\s*tonnes", text)
|
||||||
|
# data["Carbon Emissions (t/year)"] = float(carbon_match.group(1))
|
||||||
|
|
||||||
|
# Extract Fuel Bill
|
||||||
|
fuel_bill_match = re.search(r"Fuel Bill:\s*£(\d+)", text)
|
||||||
|
data["Fuel Bill"] = f"£{fuel_bill_match.group(1)}"
|
||||||
|
|
||||||
|
# Extract individual address components
|
||||||
|
postcode = re.search(r"Postcode:\s*(.*?)\nRegion:", text)
|
||||||
|
# region = re.search(r"Region:\s*(.*?)\nHouse Name:", text)
|
||||||
|
house_name = re.search(r"House Name:\s*(.*?)\nHouse No:", text)
|
||||||
|
house_no = re.search(r"House No:\s*(.*?)\nStreet:", text)
|
||||||
|
street = re.search(r"Street:\s*(.*?)\nLocality:", text)
|
||||||
|
locality = re.search(r"Locality:\s*(.*?)\nTown:", text)
|
||||||
|
town = re.search(r"Town:\s*(.*?)\nCounty:", text)
|
||||||
|
county = re.search(r"County:\s*(.*?)\nProperty Tenure:", text)
|
||||||
|
|
||||||
|
# Clean extracted values and remove any prefixes
|
||||||
|
address_parts = [
|
||||||
|
house_no.group(1).strip() if house_no else "",
|
||||||
|
house_name.group(1).strip() if house_name else "",
|
||||||
|
street.group(1).strip() if street else "",
|
||||||
|
locality.group(1).strip() if locality else "",
|
||||||
|
town.group(1).strip() if town else "",
|
||||||
|
county.group(1).strip() if county else "",
|
||||||
|
postcode.group(1).strip() if postcode else ""
|
||||||
|
]
|
||||||
|
|
||||||
|
# Join non-empty parts with a comma
|
||||||
|
data["Address"] = ", ".join([part for part in address_parts if part])
|
||||||
|
data["Postcode"] = postcode.group(1).strip()
|
||||||
|
|
||||||
|
# windows_section = re.search(r"Windows\s*(.*?)\s*Draught Proofing", text, re.DOTALL)
|
||||||
|
# windows_text = windows_section.group(1)
|
||||||
|
# window_data = extract_window_age_description(windows_text)
|
||||||
|
# data.update(window_data)
|
||||||
|
|
||||||
|
# Extract Total Number of Doors
|
||||||
|
total_doors_match = re.search(r"Total Number of Doors\s*(\d+)", text)
|
||||||
|
data["Total Number of Doors"] = int(total_doors_match.group(1))
|
||||||
|
|
||||||
|
# Extract Number of Insulated Doors
|
||||||
|
insulated_doors_match = re.search(r"Number of Insulated Doors\s*(\d+)", text)
|
||||||
|
data["Number of Insulated Doors"] = int(insulated_doors_match.group(1))
|
||||||
|
|
||||||
|
# Extract heating system
|
||||||
|
# Extract Primary Heating Data
|
||||||
|
# Extract Primary Heating Section
|
||||||
|
primary_heating_section1 = re.search(r"Main\s*Heating1\s*(.*?)\s*Main\s*Heating2", text, re.DOTALL)
|
||||||
|
primary_heating_section2 = re.search(r"Main\s*Heating1\s*(.*?)\s*Water\s*Heating", text, re.DOTALL)
|
||||||
|
primary_heating_section = primary_heating_section1 if primary_heating_section1 else primary_heating_section2
|
||||||
|
|
||||||
|
primary_text = primary_heating_section.group(1)
|
||||||
|
|
||||||
|
# Handle extracting main heating code:
|
||||||
|
mainheat_search = re.search(r"Main Heating Code\s*(.*?)\n", primary_text)
|
||||||
|
if mainheat_search is None:
|
||||||
|
mainheat_search = re.search(r"Main Heating EES Code\s*(.*?)\n", primary_text)
|
||||||
|
if mainheat_search is None:
|
||||||
|
mainheat_search = re.search(r"PCDF boiler Reference\s*(.*?)\n", primary_text)
|
||||||
|
|
||||||
|
data["Existing Primary Heating System"] = mainheat_search.group(1).strip()
|
||||||
|
|
||||||
|
data["Existing Primary Heating PCDF Reference"] = re.search(
|
||||||
|
r"PCDF boiler Reference\s*(\d+)", primary_text
|
||||||
|
).group(1)
|
||||||
|
|
||||||
|
controls_search = re.search(
|
||||||
|
r"Main Heating Controls Sap\s*(.*?)\n", primary_text
|
||||||
|
)
|
||||||
|
if controls_search is None:
|
||||||
|
controls_search = re.search(
|
||||||
|
r"Main Heating Controls\s*(.*?)\n", primary_text
|
||||||
|
)
|
||||||
|
data["Existing Primary Heating Controls"] = controls_search.group(1).strip()
|
||||||
|
data["Existing Primary Heating % of Heat"] = int(
|
||||||
|
re.search(r"Percentage of Heat\s*(\d+)\s*%", primary_text).group(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract Secondary Heating Section
|
||||||
|
secondary_heating_section = re.search(r"Main\s*Heating2\s*(.*?)\s*Water\s*Heating", text, re.DOTALL)
|
||||||
|
|
||||||
|
if secondary_heating_section is None:
|
||||||
|
data["Existing Secondary Heating System"] = ""
|
||||||
|
data["Existing Secondary Heating PCDF Reference"] = ""
|
||||||
|
data["Existing Secondary Heating Controls"] = ""
|
||||||
|
data["Existing Secondary Heating % of Heat"] = 0
|
||||||
|
|
||||||
|
else:
|
||||||
|
secondary_text = secondary_heating_section.group(1)
|
||||||
|
|
||||||
|
main_heating_code_match_secondary = re.search(
|
||||||
|
r"Main Heating Code\s*(.*?)(?=\n|Percentage of Heat)", secondary_text
|
||||||
|
)
|
||||||
|
if main_heating_code_match_secondary is None:
|
||||||
|
main_heating_code_match_secondary = re.search(
|
||||||
|
r"Main Heating EES Code\s*(.*?)(?=\n|Percentage of Heat)", secondary_text
|
||||||
|
)
|
||||||
|
|
||||||
|
data["Existing Secondary Heating System"] = main_heating_code_match_secondary.group(1).strip()
|
||||||
|
data["Existing Secondary Heating PCDF Reference"] = re.search(r"PCDF boiler Reference\s*(\d+)",
|
||||||
|
secondary_text).group(1)
|
||||||
|
second_heating_controls_match = re.search(r"Main Heating Controls\s*(.*?)\n", secondary_text)
|
||||||
|
data["Existing Secondary Heating Controls"] = (
|
||||||
|
second_heating_controls_match.group(1).strip() if second_heating_controls_match else ""
|
||||||
|
)
|
||||||
|
data["Existing Secondary Heating % of Heat"] = int(
|
||||||
|
re.search(r"Percentage of Heat\s*(\d+)\s*%", secondary_text).group(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract Secondary Heating and Water Heating Codes
|
||||||
|
secondary_heating_code_match = re.search(r"Secondary Heating Code\s*(.*?)\n", text)
|
||||||
|
water_heating_code_match = re.search(r"Water Heating Code\s*(.*?)\n", text)
|
||||||
|
|
||||||
|
if data["Existing Secondary Heating System"] == "":
|
||||||
|
data["Secondary Heating Code"] = ""
|
||||||
|
else:
|
||||||
|
data["Secondary Heating Code"] = secondary_heating_code_match.group(
|
||||||
|
1).strip() if secondary_heating_code_match else ""
|
||||||
|
|
||||||
|
data["Water Heating Code"] = water_heating_code_match.group(1).strip()
|
||||||
|
|
||||||
|
dimensions = extract_building_parts_summary(text)
|
||||||
|
data.update(dimensions)
|
||||||
|
|
||||||
|
# Need to get the hot water
|
||||||
|
section_match = re.search(r"15\.0.*?\n(.*?)15\.1", text, re.DOTALL)
|
||||||
|
section_text = section_match.group(1)
|
||||||
|
|
||||||
|
# Extract Water Heating Code
|
||||||
|
code_match = re.search(r"Water Heating Code\s+(\S+)", section_text)
|
||||||
|
fuel_match = re.search(r"Water Heating Fuel Type\s+(.+)", section_text)
|
||||||
|
if fuel_match is None:
|
||||||
|
fuel_type = None
|
||||||
|
else:
|
||||||
|
fuel_type = fuel_match.group(1).strip()
|
||||||
|
|
||||||
|
code = code_match.group(1)
|
||||||
|
data["Hot Water System"] = code
|
||||||
|
data["Hot Water Fuel"] = fuel_type
|
||||||
|
|
||||||
|
# data["Number of Light Fittings"] = int(re.search(r"Total number of light fittings\s*(\d+)", text).group(1))
|
||||||
|
# data["Number of LEL Fittings"] = int(re.search(r"Total number of L.E.L. fittings\s*(\d+)", text).group(1))
|
||||||
|
# data["Number of fittings needing LEL"] = data["Number of Light Fittings"] - data["Number of LEL Fittings"]
|
||||||
|
|
||||||
|
extracted_roof_data = extract_roof_details_summary(text)
|
||||||
|
main_roof_data = [roof for roof in extracted_roof_data if "Main" in roof["Building Part"]][0]
|
||||||
|
data["Main Roof Type"] = main_roof_data["Roof Type"]
|
||||||
|
data["Main Roof Insulation"] = main_roof_data["Roof Insulation"]
|
||||||
|
data["Main Roof Insulation Thickness"] = main_roof_data["Roof Insulation Thickness"]
|
||||||
|
|
||||||
|
walls_data = extract_wall_details_summary(text)
|
||||||
|
# Get the main building wall data
|
||||||
|
main_building_walls = [wall for wall in walls_data if "Main" in wall["Building Part"]][0]
|
||||||
|
data["Main Wall Type"] = main_building_walls["Wall Type"]
|
||||||
|
data["Main Wall Insulation"] = main_building_walls["Wall Insulation"]
|
||||||
|
# data["Main Wall Dry-lining"] = main_building_walls["Wall Dry-lining"]
|
||||||
|
# data["Main Wall Thickness"] = main_building_walls["Wall Thickness (mm)"]
|
||||||
|
# data["Main Building Alternative Wall Type"] = main_building_walls["Alternative Wall Type"]
|
||||||
|
# data["Main Building Alternative Wall Insulation"] = main_building_walls["Alternative Wall Insulation"]
|
||||||
|
# data["Main Building Alternative Wall Dry-lining"] = main_building_walls["Alternative Wall Dry-lining"]
|
||||||
|
# data["Main Building Alternative Wall Thickness"] = main_building_walls["Alternative Wall Thickness (mm)"]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
folder_location = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Livewest/July 2025 Heating Upgrades"
|
||||||
|
|
||||||
|
df = pd.read_csv("/Users/khalimconn-kowlessar/Documents/hestia/July 2025 Surveys/export_summary_table.csv")
|
||||||
|
|
||||||
|
property_data = []
|
||||||
|
for _, x in tqdm(df.iterrows(), total=len(df)):
|
||||||
|
|
||||||
|
if not pd.isnull(x["error"]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filepath = x["filepath"]
|
||||||
|
if filepath in ["No summary file found"]:
|
||||||
|
continue
|
||||||
|
summary_data = extract_summary_report(pdf_path=filepath)
|
||||||
|
property_data.append(
|
||||||
|
{
|
||||||
|
**x.to_dict(),
|
||||||
|
**summary_data
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
property_data = pd.DataFrame(property_data)
|
||||||
|
# Store as excel
|
||||||
|
property_data.to_excel(
|
||||||
|
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Livewest/July 2025 Heating "
|
||||||
|
"Upgrades/property_table_24th_july.xlsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
sandwell_data = property_data[property_data["company"] == "sandwell.gov.uk"]
|
||||||
|
sandwell_data.to_csv(
|
||||||
|
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Livewest/July 2025 Heating "
|
||||||
|
"Upgrades/Sandwell EPR data (WIP).xlsx"
|
||||||
|
)
|
||||||
|
|
@ -17,7 +17,6 @@ class VentilationRecommendations(Definitions):
|
||||||
):
|
):
|
||||||
self.property = property_instance
|
self.property = property_instance
|
||||||
|
|
||||||
self.has_ventilaion = None
|
|
||||||
self.recommendation = None
|
self.recommendation = None
|
||||||
self.materials = [part for part in materials if part["type"] == "mechanical_ventilation"]
|
self.materials = [part for part in materials if part["type"] == "mechanical_ventilation"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
def __init__(self, components, max_cost, max_gain, allow_slack=True):
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
@ -21,6 +21,8 @@ class GainOptimiser:
|
||||||
:param components: List of components, where each component is a dictionary with keys "id", "cost" and "gain"
|
:param components: List of components, where each component is a dictionary with keys "id", "cost" and "gain"
|
||||||
:param max_cost: Maximum cost constraint
|
:param max_cost: Maximum cost constraint
|
||||||
: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
|
||||||
|
is infeasible. Defaults to True.
|
||||||
"""
|
"""
|
||||||
self.components = components
|
self.components = components
|
||||||
self.max_cost = max_cost
|
self.max_cost = max_cost
|
||||||
|
|
@ -32,6 +34,7 @@ class GainOptimiser:
|
||||||
self.solution = []
|
self.solution = []
|
||||||
self.solution_gain = None
|
self.solution_gain = None
|
||||||
self.solution_cost = None
|
self.solution_cost = None
|
||||||
|
self.allow_slack = allow_slack
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
# Initialize Model
|
# Initialize Model
|
||||||
|
|
@ -124,15 +127,18 @@ class GainOptimiser:
|
||||||
if (self.m.status == OptimizationStatus.INFEASIBLE) or (
|
if (self.m.status == OptimizationStatus.INFEASIBLE) or (
|
||||||
(self.m.status == OptimizationStatus.OPTIMAL) and not len(solution)
|
(self.m.status == OptimizationStatus.OPTIMAL) and not len(solution)
|
||||||
):
|
):
|
||||||
logger.info("We have an infeasible model, setting up slack model")
|
if self.allow_slack:
|
||||||
self.setup_slack()
|
logger.info("We have an infeasible model, setting up slack model")
|
||||||
self.m.optimize()
|
self.setup_slack()
|
||||||
solution = [
|
self.m.optimize()
|
||||||
item for group, group_vars in zip(self.components, self.variables) for item, var in
|
solution = [
|
||||||
zip(group, group_vars)
|
item for group, group_vars in zip(self.components, self.variables) for item, var in
|
||||||
if
|
zip(group, group_vars)
|
||||||
var.x >= 0.99
|
if
|
||||||
]
|
var.x >= 0.99
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
logger.info("Infeasible but slack disabled - returning empty solution")
|
||||||
|
|
||||||
self.solution = solution
|
self.solution = solution
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,56 @@
|
||||||
|
import pandas as pd
|
||||||
import backend.app.assumptions as assumptions
|
import backend.app.assumptions as assumptions
|
||||||
|
from backend.Property import Property
|
||||||
|
from backend.app.plan.schemas import PlanTriggerRequest
|
||||||
|
from backend.app.utils import epc_to_sap_lower_bound
|
||||||
|
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||||
|
|
||||||
|
|
||||||
def prepare_input_measures(property_recommendations, goal, needs_ventilation):
|
def prepare_input_measures(property_recommendations, goal, needs_ventilation):
|
||||||
"""
|
"""
|
||||||
Basic function to convert recommendations_to_upload to a format that is
|
Prepares a nested list of measure options for optimisation.
|
||||||
suitable for the optimiser - large
|
|
||||||
:param property_recommendations: object containing the recommendations, created in the plan trigger api
|
Each sublist represents all available variants of a single measure type (e.g. all solar PV options).
|
||||||
:param goal: goal to be optimised for, should be one of the keys in gain_map. E.g. if the gain is SAP points,
|
Within each sublist, each measure is represented as a dictionary containing:
|
||||||
the goal should reflect that desired gain
|
- id: unique recommendation identifier
|
||||||
:param needs_ventilation: boolean to indicate if the property needs ventilation
|
- cost: total cost of the measure (including ventilation if bundled)
|
||||||
:return: Nested list of input measures
|
- gain: the relevant gain metric based on the selected goal
|
||||||
|
- type: the measure type, optionally combined with ventilation (e.g. "wall_insulation+mechanical_ventilation")
|
||||||
|
|
||||||
|
Ventilation bundling:
|
||||||
|
- If a property needs ventilation, and a measure type requires it (as defined in
|
||||||
|
assumptions.measures_needing_ventilation),
|
||||||
|
the ventilation cost and gain are added to that measure’s values.
|
||||||
|
|
||||||
|
Filtering:
|
||||||
|
- Measures with negative `energy_cost_savings` are excluded.
|
||||||
|
- Solar PV options with batteries are excluded (currently handled by a placeholder bitwise NOT).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
property_recommendations : list[list[dict]]
|
||||||
|
Nested list of recommendations for a property. Each inner list represents variations of the same measure type.
|
||||||
|
goal : str
|
||||||
|
Optimisation goal, one of: "Increasing EPC", "Energy Savings", "Reducing CO2 emissions".
|
||||||
|
needs_ventilation : bool
|
||||||
|
Whether the property requires mechanical ventilation to accompany certain measures.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[list[dict]]
|
||||||
|
Nested list of prepared measure options, ready for input into the optimiser.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
goal_map = {
|
goal_map = {
|
||||||
"Increasing EPC": "sap_points"
|
"Increasing EPC": "sap_points",
|
||||||
|
"Energy Savings": "kwh_savings",
|
||||||
|
"Reducing CO2 emissions": "co2_equivalent_savings",
|
||||||
}
|
}
|
||||||
|
|
||||||
goal_key = goal_map[goal]
|
goal_key = goal_map[goal]
|
||||||
if not goal_key:
|
if not goal_key:
|
||||||
raise NotImplementedError("Not implemented this gain type - investigate me")
|
raise NotImplementedError("Not implemented this gain type - investigate me")
|
||||||
|
|
||||||
# We ony ever have one ventilation measure with now
|
|
||||||
ventilation_recommendation = next(
|
ventilation_recommendation = next(
|
||||||
(measure[0] for measure in property_recommendations if measure[0]["type"] == "mechanical_ventilation"),
|
(measure[0] for measure in property_recommendations if measure[0]["type"] == "mechanical_ventilation"),
|
||||||
{}
|
{}
|
||||||
|
|
@ -29,22 +59,22 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation):
|
||||||
input_measures = []
|
input_measures = []
|
||||||
for recs in property_recommendations:
|
for recs in property_recommendations:
|
||||||
|
|
||||||
|
# Skip ventilation as a standalone optimisation option (it will be bundled)
|
||||||
if needs_ventilation and recs[0]["type"] == "mechanical_ventilation":
|
if needs_ventilation and recs[0]["type"] == "mechanical_ventilation":
|
||||||
# If we house needs ventilation, ventilation will be packaged with the fabric measure so
|
|
||||||
# we don't need to optimise it independently
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Filter out solar PV with batteries
|
||||||
if recs[0]["type"] == "solar_pv":
|
if recs[0]["type"] == "solar_pv":
|
||||||
# if the recommendation is a solar recommendation with a battery, we exclude it from the optimisation.
|
|
||||||
recs = [r for r in recs if ~r["has_battery"]]
|
recs = [r for r in recs if ~r["has_battery"]]
|
||||||
|
|
||||||
|
# Only include measures with non-negative cost savings
|
||||||
recs_to_append = [rec for rec in recs if rec["energy_cost_savings"] >= 0]
|
recs_to_append = [rec for rec in recs if rec["energy_cost_savings"] >= 0]
|
||||||
if not recs_to_append:
|
if not recs_to_append:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Build enriched measure data
|
||||||
to_append = []
|
to_append = []
|
||||||
for rec in recs:
|
for rec in recs:
|
||||||
# We bundle the impact of ventilation with the measure
|
|
||||||
total = (
|
total = (
|
||||||
rec["total"] + ventilation_recommendation["total"]
|
rec["total"] + ventilation_recommendation["total"]
|
||||||
if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
|
if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
|
||||||
|
|
@ -55,23 +85,232 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation):
|
||||||
if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
|
if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
|
||||||
else rec[goal_key]
|
else rec[goal_key]
|
||||||
)
|
)
|
||||||
|
|
||||||
rec_type = (
|
rec_type = (
|
||||||
"+".join(
|
f"{rec['type']}+{ventilation_recommendation['type']}"
|
||||||
[rec["type"], ventilation_recommendation["type"]]
|
if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
|
||||||
) if rec["type"] in assumptions.measures_needing_ventilation and needs_ventilation
|
|
||||||
else rec["type"]
|
else rec["type"]
|
||||||
)
|
)
|
||||||
|
|
||||||
to_append.append(
|
to_append.append(
|
||||||
{
|
{"id": rec["recommendation_id"], "cost": total, "gain": gain, "type": rec_type}
|
||||||
"id": rec["recommendation_id"],
|
|
||||||
"cost": total,
|
|
||||||
"gain": gain,
|
|
||||||
"type": rec_type
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
input_measures.append(to_append)
|
input_measures.append(to_append)
|
||||||
|
|
||||||
return input_measures
|
return input_measures
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_fixed_gain(property_required_measures, recommendations, p, needs_ventilation):
|
||||||
|
"""
|
||||||
|
Calculates the total "fixed gain" from required measures for a property.
|
||||||
|
|
||||||
|
Required measures are applied regardless of optimisation. This function:
|
||||||
|
- Finds the maximum SAP points for each required measure type.
|
||||||
|
- Sums those max SAP values into a fixed gain total.
|
||||||
|
- Adds the SAP points for mechanical ventilation if:
|
||||||
|
* The property needs ventilation, and
|
||||||
|
* At least one required measure needs ventilation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
property_required_measures : list[list[dict]]
|
||||||
|
Nested list of required measures for the property.
|
||||||
|
recommendations : dict
|
||||||
|
All recommendations for all properties, keyed by property id.
|
||||||
|
p : object
|
||||||
|
Property object (must have .id).
|
||||||
|
needs_ventilation : bool
|
||||||
|
Whether ventilation should be bundled with certain measures.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
float
|
||||||
|
Total fixed SAP gain from required measures (and ventilation, if applicable).
|
||||||
|
"""
|
||||||
|
if not property_required_measures:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sap_by_type = [
|
||||||
|
{"type": rec["type"], "sap_points": rec["sap_points"]}
|
||||||
|
for recs in property_required_measures for rec in recs
|
||||||
|
]
|
||||||
|
|
||||||
|
max_per_type = pd.DataFrame(sap_by_type).groupby("type")["sap_points"].max().to_dict()
|
||||||
|
fixed_gain = sum(max_per_type.values())
|
||||||
|
|
||||||
|
required_types = {rec["type"] for rec in sap_by_type}
|
||||||
|
if needs_ventilation and any(r in required_types for r in assumptions.measures_needing_ventilation):
|
||||||
|
fixed_gain += next(
|
||||||
|
(r[0]["sap_points"] for r in recommendations[p.id] if r[0]["type"] == "mechanical_ventilation"),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
return fixed_gain
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_gain(body: PlanTriggerRequest, p: Property, fixed_gain: float) -> float | None:
|
||||||
|
"""
|
||||||
|
Calculates the target gain value for optimisation based on the goal.
|
||||||
|
|
||||||
|
- For "Increasing EPC": Computes the SAP gain needed to reach the target EPC,
|
||||||
|
applies a slack adjustment (via CostOptimiser), and subtracts fixed gains from required measures.
|
||||||
|
- For "Energy Savings" or "Reducing CO2 emissions": Returns None,
|
||||||
|
which signals the optimiser to simply maximise gain under a budget.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
body : object
|
||||||
|
Request body object containing optimisation settings (goal, goal_value, simulate_sap_10, etc.)
|
||||||
|
p : object
|
||||||
|
Property object with EPC data (must have p.data["current-energy-efficiency"]).
|
||||||
|
fixed_gain : float
|
||||||
|
Total fixed gain from required measures (returned by calculate_fixed_gain).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
float or None
|
||||||
|
Required SAP gain for EPC, or None for non-EPC goals.
|
||||||
|
"""
|
||||||
|
if body.goal == "Increasing EPC":
|
||||||
|
current_sap = int(p.data["current-energy-efficiency"])
|
||||||
|
gain = CostOptimiser.calculate_sap_gain_with_slack(
|
||||||
|
epc_to_sap_lower_bound(body.goal_value) - current_sap
|
||||||
|
) - fixed_gain
|
||||||
|
if body.simulate_sap_10:
|
||||||
|
gain += 3
|
||||||
|
return max(gain, 0)
|
||||||
|
elif body.goal in ["Energy Savings", "Reducing CO2 emissions"]:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"Goal {body.goal} is not supported")
|
||||||
|
|
||||||
|
|
||||||
|
def add_required_measures(property_id, property_required_measures, recommendations, selected):
|
||||||
|
"""
|
||||||
|
Ensures the cheapest variant of each required measure is added to the selected recommendations.
|
||||||
|
|
||||||
|
For each required measure type, this function:
|
||||||
|
- Finds the lowest-cost variant not already selected.
|
||||||
|
- Adds it to the selected recommendation IDs.
|
||||||
|
- Returns a flattened list of all selected measure details for final output.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
property_id : int
|
||||||
|
Unique identifier for the property.
|
||||||
|
property_required_measures : list[list[dict]]
|
||||||
|
Nested list of required measures for the property.
|
||||||
|
recommendations : dict
|
||||||
|
All recommendations for all properties, keyed by property id.
|
||||||
|
selected : set
|
||||||
|
Set of already selected recommendation IDs from the optimiser.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[dict]
|
||||||
|
Flat list of selected measure details, each containing:
|
||||||
|
{"id", "cost", "gain", "type"}
|
||||||
|
"""
|
||||||
|
for recs in property_required_measures:
|
||||||
|
cheapest = min(
|
||||||
|
(rec for rec in recs if rec["recommendation_id"] not in selected),
|
||||||
|
key=lambda rec: rec["total"],
|
||||||
|
)
|
||||||
|
selected.add(cheapest["recommendation_id"])
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"id": rec["recommendation_id"], "cost": rec["total"], "gain": rec["sap_points"], "type": rec["type"]}
|
||||||
|
for recs in recommendations[property_id] for rec in recs
|
||||||
|
if rec["recommendation_id"] in selected
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def add_best_practice_measures(property_id, solution, recommendations, selected):
|
||||||
|
"""
|
||||||
|
Ensures best-practice measures like ventilation and trickle vents are included
|
||||||
|
in the selected recommendations when appropriate.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If a measure requiring ventilation is selected AND ventilation is not already present,
|
||||||
|
add the corresponding mechanical ventilation recommendation.
|
||||||
|
- Always add trickle vents if they exist in the recommendations.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
property_id : int
|
||||||
|
The unique identifier for the property.
|
||||||
|
solution : list[dict]
|
||||||
|
The current list of selected measures (each containing id, type, gain, cost).
|
||||||
|
recommendations : dict
|
||||||
|
All recommendations for all properties, keyed by property id.
|
||||||
|
selected : set
|
||||||
|
Set of already selected recommendation IDs.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
set
|
||||||
|
Updated set of selected recommendation IDs, including ventilation and trickle vents if applicable.
|
||||||
|
"""
|
||||||
|
# Check if any selected measure requires ventilation
|
||||||
|
ventilation_selected = [r for r in solution if "+mechanical_ventilation" in r["type"]]
|
||||||
|
|
||||||
|
# If ventilation has been selected, or one of the measures needs ventilation, we need to ensure ventilation is
|
||||||
|
# included
|
||||||
|
needs_ventilation = any(
|
||||||
|
x in [r["type"] for r in solution] for x in assumptions.measures_needing_ventilation
|
||||||
|
) or len(ventilation_selected) > 0
|
||||||
|
|
||||||
|
if needs_ventilation:
|
||||||
|
ventilation_rec = next(
|
||||||
|
(r[0] for r in recommendations[property_id] if r[0]["type"] == "mechanical_ventilation"),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if ventilation_rec:
|
||||||
|
selected.add(ventilation_rec["recommendation_id"])
|
||||||
|
|
||||||
|
# Always add trickle vents if available
|
||||||
|
trickle_vents_rec = next(
|
||||||
|
(r[0] for r in recommendations[property_id] if r[0]["type"] == "trickle_vents"),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if trickle_vents_rec:
|
||||||
|
selected.add(trickle_vents_rec["recommendation_id"])
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_recommendations_with_defaults(property_id, recommendations, selected):
|
||||||
|
"""
|
||||||
|
Flattens nested recommendation lists for a property and marks which
|
||||||
|
recommendations were selected.
|
||||||
|
|
||||||
|
Each recommendation dict is copied and an extra key `default` is added:
|
||||||
|
- True if the recommendation ID is in `selected`
|
||||||
|
- False otherwise
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
property_id : int
|
||||||
|
The unique identifier for the property.
|
||||||
|
recommendations : dict
|
||||||
|
All recommendations for all properties, keyed by property id.
|
||||||
|
Each value is a list of lists (grouped by measure type).
|
||||||
|
selected : set
|
||||||
|
Set of selected recommendation IDs.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[dict]
|
||||||
|
A flattened list of recommendation dicts for the given property,
|
||||||
|
each with an added `default` field.
|
||||||
|
"""
|
||||||
|
final_recommendations = [
|
||||||
|
[
|
||||||
|
{**rec, "default": rec["recommendation_id"] in selected}
|
||||||
|
for rec in recommendations_by_type
|
||||||
|
]
|
||||||
|
for recommendations_by_type in recommendations[property_id]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Flatten the nested list of lists into a single list
|
||||||
|
return [rec for recommendations_by_type in final_recommendations for rec in recommendations_by_type]
|
||||||
|
|
|
||||||
|
|
@ -1193,11 +1193,10 @@ testing_examples = [
|
||||||
'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None,
|
'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None,
|
||||||
'sheating-env-eff': None
|
'sheating-env-eff': None
|
||||||
},
|
},
|
||||||
"heating_measure_types": [
|
"heating_measure_types": [],
|
||||||
'high_heat_retention_storage_heater'
|
"notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating"
|
||||||
],
|
"don't recommend anything. HHRSH isn't recommended as with underfloor heating, it's quite"
|
||||||
"notes": "This property is a flag, without mains gas connection. Currently has underfloor electric heating"
|
"disruptive"
|
||||||
"so we recommend HHR"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epc": {
|
"epc": {
|
||||||
|
|
@ -1239,12 +1238,9 @@ testing_examples = [
|
||||||
},
|
},
|
||||||
"heating_measure_types": [
|
"heating_measure_types": [
|
||||||
'air_source_heat_pump',
|
'air_source_heat_pump',
|
||||||
'boiler_upgrade',
|
|
||||||
'boiler_upgrade',
|
|
||||||
'high_heat_retention_storage_heater'
|
'high_heat_retention_storage_heater'
|
||||||
],
|
],
|
||||||
"notes": "The property has warm air electricaire heating, so we recommend ASHP and HHR. It also has a mains"
|
"notes": "The property has warm air electricaire heating, so we recommend ASHP and HHR"
|
||||||
"connection so we recommend a gas condensing boiler"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"epc": {
|
"epc": {
|
||||||
|
|
@ -1287,9 +1283,8 @@ testing_examples = [
|
||||||
'sheating-env-eff': None
|
'sheating-env-eff': None
|
||||||
},
|
},
|
||||||
"heating_measure_types": [
|
"heating_measure_types": [
|
||||||
'boiler_upgrade',
|
|
||||||
'boiler_upgrade',
|
|
||||||
],
|
],
|
||||||
"notes": "This property has warm air mains gas heating, so we recommend a gas condensing boiler"
|
"notes": "This property has warm air mains gas heating; we recommend no heating upgrades as the efficiency is"
|
||||||
|
"good"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
350
recommendations/tests/test_data/measures_to_optimise.py
Normal file
350
recommendations/tests/test_data/measures_to_optimise.py
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
import datetime
|
||||||
|
import numpy as np
|
||||||
|
from numpy import nan
|
||||||
|
from pandas import Timestamp
|
||||||
|
|
||||||
|
measures_to_optimise = [
|
||||||
|
[{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation',
|
||||||
|
'description': 'EWI Pro EPS external wall insulation system with '
|
||||||
|
'Brick Slip finish',
|
||||||
|
'depth': 150.0, 'depth_unit': 'mm', 'cost': None,
|
||||||
|
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
|
||||||
|
'r_value_unit': 'square_meter_kelvin_per_watt',
|
||||||
|
'thermal_conductivity': 0.038,
|
||||||
|
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS',
|
||||||
|
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
|
||||||
|
'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0,
|
||||||
|
'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0,
|
||||||
|
'total_cost': 298.35, 'notes': 'This is the quoted value from SCIS',
|
||||||
|
'is_installer_quote': True, 'quantity': 63.98796761892035,
|
||||||
|
'quantity_unit': 'm2', 'total': 19090.810139104888,
|
||||||
|
'labour_hours': 0.0, 'labour_days': 0.0}],
|
||||||
|
'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation',
|
||||||
|
'description': 'Install 150mm EWI Pro EPS external wall insulation system with Brick Slip '
|
||||||
|
'finish on external walls',
|
||||||
|
'starting_u_value': 1.7, 'new_u_value': 0.32, 'already_installed': False,
|
||||||
|
'sap_points': np.float64(9.6),
|
||||||
|
'simulation_config': {'is_as_built_ending': False, 'walls_is_assumed_ending': False,
|
||||||
|
'walls_insulation_thickness_ending': 'average',
|
||||||
|
'external_insulation_ending': True, 'walls_energy_eff_ending': 'Good',
|
||||||
|
'walls_thermal_transmittance_ending': 0.23},
|
||||||
|
'description_simulation': {'walls-description': 'Solid brick, with external insulation',
|
||||||
|
'walls-energy-eff': 'Good'}, 'total': 19090.810139104888,
|
||||||
|
'labour_hours': 0.0, 'labour_days': 0.0, 'survey': False, 'recommendation_id': '0_phase=0',
|
||||||
|
'efficiency': 11229.568317120522, 'co2_equivalent_savings': np.float64(0.5),
|
||||||
|
'heat_demand': np.float64(37.099999999999994), 'kwh_savings': np.float64(1813.199999999999),
|
||||||
|
'energy_cost_savings': np.float64(135.03007058823516)}, {'phase': 0, 'parts': [
|
||||||
|
{'id': 2373, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish',
|
||||||
|
'depth': 95.0,
|
||||||
|
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125,
|
||||||
|
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032,
|
||||||
|
'thermal_conductivity_unit': None,
|
||||||
|
'link': 'SCIS', 'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True,
|
||||||
|
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1,
|
||||||
|
'plant_cost': 0.0, 'total_cost': 89.0, 'notes': None, 'is_installer_quote': True,
|
||||||
|
'quantity': 63.98796761892035,
|
||||||
|
'quantity_unit': 'm2', 'total': 5694.929118083911, 'labour_hours': 134.37473199973275,
|
||||||
|
'labour_days': 4.199210374991648}], 'type': 'internal_wall_insulation',
|
||||||
|
'measure_type': 'internal_wall_insulation',
|
||||||
|
'description': 'Install 95mm SWIP '
|
||||||
|
'EcoBatt & '
|
||||||
|
'Plastered finish '
|
||||||
|
'on internal walls',
|
||||||
|
'starting_u_value': 1.7,
|
||||||
|
'new_u_value': 0.32,
|
||||||
|
'already_installed': False,
|
||||||
|
'sap_points': 6,
|
||||||
|
'simulation_config': {
|
||||||
|
'is_as_built_ending': False,
|
||||||
|
'walls_is_assumed_ending': False,
|
||||||
|
'walls_insulation_thickness_ending': 'average',
|
||||||
|
'internal_insulation_ending':
|
||||||
|
True,
|
||||||
|
'walls_energy_eff_ending':
|
||||||
|
'Good',
|
||||||
|
'walls_thermal_transmittance_ending': 0.29},
|
||||||
|
'description_simulation': {
|
||||||
|
'walls-description': 'Solid '
|
||||||
|
'brick, '
|
||||||
|
'with '
|
||||||
|
'internal '
|
||||||
|
'insulation',
|
||||||
|
'walls-energy-eff': 'Good'},
|
||||||
|
'total': 5694.929118083911,
|
||||||
|
'labour_hours': 134.37473199973275,
|
||||||
|
'labour_days': 4.199210374991648,
|
||||||
|
'survey': True,
|
||||||
|
'recommendation_id': '1_phase=0',
|
||||||
|
'efficiency': 3349.6383047552417,
|
||||||
|
'co2_equivalent_savings': np.float64(
|
||||||
|
0.5), 'heat_demand': np.float64(
|
||||||
|
35.30000000000001), 'kwh_savings': np.float64(1424.699999999999), 'energy_cost_savings': np.float64(
|
||||||
|
106.09824705882352)}], [{'phase': 1, 'parts': [
|
||||||
|
{'id': 2351, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 300.0,
|
||||||
|
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
|
||||||
|
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
|
||||||
|
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS',
|
||||||
|
'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True, 'prime_material_cost': None,
|
||||||
|
'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 15.0,
|
||||||
|
'notes': 'This is the cost if there is less than 100mm existing insulation', 'is_installer_quote': True,
|
||||||
|
'quantity': 63.98796761892035, 'quantity_unit': 'm2', 'total': 645.0, 'labour_hours': 8, 'labour_days': 1}],
|
||||||
|
'type': 'loft_insulation', 'measure_type': 'loft_insulation',
|
||||||
|
'description': 'Install 300mm of Knauf Loft Roll 44 glass fibre roll in your loft',
|
||||||
|
'starting_u_value': 2.3, 'new_u_value': 2.3, 'sap_points': np.float64(2.4),
|
||||||
|
'already_installed': False,
|
||||||
|
'simulation_config': {'is_loft_ending': True, 'roof_is_assumed_ending': False,
|
||||||
|
'roof_insulation_thickness_ending': '300',
|
||||||
|
'roof_thermal_transmittance_ending': 2.3,
|
||||||
|
'roof_energy_eff_ending': 'Very Good'},
|
||||||
|
'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation',
|
||||||
|
'roof-energy-eff': 'Very Good'}, 'total': 645.0,
|
||||||
|
'labour_hours': 8, 'labour_days': 1, 'survey': False,
|
||||||
|
'recommendation_id': '2_phase=1',
|
||||||
|
'efficiency': 278.1347826086957,
|
||||||
|
'co2_equivalent_savings': np.float64(0.10000000000000009),
|
||||||
|
'heat_demand': np.float64(1.5), 'kwh_savings': np.float64(572.5500000000002),
|
||||||
|
'energy_cost_savings': np.float64(42.638135294117774)}], [{'phase': 2, 'parts': [
|
||||||
|
{'id': 2329, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': 0.0,
|
||||||
|
'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan,
|
||||||
|
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
|
||||||
|
'thermal_conductivity_unit': None,
|
||||||
|
'link': 'SCIS', 'created_at': datetime.datetime(2025, 3, 16, 15, 26, 22, 379496), 'is_active': True,
|
||||||
|
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0,
|
||||||
|
'plant_cost': 0.0, 'total_cost': 350.0, 'notes': None, 'is_installer_quote': True, 'total': 700.0,
|
||||||
|
'quantity': 2,
|
||||||
|
'quantity_unit': 'part'}
|
||||||
|
], 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation',
|
||||||
|
'description':
|
||||||
|
'Install 2 '
|
||||||
|
'Mechanical '
|
||||||
|
'Extract '
|
||||||
|
'Ventilation units',
|
||||||
|
'starting_u_value': None,
|
||||||
|
'new_u_value': None,
|
||||||
|
'already_installed': False,
|
||||||
|
'sap_points': np.float64(
|
||||||
|
-0.10000000000000142),
|
||||||
|
'heat_demand': np.float64(
|
||||||
|
-3.3999999999999773),
|
||||||
|
'kwh_savings': np.float64(
|
||||||
|
-45.899999999999636),
|
||||||
|
'co2_equivalent_savings': np.float64(
|
||||||
|
0.0),
|
||||||
|
'energy_cost_savings': np.float64(
|
||||||
|
-3.4181999999999846),
|
||||||
|
'total': 700.0,
|
||||||
|
'labour_hours': 8,
|
||||||
|
'labour_days': 1.0,
|
||||||
|
'simulation_config': {
|
||||||
|
'mechanical_ventilation_ending': 'mechanical, extract only'},
|
||||||
|
'description_simulation': {
|
||||||
|
'mechanical-ventilation': 'mechanical, extract only'},
|
||||||
|
'recommendation_id': '3_phase=2',
|
||||||
|
'efficiency': 0}
|
||||||
|
], [
|
||||||
|
{'phase': 3, 'parts': [{'id': 2409, 'type': 'suspended_floor_insulation',
|
||||||
|
'description': 'Q-bot underfloor insulation', 'depth': 75.0,
|
||||||
|
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
|
||||||
|
'r_value_per_mm': 0.045454547,
|
||||||
|
'r_value_unit': 'square_meter_kelvin_per_watt',
|
||||||
|
'thermal_conductivity': 0.022,
|
||||||
|
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
|
||||||
|
'link': 'SCIS',
|
||||||
|
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
|
||||||
|
'is_active': True, 'prime_material_cost': None,
|
||||||
|
'material_cost': 0.0, 'labour_cost': 0.0,
|
||||||
|
'labour_hours_per_unit': 1.63, 'plant_cost': 0.0,
|
||||||
|
'total_cost': 93.75,
|
||||||
|
'notes': 'Linearly interpolated based on Qbot costs',
|
||||||
|
'is_installer_quote': True, 'quantity': 43.0, 'quantity_unit': 'm2',
|
||||||
|
'total': 4031.25, 'labour_hours': 70.08999999999999,
|
||||||
|
'labour_days': 2.920416666666666}],
|
||||||
|
'type': 'suspended_floor_insulation', 'measure_type': 'suspended_floor_insulation',
|
||||||
|
'description': 'Install 75mm Q-bot underfloor insulation insulation in suspended floor',
|
||||||
|
'starting_u_value': 0.83, 'new_u_value': 0.22, 'sap_points': 2, 'survey': True,
|
||||||
|
'already_installed': False, 'simulation_config': {'floor_is_assumed_ending': False,
|
||||||
|
'floor_insulation_thickness_ending':
|
||||||
|
'average',
|
||||||
|
'floor_thermal_transmittance_ending':
|
||||||
|
0.685593},
|
||||||
|
'description_simulation': {'floor-description': 'Suspended, insulated'}, 'total': 4031.25,
|
||||||
|
'labour_hours': 70.08999999999999, 'labour_days': 2.920416666666666,
|
||||||
|
'recommendation_id': '4_phase=3', 'efficiency': 4856.707710843373,
|
||||||
|
'co2_equivalent_savings': np.float64(0.20000000000000018), 'heat_demand': np.float64(33.5),
|
||||||
|
'kwh_savings': np.float64(1018.0999999999995),
|
||||||
|
'energy_cost_savings': np.float64(75.8185058823529)}], [
|
||||||
|
{'phase': 4, 'parts': [], 'type': 'low_energy_lighting',
|
||||||
|
'measure_type': 'low_energy_lighting',
|
||||||
|
'description': 'Install low energy lighting in 14 outlets', 'starting_u_value': None,
|
||||||
|
'new_u_value': None, 'already_installed': False, 'sap_points': 2, 'kwh_savings': 766.5,
|
||||||
|
'energy_cost_savings': 197.22044999999997,
|
||||||
|
'co2_equivalent_savings': np.float64(0.09999999999999964),
|
||||||
|
'description_simulation': {'lighting-energy-eff': 'Very Good',
|
||||||
|
'lighting-description': 'Low energy lighting in all fixed '
|
||||||
|
'outlets',
|
||||||
|
'low-energy-lighting': 100}, 'total': 58.8, 'labour_hours': 1,
|
||||||
|
'labour_days': 0.125, 'survey': True, 'recommendation_id': '5_phase=4', 'efficiency': 29.4,
|
||||||
|
'heat_demand': np.float64(5.099999999999994)}], [
|
||||||
|
{'type': 'heating', 'phase': 5, 'measure_type': 'time_temperature_zone_control',
|
||||||
|
'parts': [],
|
||||||
|
'description': 'Upgrade heating controls to Smart Thermostats, room sensors and smart '
|
||||||
|
'radiator valves (time & temperature zone control)',
|
||||||
|
'total': 739.576, 'subtotal': 700.48, 'vat': 39.096000000000004,
|
||||||
|
'labour_hours': 3.6199999999999997, 'labour_days': np.float64(1.0),
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(2.9),
|
||||||
|
'already_installed': False,
|
||||||
|
'simulation_config': {'thermostatic_control_ending': 'time and temperature zone control',
|
||||||
|
'switch_system_ending': None, 'trvs_ending': None,
|
||||||
|
'mainheatc_energy_eff_ending': 'Very Good'},
|
||||||
|
'description_simulation': {'mainheatcont-description': 'Time and temperature zone control',
|
||||||
|
'mainheatc-energy-eff': 'Very Good'},
|
||||||
|
'recommendation_id': '6_phase=5', 'efficiency': 739.576,
|
||||||
|
'co2_equivalent_savings': np.float64(0.30000000000000027),
|
||||||
|
'heat_demand': np.float64(6.599999999999994), 'kwh_savings': np.float64(853.6999999999998),
|
||||||
|
'energy_cost_savings': np.float64(63.57554117647055)}], [
|
||||||
|
{'phase': 6, 'parts': [], 'type': 'secondary_heating', 'measure_type': 'secondary_heating',
|
||||||
|
'description': 'Remove the secondary heating system', 'starting_u_value': None,
|
||||||
|
'new_u_value': None, 'sap_points': np.float64(3.6), 'already_installed': False,
|
||||||
|
'total': 30.0, 'subtotal': 25.0, 'vat': 5.0, 'labour_hours': 3.0,
|
||||||
|
'labour_days': np.float64(1.0),
|
||||||
|
'simulation_config': {'secondheat_description_ending': 'None'},
|
||||||
|
'description_simulation': {'secondheat-description': 'None'},
|
||||||
|
'recommendation_id': '7_phase=6', 'efficiency': 30.0,
|
||||||
|
'co2_equivalent_savings': np.float64(0.10000000000000009),
|
||||||
|
'heat_demand': np.float64(15.400000000000006),
|
||||||
|
'kwh_savings': np.float64(202.30000000000018),
|
||||||
|
'energy_cost_savings': np.float64(15.065400000000011)}], [
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
|
||||||
|
'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
|
||||||
|
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(65.0)},
|
||||||
|
'recommendation_id': '8_phase=7', 'efficiency': np.float64(462.54923076923075),
|
||||||
|
'co2_equivalent_savings': np.float64(0.47347873833399995),
|
||||||
|
'heat_demand': np.float64(88.69999999999999),
|
||||||
|
'kwh_savings': np.float64(2040.8566307499998),
|
||||||
|
'energy_cost_savings': np.float64(525.1124110919749)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
|
||||||
|
'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
|
||||||
|
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(65.0)},
|
||||||
|
'recommendation_id': '9_phase=7', 'efficiency': np.float64(810.5390769230769),
|
||||||
|
'co2_equivalent_savings': np.float64(0.6628702336675999),
|
||||||
|
'heat_demand': np.float64(88.69999999999999),
|
||||||
|
'kwh_savings': np.float64(2857.1992830499994),
|
||||||
|
'energy_cost_savings': np.float64(735.1573755287648)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
|
||||||
|
'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
|
||||||
|
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3692.66794),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(60.0)},
|
||||||
|
'recommendation_id': '10_phase=7', 'efficiency': np.float64(485.54099999999994),
|
||||||
|
'co2_equivalent_savings': np.float64(0.42834948104),
|
||||||
|
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397),
|
||||||
|
'energy_cost_savings': np.float64(475.0617304809999)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
|
||||||
|
'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
|
||||||
|
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3692.66794),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(60.0)},
|
||||||
|
'recommendation_id': '11_phase=7', 'efficiency': np.float64(862.5299999999999),
|
||||||
|
'co2_equivalent_savings': np.float64(0.599689273456),
|
||||||
|
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558),
|
||||||
|
'energy_cost_savings': np.float64(665.0864226734)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
|
||||||
|
'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
|
||||||
|
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(55.0)},
|
||||||
|
'recommendation_id': '12_phase=7', 'efficiency': np.float64(512.964),
|
||||||
|
'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3),
|
||||||
|
'kwh_savings': np.float64(1650.2708274),
|
||||||
|
'energy_cost_savings': np.float64(424.61468389001993)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
|
||||||
|
'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
|
||||||
|
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(55.0)},
|
||||||
|
'recommendation_id': '13_phase=7', 'efficiency': np.float64(924.2247272727273),
|
||||||
|
'co2_equivalent_savings': np.float64(0.53600796473952), 'heat_demand': np.float64(78.3),
|
||||||
|
'kwh_savings': np.float64(2310.3791583599996),
|
||||||
|
'energy_cost_savings': np.float64(594.4605574460278)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
|
||||||
|
'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
|
||||||
|
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(45.0)},
|
||||||
|
'recommendation_id': '14_phase=7', 'efficiency': np.float64(606.5253333333333),
|
||||||
|
'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0),
|
||||||
|
'kwh_savings': np.float64(1453.5933906),
|
||||||
|
'energy_cost_savings': np.float64(374.00957940138)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
|
||||||
|
'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
|
||||||
|
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(45.0)},
|
||||||
|
'recommendation_id': '15_phase=7', 'efficiency': np.float64(1109.1773333333333),
|
||||||
|
'co2_equivalent_savings': np.float64(0.47212713326688), 'heat_demand': np.float64(64.0),
|
||||||
|
'kwh_savings': np.float64(2035.03074684),
|
||||||
|
'energy_cost_savings': np.float64(523.6134111619319)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
|
||||||
|
'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
|
||||||
|
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2510.25188),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(40.0)},
|
||||||
|
'recommendation_id': '16_phase=7', 'efficiency': np.float64(659.3565),
|
||||||
|
'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3),
|
||||||
|
'kwh_savings': np.float64(1255.12594),
|
||||||
|
'energy_cost_savings': np.float64(322.94390436199996)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
|
||||||
|
'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
|
||||||
|
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2510.25188),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(40.0)},
|
||||||
|
'recommendation_id': '17_phase=7', 'efficiency': np.float64(1224.84),
|
||||||
|
'co2_equivalent_savings': np.float64(0.40766490531199995), 'heat_demand': np.float64(54.3),
|
||||||
|
'kwh_savings': np.float64(1757.1763159999998),
|
||||||
|
'energy_cost_savings': np.float64(452.1214661067999)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
|
||||||
|
'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
|
||||||
|
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2096.682636),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(35.0)},
|
||||||
|
'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856),
|
||||||
|
'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5),
|
||||||
|
'kwh_savings': np.float64(1048.341318), 'energy_cost_savings': np.float64(269.7382211214)},
|
||||||
|
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.',
|
||||||
|
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
|
||||||
|
'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0,
|
||||||
|
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
|
||||||
|
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2096.682636),
|
||||||
|
'description_simulation': {'photo-supply': np.float64(35.0)},
|
||||||
|
'recommendation_id': '19_phase=7', 'efficiency': np.float64(1373.5491428571427),
|
||||||
|
'co2_equivalent_savings': np.float64(0.3405012600864), 'heat_demand': np.float64(48.5),
|
||||||
|
'kwh_savings': np.float64(1467.6778451999999),
|
||||||
|
'energy_cost_savings': np.float64(377.6335095699599)}]
|
||||||
|
]
|
||||||
|
|
@ -40,15 +40,14 @@ class TestLightingRecommendations:
|
||||||
lr.recommend()
|
lr.recommend()
|
||||||
assert len(lr.recommendation) == 1
|
assert len(lr.recommendation) == 1
|
||||||
|
|
||||||
|
# Note - this test may be dependent on the ofgem price caps
|
||||||
assert lr.recommendation == [
|
assert lr.recommendation == [
|
||||||
{'phase': 0, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
|
{'phase': 0, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
|
||||||
'description': 'Install low energy lighting in 4 outlets', 'starting_u_value': None, 'new_u_value': None,
|
'description': 'Install low energy lighting in 4 outlets', 'starting_u_value': None, 'new_u_value': None,
|
||||||
'already_installed': False, 'sap_points': 0.4, 'kwh_savings': 219.0, 'energy_cost_savings': 54.4434,
|
'already_installed': False, 'sap_points': 0.4, 'kwh_savings': 219.0,
|
||||||
'co2_equivalent_savings': 0.035478, 'description_simulation': {'lighting-energy-eff': 'Very Good',
|
'energy_cost_savings': 56.348699999999994, 'co2_equivalent_savings': 0.035478,
|
||||||
'lighting-description': 'Low energy '
|
'description_simulation': {'lighting-energy-eff': 'Very Good',
|
||||||
'lighting in all '
|
'lighting-description': 'Low energy lighting in all fixed outlets',
|
||||||
'fixed outlets',
|
'low-energy-lighting': 100}, 'total': 188.76000000000002, 'subtotal': 157.3,
|
||||||
'low-energy-lighting': 100},
|
'vat': 31.460000000000004, 'contingency': 14.3, 'material': 80.0, 'labour_hours': 3.2, 'labour_days': 0.4,
|
||||||
'total': 188.76000000000002, 'subtotal': 157.3, 'vat': 31.460000000000004, 'contingency': 14.3,
|
'labour_cost': 63.0, 'survey': False}]
|
||||||
'material': 80.0, 'labour_hours': 3.2, 'labour_days': 0.4, 'labour_cost': 63.0, 'survey': False}
|
|
||||||
]
|
|
||||||
|
|
|
||||||
255
recommendations/tests/test_optimiser_functions.py
Normal file
255
recommendations/tests/test_optimiser_functions.py
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
import pytest
|
||||||
|
import numpy as np
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from recommendations.tests.test_data.measures_to_optimise import measures_to_optimise
|
||||||
|
from recommendations.optimiser import optimiser_functions
|
||||||
|
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
||||||
|
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrepareInputMeasures:
|
||||||
|
def test_returns_expected_structure_without_ventilation(self):
|
||||||
|
recs = [
|
||||||
|
[ # loft insulation measure
|
||||||
|
{"recommendation_id": "loft1", "type": "loft_insulation", "total": 100, "kwh_savings": 200,
|
||||||
|
"energy_cost_savings": 10, "has_battery": False},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=False)
|
||||||
|
assert isinstance(measures, list)
|
||||||
|
assert measures[0][0]["id"] == "loft1"
|
||||||
|
assert measures[0][0]["cost"] == 100
|
||||||
|
assert measures[0][0]["gain"] == 200
|
||||||
|
|
||||||
|
def test_bundles_ventilation_when_needed(self, monkeypatch):
|
||||||
|
# patch measures_needing_ventilation so that "wall_insulation" needs ventilation
|
||||||
|
monkeypatch.setattr(optimiser_functions.assumptions, "measures_needing_ventilation",
|
||||||
|
["internal_wall_insulation"])
|
||||||
|
recs = [
|
||||||
|
[{"recommendation_id": "wall1", "type": "internal_wall_insulation", "total": 500, "kwh_savings": 300,
|
||||||
|
"energy_cost_savings": 5, "has_battery": False}],
|
||||||
|
[{"recommendation_id": "vent1", "type": "mechanical_ventilation", "total": 50, "kwh_savings": 30,
|
||||||
|
"energy_cost_savings": 5, "has_battery": False}]
|
||||||
|
]
|
||||||
|
measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=True)
|
||||||
|
wall_option = measures[0][0]
|
||||||
|
assert wall_option["cost"] == 550
|
||||||
|
assert wall_option["gain"] == 330
|
||||||
|
assert "+mechanical_ventilation" in wall_option["type"]
|
||||||
|
|
||||||
|
def test_filters_out_negative_cost_savings(self):
|
||||||
|
recs = [
|
||||||
|
[{"recommendation_id": "bad1", "type": "loft_insulation", "total": 200, "kwh_savings": 100,
|
||||||
|
"energy_cost_savings": -5, "has_battery": False}],
|
||||||
|
]
|
||||||
|
measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=False)
|
||||||
|
assert measures == [] # should skip negative cost saving recs
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateFixedGain:
|
||||||
|
def test_no_required_measures_returns_zero(self):
|
||||||
|
fixed_gain = optimiser_functions.calculate_fixed_gain(
|
||||||
|
[], {}, SimpleNamespace(id="P1"), needs_ventilation=False
|
||||||
|
)
|
||||||
|
assert fixed_gain == 0
|
||||||
|
|
||||||
|
def test_sums_max_sap_points_per_type(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(optimiser_functions.assumptions, "measures_needing_ventilation",
|
||||||
|
["internal_wall_insulation"])
|
||||||
|
required_measures = [
|
||||||
|
[{"type": "internal_wall_insulation", "sap_points": 5},
|
||||||
|
{"type": "internal_wall_insulation", "sap_points": 10}],
|
||||||
|
[{"type": "loft_insulation", "sap_points": 3}]
|
||||||
|
]
|
||||||
|
recommendations = {"P1": [[{"type": "mechanical_ventilation", "sap_points": 2}]]}
|
||||||
|
prop = SimpleNamespace(id="P1")
|
||||||
|
gain = optimiser_functions.calculate_fixed_gain(
|
||||||
|
required_measures, recommendations, prop, needs_ventilation=True
|
||||||
|
)
|
||||||
|
# Should take max of wall (10) + loft (3) + ventilation (2)
|
||||||
|
assert gain == 15
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateGain:
|
||||||
|
def test_returns_none_for_energy_savings_goal(self):
|
||||||
|
body = SimpleNamespace(goal="Energy Savings")
|
||||||
|
prop = SimpleNamespace(data={"current-energy-efficiency": "50"})
|
||||||
|
gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=0)
|
||||||
|
assert gain is None
|
||||||
|
|
||||||
|
def test_calculates_gain_for_epc(self, monkeypatch):
|
||||||
|
# patch cost optimiser calculation
|
||||||
|
monkeypatch.setattr(optimiser_functions, "epc_to_sap_lower_bound", lambda goal_value: 69)
|
||||||
|
|
||||||
|
body = SimpleNamespace(goal="Increasing EPC", goal_value="C", simulate_sap_10=False)
|
||||||
|
prop = SimpleNamespace(data={"current-energy-efficiency": "50"})
|
||||||
|
gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=2)
|
||||||
|
assert gain == 18.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddRequiredMeasures:
|
||||||
|
def test_adds_cheapest_required_measure(self):
|
||||||
|
property_id = "P1"
|
||||||
|
required_measures = [
|
||||||
|
[{"recommendation_id": "a", "total": 100, "sap_points": 5, "type": "loft_insulation"},
|
||||||
|
{"recommendation_id": "b", "total": 80, "sap_points": 6, "type": "loft_insulation"}]
|
||||||
|
]
|
||||||
|
recommendations = {
|
||||||
|
"P1": [[{"recommendation_id": "a", "total": 100, "sap_points": 5, "type": "loft_insulation"},
|
||||||
|
{"recommendation_id": "b", "total": 80, "sap_points": 6, "type": "loft_insulation"}]]
|
||||||
|
}
|
||||||
|
selected = set()
|
||||||
|
result = optimiser_functions.add_required_measures(property_id, required_measures, recommendations, selected)
|
||||||
|
# cheapest should be b
|
||||||
|
assert "b" in selected
|
||||||
|
assert any(rec["id"] == "b" for rec in result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddBestPracticeMeasures:
|
||||||
|
def test_adds_ventilation_and_trickle_vents(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(optimiser_functions.assumptions, "measures_needing_ventilation",
|
||||||
|
["internal_wall_insulation"])
|
||||||
|
property_id = "P1"
|
||||||
|
solution = [{"type": "internal_wall_insulation", "id": "w1", "gain": 10, "cost": 100}]
|
||||||
|
recommendations = {
|
||||||
|
"P1": [
|
||||||
|
[{"type": "mechanical_ventilation", "recommendation_id": "vent1"}],
|
||||||
|
[{"type": "trickle_vents", "recommendation_id": "trickle1"}]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
selected = set()
|
||||||
|
updated = optimiser_functions.add_best_practice_measures(property_id, solution, recommendations, selected)
|
||||||
|
assert "vent1" in updated
|
||||||
|
assert "trickle1" in updated
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlattenRecommendationsWithDefaults:
|
||||||
|
def test_marks_selected_and_flattens(self):
|
||||||
|
property_id = "P1"
|
||||||
|
recommendations = {
|
||||||
|
"P1": [
|
||||||
|
[{"recommendation_id": "a", "foo": 1}, {"recommendation_id": "b", "foo": 2}],
|
||||||
|
[{"recommendation_id": "c", "foo": 3}]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
selected = {"b", "c"}
|
||||||
|
result = optimiser_functions.flatten_recommendations_with_defaults(property_id, recommendations, selected)
|
||||||
|
# All recs should now have a default key
|
||||||
|
assert all("default" in rec for rec in result)
|
||||||
|
assert next(r for r in result if r["recommendation_id"] == "b")["default"] is True
|
||||||
|
assert next(r for r in result if r["recommendation_id"] == "a")["default"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncreasingEpcE2e:
|
||||||
|
"""
|
||||||
|
Test out the classic increasing EPC optimisation flow end-to-end.
|
||||||
|
We have a goal (Increasing EPC), no budget, and we expect the optimiser to choose
|
||||||
|
the best set of measures and include best-practice ventilation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_case(self):
|
||||||
|
# ✅ Dummy property object
|
||||||
|
p = SimpleNamespace(
|
||||||
|
id="P1",
|
||||||
|
has_ventilation=False,
|
||||||
|
data={"current-energy-efficiency": "52"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ Dummy request body
|
||||||
|
body = SimpleNamespace(
|
||||||
|
goal="Increasing EPC",
|
||||||
|
goal_value="C",
|
||||||
|
optimise=True,
|
||||||
|
budget=None,
|
||||||
|
simulate_sap_10=False,
|
||||||
|
required_measures=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ Use your massive measures_to_optimise list
|
||||||
|
|
||||||
|
recommendations = {"P1": measures_to_optimise}
|
||||||
|
|
||||||
|
return p, body, recommendations
|
||||||
|
|
||||||
|
def test_end_to_end_increasing_epc(self, setup_case):
|
||||||
|
p, body, recommendations = setup_case
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# RUN THE OPTIMISATION LOOP
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs}
|
||||||
|
property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures]
|
||||||
|
measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures]
|
||||||
|
|
||||||
|
# ventilation flag
|
||||||
|
needs_ventilation = any(
|
||||||
|
x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation
|
||||||
|
) and not p.has_ventilation
|
||||||
|
|
||||||
|
assert needs_ventilation
|
||||||
|
|
||||||
|
input_measures = optimiser_functions.prepare_input_measures(measures_to_optimise, body.goal, needs_ventilation)
|
||||||
|
|
||||||
|
assert input_measures, "Expected measures to optimise"
|
||||||
|
assert len(input_measures) == 7
|
||||||
|
|
||||||
|
fixed_gain = optimiser_functions.calculate_fixed_gain(
|
||||||
|
property_required_measures, recommendations, p, needs_ventilation
|
||||||
|
)
|
||||||
|
assert fixed_gain == 0, "No required measures should mean fixed gain is 0"
|
||||||
|
|
||||||
|
gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
|
||||||
|
|
||||||
|
assert gain == 18.5, "Expected gain to be calculated correctly based on fixed gain and SAP target"
|
||||||
|
|
||||||
|
optimiser = (
|
||||||
|
GainOptimiser(
|
||||||
|
input_measures, max_cost=body.budget, max_gain=gain,
|
||||||
|
allow_slack=body.goal == "Increasing EPC"
|
||||||
|
) if body.budget else CostOptimiser(input_measures, min_gain=gain)
|
||||||
|
)
|
||||||
|
optimiser.setup()
|
||||||
|
optimiser.solve()
|
||||||
|
solution = optimiser.solution
|
||||||
|
|
||||||
|
assert solution, "Optimiser should return a non-empty solution"
|
||||||
|
assert all("id" in m for m in solution)
|
||||||
|
assert any("solar_pv" in m["type"] for m in solution), "Expected solar PV to be included"
|
||||||
|
|
||||||
|
# Collect selected measure IDs
|
||||||
|
selected = {r["id"] for r in solution}
|
||||||
|
|
||||||
|
assert selected == {'8_phase=7', '5_phase=4', '7_phase=6'}
|
||||||
|
|
||||||
|
# Add required measures (none here)
|
||||||
|
solution = optimiser_functions.add_required_measures(
|
||||||
|
property_id=p.id, property_required_measures=property_required_measures,
|
||||||
|
recommendations=recommendations, selected=selected,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert solution == [
|
||||||
|
{'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'},
|
||||||
|
{'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'},
|
||||||
|
{'id': '8_phase=7', 'cost': 6013.139999999999, 'gain': np.float64(13.0), 'type': 'solar_pv'}
|
||||||
|
]
|
||||||
|
|
||||||
|
total_optimised_gain = sum(m["gain"] for m in solution)
|
||||||
|
assert total_optimised_gain == 18.6, "Total gain of optimised measures should meet or exceed target gain"
|
||||||
|
|
||||||
|
selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)
|
||||||
|
|
||||||
|
# Flatten recommendations for output
|
||||||
|
flattened = optimiser_functions.flatten_recommendations_with_defaults(p.id, recommendations, selected)
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# FINAL ASSERTIONS
|
||||||
|
# ---------------------
|
||||||
|
assert isinstance(flattened, list)
|
||||||
|
assert all("default" in rec for rec in flattened)
|
||||||
|
assert any(rec["default"] for rec in flattened), "Some measures should be marked as default"
|
||||||
|
|
||||||
|
# We don't add ventilation as major insulation work isn't done
|
||||||
|
ventilation_added = any(rec["recommendation_id"] == "3_phase=2" and rec["default"] for rec in flattened)
|
||||||
|
assert not ventilation_added, "Ventilation should not be added without major insulation work"
|
||||||
|
|
@ -76,6 +76,8 @@ class TestVentilationRecommendations:
|
||||||
epc_record = EPCRecord()
|
epc_record = EPCRecord()
|
||||||
epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, extract only"}
|
epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, extract only"}
|
||||||
input_property4 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
|
input_property4 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
|
||||||
|
input_property4.identify_ventilation()
|
||||||
|
assert input_property4.has_ventilation
|
||||||
|
|
||||||
recommender4 = VentilationRecommendations(
|
recommender4 = VentilationRecommendations(
|
||||||
property_instance=input_property4,
|
property_instance=input_property4,
|
||||||
|
|
@ -87,12 +89,13 @@ class TestVentilationRecommendations:
|
||||||
recommender4.recommend(phase=None)
|
recommender4.recommend(phase=None)
|
||||||
|
|
||||||
assert not recommender4.recommendation
|
assert not recommender4.recommendation
|
||||||
assert recommender4.has_ventilaion
|
|
||||||
|
|
||||||
def test_existing_ventilation_2(self):
|
def test_existing_ventilation_2(self):
|
||||||
epc_record = EPCRecord()
|
epc_record = EPCRecord()
|
||||||
epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, supply and extract"}
|
epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, supply and extract"}
|
||||||
input_property5 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
|
input_property5 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
|
||||||
|
input_property5.identify_ventilation()
|
||||||
|
assert input_property5.has_ventilation
|
||||||
|
|
||||||
recommender5 = VentilationRecommendations(
|
recommender5 = VentilationRecommendations(
|
||||||
property_instance=input_property5,
|
property_instance=input_property5,
|
||||||
|
|
@ -104,4 +107,3 @@ class TestVentilationRecommendations:
|
||||||
recommender5.recommend(phase=None)
|
recommender5.recommend(phase=None)
|
||||||
|
|
||||||
assert not recommender5.recommendation
|
assert not recommender5.recommendation
|
||||||
assert recommender5.has_ventilaion
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ functions:
|
||||||
- sqs:
|
- sqs:
|
||||||
arn: arn:aws:sqs:${self:provider.region}:${aws:accountId}:model-engine-queue
|
arn: arn:aws:sqs:${self:provider.region}:${aws:accountId}:model-engine-queue
|
||||||
batchSize: 1
|
batchSize: 1
|
||||||
maximumConcurrency: 2
|
maximumConcurrency: 2 # Heavily restricts concurrency to avoid overwhelming the ldmbda limits
|
||||||
|
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ from backend.app.utils import sap_to_epc
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from backend.app.db.connection import db_engine
|
from backend.app.db.connection import db_engine
|
||||||
from backend.app.db.models.recommendations import Recommendation, Plan, PlanRecommendations
|
from backend.app.db.models.recommendations import Recommendation, Plan, PlanRecommendations
|
||||||
from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel
|
from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel, PropertyDetailsSpatial
|
||||||
|
|
||||||
PORTFOLIO_ID = 206
|
# PORTFOLIO_ID = 206
|
||||||
SCENARIOS = [389]
|
# SCENARIOS = [389]
|
||||||
|
PORTFOLIO_ID = 221
|
||||||
|
SCENARIOS = [427]
|
||||||
|
|
||||||
|
|
||||||
def get_data(portfolio_id, scenario_ids):
|
def get_data(portfolio_id, scenario_ids):
|
||||||
|
|
@ -125,17 +127,64 @@ df["predicted_post_works_sap"] = df["predicted_post_works_sap"].round()
|
||||||
df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply(lambda x: sap_to_epc(x))
|
df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply(lambda x: sap_to_epc(x))
|
||||||
|
|
||||||
# We merge this back to the main dataframe, which will contain the bathrooms
|
# We merge this back to the main dataframe, which will contain the bathrooms
|
||||||
from utils.s3 import read_csv_from_s3
|
from utils.s3 import read_csv_from_s3, read_excel_from_s3
|
||||||
|
|
||||||
asset_list = read_csv_from_s3(bucket_name="retrofit-plan-inputs-dev", filepath='8/206/asset_list.csv')
|
# asset_list = read_csv_from_s3(bucket_name="retrofit-plan-inputs-dev", filepath='8/206/asset_list.csv')
|
||||||
|
asset_list = read_excel_from_s3(
|
||||||
|
bucket_name="retrofit-plan-inputs-dev", file_key='8/221/20250722T202328736Z/asset_list.xlsx',
|
||||||
|
header_row=0, sheet_name="320 - edited"
|
||||||
|
)
|
||||||
asset_list = pd.DataFrame(asset_list)
|
asset_list = pd.DataFrame(asset_list)
|
||||||
|
asset_list = asset_list[["domna_full_address", "domna_postcode", "epc_os_uprn", ]].copy()
|
||||||
|
asset_list = asset_list.rename(columns={"epc_os_uprn": "uprn"})
|
||||||
df["uprn"] = df["uprn"].astype(str)
|
df["uprn"] = df["uprn"].astype(str)
|
||||||
|
asset_list["uprn"] = asset_list["uprn"].astype("Int64").astype(str)
|
||||||
asset_list = asset_list.merge(
|
asset_list = asset_list.merge(
|
||||||
df.drop(columns=["address", "postcode", "property_type", "total_floor_area"]),
|
df.drop(columns=["address", "postcode", "property_type", "total_floor_area"]),
|
||||||
how="left",
|
how="left",
|
||||||
on="uprn"
|
on="uprn"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Get conservation area data from property details spatial. based on the UPRNs
|
||||||
|
def get_conservation_area_data(uprns):
|
||||||
|
session = sessionmaker(bind=db_engine)()
|
||||||
|
session.begin()
|
||||||
|
|
||||||
|
# Query to get conservation area data
|
||||||
|
spatial_query = session.query(
|
||||||
|
PropertyDetailsSpatial
|
||||||
|
).filter(
|
||||||
|
PropertyDetailsSpatial.uprn.in_(uprns) # Filter by UPRNs
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Transform spatial data to include all fields dynamically
|
||||||
|
spatial_data = [
|
||||||
|
{col.name: getattr(spatial, col.name) for col in PropertyDetailsSpatial.__table__.columns}
|
||||||
|
for spatial in spatial_query
|
||||||
|
]
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
return pd.DataFrame(spatial_data)
|
||||||
|
|
||||||
|
|
||||||
|
uprns = asset_list[
|
||||||
|
~pd.isna(asset_list["uprn"]) & (asset_list["uprn"] != "<NA>")
|
||||||
|
]["uprn"].astype(int).unique().tolist()
|
||||||
|
conservation_area_data = get_conservation_area_data(uprns)
|
||||||
|
conservation_area_data["uprn"] = conservation_area_data["uprn"].astype(str)
|
||||||
|
asset_list = asset_list.merge(
|
||||||
|
conservation_area_data[["uprn", "conservation_status", "is_listed_building", "is_heritage_building"]],
|
||||||
|
how="left",
|
||||||
|
on="uprn"
|
||||||
|
)
|
||||||
|
|
||||||
|
# For exporting NCHA
|
||||||
|
asset_list.to_excel(
|
||||||
|
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/NCHA/320 Portfolio/asset_list_epc_b.xlsx",
|
||||||
|
index=False
|
||||||
|
)
|
||||||
|
|
||||||
condition_costs = pd.read_excel(
|
condition_costs = pd.read_excel(
|
||||||
"/Users/khalimconn-kowlessar/Documents/hestia/sfr/Spring JV/Condition costs.xlsx",
|
"/Users/khalimconn-kowlessar/Documents/hestia/sfr/Spring JV/Condition costs.xlsx",
|
||||||
sheet_name="Prices - Khalim",
|
sheet_name="Prices - Khalim",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue