Merge pull request #342 from Hestia-Homes/retrofit-assessmet-api

Retrofit assessmet api
This commit is contained in:
KhalimCK 2024-09-30 14:16:08 +01:00 committed by GitHub
commit 1f7eb8c89e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 3804 additions and 722 deletions

View file

@ -187,6 +187,9 @@ class Property:
# This additional condition data should change how we pass kwargs to this. We should no longer need to pass
# kwargs to this class, but instead, we should pass the energy assessment condition data
energy_assessment = (
{"condition": {}, "energy_assessment_is_newer": False} if energy_assessment is None else energy_assessment
)
self.energy_assessment_condition_data = energy_assessment["condition"]
self.energy_assessment_is_newer = energy_assessment["energy_assessment_is_newer"]
@ -500,11 +503,10 @@ class Property:
output["lighting_energy_eff_ending"] = "Very Good"
if recommendation["type"] == "windows_glazing":
is_secondary_glazing = recommendation["is_secondary_glazing"]
output["multi_glaze_proportion_ending"] = 100
if output["windows_energy_eff_ending"] not in ["Average", "Good", "Very Good"]:
output["windows_energy_eff_ending"] = "Average"
is_secondary_glazing = recommendation["is_secondary_glazing"]
output["windows_energy_eff_ending"] = "Average" if not is_secondary_glazing else "Good"
if output["glazing_type_ending"] == "multiple":
pass
@ -1224,7 +1226,15 @@ class Property:
if "air_source_heat_pump" not in measures:
return False
suitable_property_type = self.data["property-type"] in ["House", "Bungalow"]
suitable_house = self.data["property-type"] == "House" and self.data["built-form"] in [
"Detached", "Semi-Detached",
]
suitable_bungalow = self.data["property-type"] == "Bungalow" and self.data["built-form"] in [
"Detached", "Semi-Detached"
]
suitable_property_type = suitable_house or suitable_bungalow
has_air_source_heat_pump = self.main_heating["has_air_source_heat_pump"]
return suitable_property_type and not has_air_source_heat_pump

View file

@ -7,6 +7,9 @@ import pandas as pd
import numpy as np
from epc_api.client import EpcClient
from backend.OrdnanceSurvey import OrdnanceSuveyClient
from etl.epc_clean.epc_attributes.WallAttributes import WallAttributes
from etl.epc_clean.epc_attributes.FloorAttributes import FloorAttributes
from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes
from BaseUtility import Definitions
from utils.logger import setup_logger
from typing import List
@ -181,6 +184,7 @@ class SearchEpc:
self.newest_epc = None
self.older_epcs = None
self.full_sap_epc = None
self.metadata = None
# These are the address and postcode values, which we store in the database
self.address_clean = None
@ -306,7 +310,10 @@ class SearchEpc:
if (property_type is None) and (address is None):
return rows
if len(uprns) == 1:
unique_property_types = {r["property-type"] for r in rows}
# We allow for variation in property type across flats/maisonettes
if (len(uprns) == 1) and ((len(unique_property_types) == 1) or unique_property_types == {"Flat", "Maisonette"}):
return rows
if property_type is not None:
@ -784,3 +791,86 @@ class SearchEpc:
self.address_clean = self.ordnance_survey_client.address_os
self.postcode_clean = self.ordnance_survey_client.postcode_os
return
def check_attribute_variations(self):
attribute_map = {
"walls-description": {
"cleaner": WallAttributes,
"attribute": [
"is_cavity_wall", "is_solid_brick", "is_system_built", "is_timber_frame",
"is_granite_or_whinstone", "is_cob", "is_sandstone_or_limestone", "is_park_home"
],
"name": "has_wall_type_ever_varied"
},
"roof-description": {
"cleaner": RoofAttributes,
"attribute": [
"is_flat", "is_pitched", "is_roof_room", "is_thatched", "has_dwelling_above"
],
"name": "has_roof_type_ever_varied"
},
"floor-description": {
"cleaner": FloorAttributes,
"attribute": [
"is_to_unheated_space", "is_to_external_air", "is_suspended", "is_solid", "is_to_external_air",
],
"name": "has_floor_type_ever_varied"
}
}
attribute_variations = {}
for attribute, attribute_objs in attribute_map.items():
attribute_variations[attribute_objs["name"]] = False
cleaner = attribute_objs["cleaner"]
type_timeline = pd.DataFrame([cleaner(epc[attribute]).process() for epc in self.older_epcs] + [
cleaner(self.newest_epc[attribute]).process()
])
# For eac col in attribute_objs["attribute"] we check if the timeline has ever varied, i.e has gone
# from true to false
for col in attribute_objs["attribute"]:
if type_timeline[col].nunique() > 1:
attribute_variations[attribute_objs["name"]] = True
break
return attribute_variations
def identify_flat_floor(self):
# If there is no dwelling above, it is a top floor flat
processed_roof = RoofAttributes(self.newest_epc["roof-description"]).process()
if not processed_roof["has_dwelling_above"]:
return "top"
# We know that there is a dwelling above. If there's also a drwelling below, it is a mid floor flat
processed_floor = FloorAttributes(self.newest_epc["floor-description"]).process()
if processed_floor["another_property_below"]:
return "mid"
# Otherwise ground floor
return "ground"
def get_metadata(self):
if self.newest_epc is None:
raise ValueError("No EPC data available")
# We check if the property has ever been downgraded on SAP
has_sap_ever_downgraded = False
sap_timeline = [int(epc["current-energy-efficiency"]) for epc in self.older_epcs] + [
int(self.newest_epc["current-energy-efficiency"])
]
# We check if there has ever been a decrease by differencing
has_sap_ever_downgraded = any(np.diff(sap_timeline) < 0)
# We check if the wall type has ever varied over time
attribute_varations = self.check_attribute_variations()
# If the property is a flat, we distinguish between top, mid, ground floor
floor = None
if self.newest_epc["property-type"] == "Flat":
floor = self.identify_flat_floor()
self.metadata = {
"days_since_last_epc": (pd.Timestamp.now() - pd.Timestamp(self.newest_epc["lodgement-date"])).days,
"has_sap_ever_downgraded": has_sap_ever_downgraded,
"floor": floor,
**attribute_varations
}

View file

@ -42,6 +42,9 @@ class GoogleSolarApi:
# your area
installation_life_span = 20
MIN_UNIT_PANELS = 4 # Minimum number of panels we allow for a domestic building
MIN_BUILDING_PANELS = 10 # Minimum number of panels we allow for a block of flats
def __init__(self, api_key, max_retries=5):
"""
Initialize the GoogleSolarApi class with the provided API key and maximum retries.
@ -192,8 +195,6 @@ class GoogleSolarApi:
if not self.need_to_store:
return
logger.info("Storing to database")
scenarios_data = self.panel_performance.head(1)[
[
"n_panels",
@ -221,7 +222,6 @@ class GoogleSolarApi:
scenarios_data["scenario_type"] = scenario_type
scenarios_data = scenarios_data.to_dict(orient="records")
# TODO: Rather than just doing a straight insert, we should overwrite what's already there if it exists
store_batch_data(
session=session,
api_data=self.insights_data,
@ -253,6 +253,9 @@ class GoogleSolarApi:
Optimise the solar panel configuration for the building.
:return:
"""
# If we look at the building level, we don't include any projects fewer than 10 panels, otherwise the
# minimum is 4
min_panels = self.MIN_BUILDING_PANELS if is_building else self.MIN_UNIT_PANELS
cost_instance = Costs(property_instance=property_instance) if property_instance is not None else None
@ -267,6 +270,10 @@ class GoogleSolarApi:
roi_summary = []
for segment in roof_segment_summaries:
if segment["panelsCount"] < min_panels:
continue
wattage = segment["panelsCount"] * self.insights_data["solarPotential"]["panelCapacityWatts"]
generated_dc_energy = segment["yearlyEnergyDcKwh"]
ratio = generated_dc_energy / wattage
@ -275,7 +282,9 @@ class GoogleSolarApi:
cost = MCS_SOLAR_PV_COST_DATA["average_cost_per_kwh"] * (wattage / 1000)
else:
cost = cost_instance.solar_pv(
wattage=wattage, has_battery=False
n_panels=segment["panelsCount"],
has_battery=False,
n_floors=property_instance.number_of_floors,
)["total"]
roi_summary.append(
@ -333,10 +342,6 @@ class GoogleSolarApi:
# We can have duplicate configurations
panel_performance = panel_performance.drop_duplicates()
# If we look at the building level, we don't include any projects fewer than 10 panels, otherwise the
# minimum is 4
min_panels = 10 if is_building else 4
panel_performance = panel_performance[panel_performance["n_panels"] >= min_panels]
if panel_performance.empty:
self.panel_performance = pd.DataFrame(
@ -486,6 +491,7 @@ class GoogleSolarApi:
panel_performance["n_panels"] = panel_performance["n_panels_halved"]
panel_performance = panel_performance.drop(columns=["n_panels_halved"])
panel_performance = panel_performance[panel_performance["n_panels"] >= min_panels]
self.panel_performance = panel_performance

View file

@ -1,5 +1,6 @@
import datetime
import pytz
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.exc import NoResultFound
from backend.app.db.models.solar import Solar, SolarScenario
@ -38,57 +39,80 @@ def get_solar_data(session: Session, longitude: float = None, latitude: float =
def store_batch_data(session: Session, api_data: dict, uprns_to_location: list, scenarios_data: list):
"""
This function will store the API data to the solar table against all of the UPRNs with longitude and latitude.
If a record already exists in the Solar table by UPRN, it will be updated instead of creating a new one.
Similarly, if a scenario exists in SolarScenario by number_panels, it will also be updated.
:param session: The database session
:param api_data: The API data to store
:param uprns_to_location: A list of dictionaries containing uprn, longitude, and latitude
:param uprns_to_location: A list of dictionaries containing UPRN, longitude, and latitude
:param scenarios_data: A list of dictionaries containing scenario data for each UPRN
"""
try:
# Insert data into the Solar table and get the IDs
solar_records = []
# Insert or update data into the Solar table
for data in uprns_to_location:
solar_record = Solar(
uprn=data['uprn'],
longitude=data['longitude'],
latitude=data['latitude'],
google_api_response=api_data,
updated_at=datetime.datetime.now(pytz.utc)
)
solar_records.append(solar_record)
session.add(solar_record)
existing_solar = session.execute(select(Solar).where(Solar.uprn == data['uprn'])).scalar_one_or_none()
session.flush() # Flush to get the IDs generated
for record in solar_records:
session.refresh(record) # Refresh to populate the ID fields
# Retrieve the IDs of the inserted records
inserted_ids = {record.uprn: record.id for record in solar_records}
# Prepare the data for SolarScenario
scenario_records = []
for data in uprns_to_location:
solar_id = inserted_ids.get(data['uprn'])
for scenario in scenarios_data:
scenario_record = SolarScenario(
solar_id=solar_id,
scenario_type=scenario['scenario_type'],
number_panels=scenario['number_panels'],
array_kwhp=scenario['array_kwhp'],
lifetime_dc_kwh=scenario['lifetime_dc_kwh'],
yearly_dc_kwh=scenario['yearly_dc_kwh'],
lifetime_ac_kwh=scenario.get('lifetime_ac_kwh'), # Optional field
yearly_ac_kwh=scenario.get('yearly_ac_kwh'), # Optional field
cost=scenario['cost'],
expected_payback_years=scenario.get('expected_payback_years'), # Optional field
panelled_roof_area=scenario['panelled_roof_area'],
is_default=scenario['is_default']
if existing_solar:
# Update the existing record
existing_solar.longitude = data['longitude']
existing_solar.latitude = data['latitude']
existing_solar.google_api_response = api_data
existing_solar.updated_at = datetime.datetime.now(pytz.utc)
solar_id = existing_solar.id
else:
# Insert a new record
solar_record = Solar(
uprn=data['uprn'],
longitude=data['longitude'],
latitude=data['latitude'],
google_api_response=api_data,
updated_at=datetime.datetime.now(pytz.utc)
)
scenario_records.append(scenario_record)
session.add(solar_record)
session.flush() # Flush to get the IDs generated
session.refresh(solar_record) # Refresh to populate the ID field
solar_id = solar_record.id
# Insert data into the SolarScenario table
session.bulk_save_objects(scenario_records)
# Insert or update data in the SolarScenario table
for scenario in scenarios_data:
existing_scenario = session.execute(
select(SolarScenario).where(
SolarScenario.solar_id == solar_id,
SolarScenario.number_panels == scenario['number_panels']
)
).scalar_one_or_none()
if existing_scenario:
# Update the existing scenario record
existing_scenario.scenario_type = scenario['scenario_type']
existing_scenario.array_kwhp = scenario['array_kwhp']
existing_scenario.lifetime_dc_kwh = scenario['lifetime_dc_kwh']
existing_scenario.yearly_dc_kwh = scenario['yearly_dc_kwh']
existing_scenario.lifetime_ac_kwh = scenario.get('lifetime_ac_kwh') # Optional field
existing_scenario.yearly_ac_kwh = scenario.get('yearly_ac_kwh') # Optional field
existing_scenario.cost = scenario['cost']
existing_scenario.expected_payback_years = scenario.get('expected_payback_years') # Optional field
existing_scenario.panelled_roof_area = scenario['panelled_roof_area']
existing_scenario.is_default = scenario['is_default']
else:
# Insert a new scenario record
scenario_record = SolarScenario(
solar_id=solar_id,
scenario_type=scenario['scenario_type'],
number_panels=scenario['number_panels'],
array_kwhp=scenario['array_kwhp'],
lifetime_dc_kwh=scenario['lifetime_dc_kwh'],
yearly_dc_kwh=scenario['yearly_dc_kwh'],
lifetime_ac_kwh=scenario.get('lifetime_ac_kwh'), # Optional field
yearly_ac_kwh=scenario.get('yearly_ac_kwh'), # Optional field
cost=scenario['cost'],
expected_payback_years=scenario.get('expected_payback_years'), # Optional field
panelled_roof_area=scenario['panelled_roof_area'],
is_default=scenario['is_default']
)
session.add(scenario_record)
# Commit the changes after all operations
session.commit()
except Exception as e:

View file

@ -30,7 +30,6 @@ from backend.app.plan.utils import get_cleaned
from backend.app.utils import epc_to_sap_lower_bound, sap_to_epc
from backend.ml_models.api import ModelApi
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
from backend.Property import Property
from backend.apis.GoogleSolarApi import GoogleSolarApi
@ -652,8 +651,6 @@ async def trigger_plan(body: PlanTriggerRequest):
)
# Store the data in the database
# TODO: Rather than just doing a straight insert, we should overwrite what's already there if it
# exists
solar_api_client.save_to_db(
session=session,
uprns_to_location=[
@ -732,6 +729,171 @@ async def trigger_plan(body: PlanTriggerRequest):
scoring_epcs.extend(property_instance.updated_simulation_epcs)
recommendations[property_id] = recommendations_with_impact
# For Debugging
# recommendation_impact_df = []
# for property_id in recommendations.keys():
# for recs_by_type in recommendations[property_id]:
# for rec in recs_by_type:
# recommendation_impact_df.append(
# {
# "property_id": property_id,
# "uprn": [p.uprn for p in input_properties if p.id == property_id][0],
# "address": [p.address for p in input_properties if p.id == property_id][0],
# "recommendation_id": rec["recommendation_id"],
# "type": rec["type"],
# "description": rec["description"],
# "sap_points": rec["sap_points"],
# "co2_equivalent_savings": rec["co2_equivalent_savings"],
# "heat_demand": rec["heat_demand"]
# }
# )
# recommendation_impact_df = pd.DataFrame(recommendation_impact_df)
#
# surveyed_uprns = [
# 10024087855, 121016117, 121016124,
# 10024087902, 121016121, 121016128
# ]
# recommendation_impact_df = recommendation_impact_df[recommendation_impact_df["uprn"].isin(surveyed_uprns)]
# # recommendation_impact_df = recommendation_impact_df[recommendation_impact_df["type"].isin(
# # ["windows_glazing", "internal_wall_insulation"])
# # ]
#
# actual_impacts_df = pd.DataFrame(
# [
# # 10024087855
# {"uprn": 10024087855, "type": "internal_wall_insulation", "actual_sap_points": 5},
# {"uprn": 10024087855, "type": "draught_proofing", "actual_sap_points": 2},
# {"uprn": 10024087855, "type": "low_energy_lighting", "actual_sap_points": 0},
# {"uprn": 10024087855, "type": "windows_glazing", "actual_sap_points": 4},
# # 121016117
# {"uprn": 121016117, "type": "internal_wall_insulation", "actual_sap_points": 6},
# {"uprn": 121016117, "type": "draught_proofing", "actual_sap_points": 1},
# {"uprn": 121016117, "type": "low_energy_lighting", "actual_sap_points": 1},
# {"uprn": 121016117, "type": "windows_glazing", "actual_sap_points": 4},
# # 121016124
# {"uprn": 121016124, "type": "internal_wall_insulation", "actual_sap_points": 8},
# {"uprn": 121016124, "type": "low_energy_lighting", "actual_sap_points": 2},
# {"uprn": 121016124, "type": "windows_glazing", "actual_sap_points": 5},
# # 10024087902
# {"uprn": 10024087902, "type": "room_roof_insulation", "actual_sap_points": 16},
# {"uprn": 10024087902, "type": "internal_wall_insulation", "actual_sap_points": 2},
# {"uprn": 10024087902, "type": "low_energy_lighting", "actual_sap_points": 0},
# # 121016121
# {"uprn": 121016121, "type": "internal_wall_insulation", "actual_sap_points": 5},
# {"uprn": 121016121, "type": "suspended_floor_insulation", "actual_sap_points": 2},
# {"uprn": 121016121, "type": "draught_proofing", "actual_sap_points": 1},
# {"uprn": 121016121, "type": "windows_glazing", "actual_sap_points": 3},
# # 121016128
# {"uprn": 121016128, "type": "internal_wall_insulation", "actual_sap_points": 6},
# {"uprn": 121016128, "type": "suspended_floor_insulation", "actual_sap_points": 1},
# {"uprn": 121016128, "type": "draught_proofing", "actual_sap_points": 1},
# {"uprn": 121016128, "type": "low_energy_lighting", "actual_sap_points": 1},
# {"uprn": 121016128, "type": "windows_glazing", "actual_sap_points": 3},
# ]
# )
#
# comparison = recommendation_impact_df.merge(
# actual_impacts_df, how="inner", on=["uprn", "type"]
# )
#
# print(recommendation_impact_df.groupby(["uprn"])["sap_points"].sum())
# property_recs = recommendation_impact_df[recommendation_impact_df["uprn"] == 121016128]
# property = [p for p in input_properties if p.uprn == 121016128][0]
# print(property.data["current-energy-efficiency"])
# print(property_recs["sap_points"].sum())
# print(property_recs["type"])
# print(float(property.data["current-energy-efficiency"]) + property_recs["sap_points"].sum())
# recommendations[property.id][2][0]["simulation_config"]
# from utils.s3 import read_dataframe_from_s3_parquet
# training_data = read_dataframe_from_s3_parquet(
# bucket_name="retrofit-data-dev",
# file_key="sap_change_model/2024-08-06-11-19-49/dataset_rooms.parquet"
# )
# import pickle
# with open("delete_me.pkl", "wb") as f:
# pickle.dump(training_data, f)
# Read in the pickle
import pickle
with open("delete_me.pkl", "rb") as f:
training_data = pickle.load(f)
# How do we simulate windows:
ending_cols = [col for col in training_data.columns if col.endswith("_ending")]
starting = {}
for c in ending_cols:
starting_colname = c.replace("_ending", "_starting")
if starting_colname in training_data.columns:
starting[c] = starting_colname
else:
starting[c] = c.replace("_ending", "")
allowed_to_change = [
# Windows
"windows_energy_eff_ending",
"glazed_type_ending",
"glazing_type_ending",
"multi_glaze_proportion_ending",
# Other
"sap_ending",
"heat_demand_ending",
"carbon_ending",
"estimated_perimeter_ending",
"lodgement_year_ending",
"lodgement_month_ending",
"days_to_ending",
"number_habitable_rooms_ending",
"number_heated_rooms_ending",
]
fixed = [c for c in ending_cols if c not in allowed_to_change + ["uprn"]]
training_fixed = training_data.copy()
for col in fixed:
starting_col = starting[col]
training_fixed = training_fixed[training_fixed[col] == training_fixed[starting_col]]
training_fixed = training_fixed.reset_index(drop=True)
# Get the recommendation config for this uprn
uprn = 121016121
property_instance = [p for p in input_properties if p.uprn == uprn][0]
property_recs = recommendations[property_instance.id]
window_recs = [r for r in property_recs if r[0]["type"] == "windows_glazing"][0]
window_recs[0].keys()
window_recs[0]["description_simulation"]["multi-glaze-proportion"]
# TODO: - In description_simulation for windows, we update glazed-type but in the model training data there
# is a column called "glazing-type".
# - We don't update glazed-area (should be "Much More Than Typical" most likely? Or Normal??)
# TODO: I think we update eveything that we actually need to, when simulating the recommendation impact for the
# ML models
# TODO: Secondary glazing appears to go to "Good", not "Average". Investigate why
# TODO: For the two properties, force recommendations for double glazing and check impact
z = training_data[training_data["glazed_type_ending"] == "secondary glazing"]
z = z[z["multi_glaze_proportion_ending"] == 100]
z["windows_energy_eff_ending"].value_counts()
# Find the things that change
example = training_fixed.iloc[3]
for _, example in training_fixed.iterrows():
things_that_change = []
for c in ending_cols:
if example[c] != example[starting[c]]:
things_that_change.append(c)
if len(things_that_change) > 4:
print(things_that_change)
print(example["uprn"])
# blah
# 100051011370 (doesn't change in actual glazing)
# example["glazed_type_ending"]
# double glazing installed before 2002
# example["glazed_type_starting"]
# double glazing, unknown install date
# 100040925015
# We call the API with the scoring epcs
scoring_epcs = pd.DataFrame(scoring_epcs)
scoring_epcs = kwh_client.transform(data=scoring_epcs, cleaned=cleaned)

View file

@ -20,7 +20,7 @@ SPECIFIC_MEASURES = [
# Walls
"internal_wall_insulation",
"external_wall_insulation",
"cavity_wall_insulation"
"cavity_wall_insulation",
# Roof
"loft_insulation",
"flat_roof_insulation",
@ -32,7 +32,21 @@ SPECIFIC_MEASURES = [
"boiler_upgrade",
"high_heat_retention_storage_heater",
"air_source_heat_pump",
"secondary_heating",
# Solar
"solar_pv",
# Windows Glazing
"double_glazing",
"secondary_glazing",
# Mechanical ventilation
"ventilation",
# Other
"low_energy_lighting",
"fireplace",
"hot_water",
]
NON_INVASIVE_SPECIFIC_MEASURES = [
# Specific measures that will typically come from an energy assessment
"trickle_vents",
"draught_proofing",
@ -49,6 +63,7 @@ MEASURE_MAP = {
"roof_insulation": ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"],
"floor_insulation": ["suspended_floor_insulation", "solid_floor_insulation"],
"heating": ["boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump"],
"windows": ["double_glazing", "secondary_glazing"],
}
@ -77,13 +92,13 @@ class PlanTriggerRequest(BaseModel):
# Validator to ensure exclusions are within the pre-defined possibilities
@validator('exclusions', each_item=True)
def check_exclusions(cls, v):
if v not in TYPICAL_MEASURE_TYPES + SPECIFIC_MEASURES:
if v not in TYPICAL_MEASURE_TYPES + SPECIFIC_MEASURES + NON_INVASIVE_SPECIFIC_MEASURES:
raise ValueError(f"{v} is not an allowed exclusion")
return v
@validator('inclusions', each_item=True)
def check_inclusions(cls, v):
if v not in TYPICAL_MEASURE_TYPES + SPECIFIC_MEASURES:
if v not in TYPICAL_MEASURE_TYPES + SPECIFIC_MEASURES + NON_INVASIVE_SPECIFIC_MEASURES:
raise ValueError(f"{v} is not an allowed inclusion")
return v

View file

@ -100,9 +100,44 @@ def retrieve_find_my_epc_data(uprn: int, postcode: str, address: str, expected_e
bills = address_res.find('div', {'id': 'bills-affected'})
bills_list = bills.find_all('li')
if not bills_list:
return None
heating_text = bills_list[0].text
hot_water_text = bills_list[1].text
# If this is the case, it's usually becaue the EPC was very old. Early EPCs did not have this information
heating_text = None
hot_water_text = None
else:
heating_text = bills_list[0].text
hot_water_text = bills_list[1].text
# Search for the assessment informaton
assessment_information = address_res.find('div', {'id': 'information'})
# Parse this information
rows = assessment_information.find_all('div', class_='govuk-summary-list__row')
# Create a dictionary to hold the parsed information
assessment_data = {}
for row in rows:
key = row.find('dt').text.strip()
if key == "Type of assessment":
# We dont reliably extract this
continue
value_tag = row.find('dd')
# Check if value contains a link (email)
if value_tag.find('a'):
value = value_tag.find('a').text.strip()
elif value_tag.find('summary'):
value = value_tag.find('span').text.strip()
else:
value = value_tag.text.strip()
assessment_data[key] = value
expected_keys = [
'Assessors name', 'Telephone', 'Email', 'Accreditation scheme', 'Assessors ID', 'Assessors declaration',
'Date of assessment', 'Date of certificate'
]
# Check we have all the expected keys
for key in expected_keys:
if key not in assessment_data:
raise ValueError(f"Missing key: {key}")
resulting_data = {
'extracted_uprn': uprn,
@ -114,6 +149,7 @@ def retrieve_find_my_epc_data(uprn: int, postcode: str, address: str, expected_e
"potential_epc_efficiency": int(potential_rating.split(' ')[-1]),
"heating_text": heating_text,
"hot_water_text": hot_water_text,
**assessment_data
}
return resulting_data

View file

@ -11,7 +11,7 @@ import inspect
src_file_path = inspect.getfile(lambda: None)
DATA_DIRECTORY = Path(src_file_path).parent / "local_data" / "20240626 Hestia Materials.xlsx"
DATA_DIRECTORY = Path(src_file_path).parent / "local_data" / "20240917 Hestia Materials.xlsx"
# Environment file is at the same level as this file
ENV_FILE = Path(src_file_path).parent / "etl" / "costs" / ".env"
dotenv.load_dotenv(ENV_FILE)
@ -46,6 +46,17 @@ def push_costs_to_db(engine, costs_df):
session.commit()
def set_current_costs_inactive(engine):
"""
Set all current costs to inactive in the database.
:param engine: The SQLAlchemy engine connected to your database.
"""
with Session(engine) as session:
session.query(Material).update({Material.is_active: False})
session.commit()
def app():
"""
This application uploads the cost data to our database
@ -71,6 +82,7 @@ def app():
db_engine = create_engine(db_string, pool_size=5, max_overflow=5)
cwi_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="cavity_wall_insulation", header=0)
ventilation_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="Ventilation", header=0)
loft_insulation_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="loft_insulation", header=0)
iwi_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="internal_wall_insulation", header=0)
suspended_floor_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="suspended_floor_insulation", header=0)
@ -84,6 +96,7 @@ def app():
costs = pd.concat(
[
cwi_costs,
ventilation_costs,
loft_insulation_costs,
iwi_costs,
suspended_floor_costs,
@ -108,6 +121,11 @@ def app():
costs[col] = costs[col].fillna(0)
# Push the costs to the database
# Since this is just uploading all of the new costs to the database, we make all of the current costs inactive
print("Setting all current costs to inactive")
set_current_costs_inactive(db_engine)
print("Pushing costs to db")
push_costs_to_db(db_engine, costs)

View file

@ -0,0 +1,97 @@
import os
import pandas as pd
from backend.SearchEpc import SearchEpc
from dotenv import load_dotenv
from tqdm import tqdm
load_dotenv(dotenv_path="backend/.env")
EPC_AUTH_TOKEN = os.getenv("EPC_AUTH_TOKEN")
def app():
"""
Simple script to pull the EPC data for the Cleethorpes Portfolio
:return:
"""
asset_list = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/sfr/Cleethorpes Portoflio/Updated Tenancy Schedule "
"Portfolio.xlsx",
)
asset_list["row_id"] = asset_list.index
asset_list[" Street No."] = asset_list[" Street No."].astype(str)
epc_data = []
for _, property in tqdm(asset_list.iterrows(), total=len(asset_list)):
if property[" Street No."] == "Ground Floor Commercial":
continue
uprn = property["Uprn"]
if not pd.isnull(uprn):
searcher = SearchEpc(
address1="",
postcode="",
auth_token=EPC_AUTH_TOKEN,
os_api_key="",
uprn=int(uprn)
)
searcher.find_property(skip_os=True)
else:
if not pd.isnull(property[" Flat No."]) and property[" Flat No."] not in ["", " "]:
address1 = property[" Flat No."].strip() + ", " + property[" Street No."].strip()
else:
address1 = property[" Street No."].strip()
if address1 == "1a Mews House 30":
address1 = "1a Rear of"
searcher = SearchEpc(
address1=address1,
postcode=property[" Postcode"].strip(),
auth_token=EPC_AUTH_TOKEN,
os_api_key="",
uprn=None,
)
searcher.get_epc()
# Get the newest record on lodgement-date
sorted_epcs = sorted(
searcher.data["rows"], key=lambda x: x["lodgement-date"]
)
searcher.newest_epc = sorted_epcs[-1]
if searcher.newest_epc is None:
raise ValueError(f"No EPC found for UPRN: {uprn}")
epc_data.append(
{
"row_id": property["row_id"],
**searcher.newest_epc
}
)
epc_df = pd.DataFrame(epc_data)
# Merge on data
asset_list_with_epc = asset_list.merge(
epc_df[["row_id", "address", "current-energy-rating", "current-energy-efficiency", "lodgement-date"]],
how="left",
left_on="row_id",
right_on="row_id",
).rename(
columns={
"address": "EPC Address",
"current-energy-rating": "Current EPC Rating",
"current-energy-efficiency": "Current SAP Score",
"lodgement-date": "EPC Date"
}
)
asset_list_with_epc.to_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/sfr/Cleethorpes Portoflio/Portfolio with EPCs.xlsx",
index=False
)
epc_df.to_csv(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/sfr/Cleethorpes Portoflio/epc_data.csv",
index=False
)

View file

@ -0,0 +1,791 @@
import os
from tqdm import tqdm
from dotenv import load_dotenv
import pandas as pd
import numpy as np
import msgpack
from utils.s3 import read_from_s3
from backend.SearchEpc import SearchEpc
from etl.spatial.OpenUprnClient import OpenUprnClient
load_dotenv(dotenv_path="backend/.env")
EPC_AUTH_TOKEN = os.getenv("EPC_AUTH_TOKEN")
pd.set_option('display.max_rows', 500)
pd.set_option('display.max_columns', 500)
pd.set_option('display.width', 1000)
def app():
# Retrieve EPC data for the SHDF AIHA portfolio
data = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/Khalim Review - 240902 - KSQ - AIHA - SHDF Wave "
"3 bid - Supplementary information.xlsx",
sheet_name="All units information",
header=3
)
# Remove the .eg row
data = data.tail(-1)
# Remove the bottom 2 rows
data = data.head(-2)
data = data.reset_index(drop=True)
data["row_id"] = data.index
ammendments = {
"12 11-18 Schonfeld Square": "12 Schonfeld Square",
"35 35-37 Schonfeld Square": "35 Schonfeld Square",
'77 Schonfeld Square': '77 Lordship Road',
"83 Lordship Road (Schonfeld Square)": "83 Lordship Road",
"A 80 Bethune Road": "80A Bethune Road",
"86B Bethune Road": "Flat B, 86 Bethune Road",
"22 Glendale Road": "22 Glendale Avenue",
"121 Southbourne Road": "121 Southbourne Grove",
}
no_epc = [
"80B Bethune Road",
"89B Manor Road",
"12 Monkville Avenue",
"9 Greenview",
]
property_type_map = {
"House, mid-terrace": "House",
"House, end terrace": "House",
"House, semi-detached": "House",
"House, detached": "House",
"Flat": "Flat",
}
epc_data = []
epc_metadata = []
for _, home in tqdm(data.iterrows(), total=len(data)):
# Build address 1 based on if there is:
# 1) Address letter or number
# 2) Street address
modified = False
address1 = ""
address1_backup = ""
if home["Address letter or number"] in ["A", "B", "C"]:
house_no = home['Street address'].split(' ')[0]
street = ' '.join(home['Street address'].split(' ')[1:])
address1 = f"{house_no}{home['Address letter or number']} {street}"
address1_backup = f"Flat {home['Address letter or number']} {house_no} {street}"
modified = True
else:
if not pd.isnull(home["Address letter or number"]):
address1 += f"{home['Address letter or number']} "
if not pd.isnull(home["Street address"]):
address1 += f"{home['Street address']}"
address1 = address1.strip()
if address1.split(" ")[-1].lower() == "rd":
# Replace with road
address1 = address1.lower().replace(" rd", " road")
# Specific ammendments
if address1 in ammendments:
address1 = ammendments[address1]
if address1 in no_epc:
continue
searcher = SearchEpc(
address1=address1,
postcode=home["Postcode"],
auth_token=EPC_AUTH_TOKEN,
os_api_key="",
property_type=property_type_map[home["Property type"]]
)
searcher.find_property(skip_os=True)
if searcher.newest_epc is None and modified:
searcher = SearchEpc(
address1=address1_backup,
postcode=home["Postcode"],
auth_token=EPC_AUTH_TOKEN,
os_api_key="",
property_type=property_type_map[home["Property type"]]
)
searcher.find_property(skip_os=True)
if searcher.newest_epc is None:
raise Exception("Not found")
epc_data.append(
{
"row_id": home["row_id"],
**searcher.newest_epc
}
)
searcher.get_metadata()
epc_metadata.append(
{
"row_id": home["row_id"],
"address": address1,
"postcode": home["Postcode"],
**searcher.metadata
}
)
epc_metadata = pd.DataFrame(epc_metadata)
epc_data = pd.DataFrame(epc_data)
# Check matched addresses
matched_addresses = epc_metadata[["row_id", "address", "postcode"]].copy()
matched_addresses = matched_addresses.merge(
data[["row_id", "Address letter or number", "Street address"]], on="row_id", how="inner"
)
# We look for differences between the asset list and the EPC data
comparison_cols = {
"Property type": [
{
"epc_col": "property-type",
"map": property_type_map
},
{
"epc_col": "built-form",
"map": {
"House, mid-terrace": "Mid-Terrace",
"House, end terrace": "End-Terrace",
"House, semi-detached": "Semi-Detached",
"House, detached": "Detached",
"Flat": "Flat",
}
}
],
"Energy starting band (EPC)": [
{
"epc_col": "current-energy-rating",
"map": {}
}
],
"Wall type": [
{
"epc_col": "walls-description",
"search_terms": {
"solid": "Solid brick",
"cavity": "Cavity wall",
"solid - internal lining": "Solid brick",
}
}
],
"Roof type": [
{
"epc_col": "roof-description",
"search_terms": {
"pitched": "Pitched",
"n/a - (flat above)": "another dwelling above"
}
}
],
"Floor type": [
{
"epc_col": "floor-description",
"search_terms": {
"solid": "Solid",
"suspended": "Suspended",
"solid - floating floor for services": "Solid"
}
}
],
}
import re
differences = []
for asset_list_col, list_of_configs in comparison_cols.items():
if asset_list_col in ["Wall type", "Roof type", "Floor type"]:
config = list_of_configs[0]
# We handle this differently
remapped = data[["row_id", asset_list_col]].copy()
# Strip the asset list col incase of leading/trailing spaces
remapped[asset_list_col] = remapped[asset_list_col].str.strip()
remapped[asset_list_col] = remapped[asset_list_col].str.lower()
remapped = remapped.merge(epc_data[["row_id", config["epc_col"]]], on="row_id", how="inner")
# We do a search term check
remapped["Match"] = None
for search_term, epc_term in config["search_terms"].items():
if "/" in search_term:
escaped_search_term = re.escape(search_term)
remapped.loc[remapped[asset_list_col].str.contains(escaped_search_term), "Match"] = (
remapped.loc[
remapped[asset_list_col].str.contains(escaped_search_term), config["epc_col"]
].str.contains(epc_term)
)
else:
remapped.loc[remapped[asset_list_col].str.contains(search_term), "Match"] = (
remapped.loc[
remapped[asset_list_col].str.contains(search_term), config["epc_col"]
].str.contains(epc_term)
)
if pd.isnull(remapped["Match"]).sum():
raise Exception("Not all matched")
remapped["Match"] = remapped["Match"].astype(bool)
if not all(remapped["Match"]):
differences.append(
{
"Column": asset_list_col,
"Differences": remapped[~remapped["Match"]],
}
)
continue
for config in list_of_configs:
remapped = data[["row_id", asset_list_col]].copy()
if config["map"]:
remapped[asset_list_col] = remapped[asset_list_col].map(config["map"])
# Merge on
remapped = remapped.merge(epc_data[["row_id", config["epc_col"]]], on="row_id", how="inner")
remapped["Match"] = remapped[asset_list_col] == remapped[config["epc_col"]]
if not all(remapped["Match"]):
differences.append(
{
"Column": asset_list_col,
"Differences": remapped[~remapped["Match"]],
}
)
# Check for property type
property_type_differences = differences[0]["Differences"].copy()
property_type_differences = property_type_differences.merge(
data[["row_id", "Address letter or number", "Street address"]], on="row_id", how="inner"
)
print(property_type_differences)
# Check for built form
built_form_differences = differences[1]["Differences"].copy()
built_form_differences = built_form_differences[built_form_differences["Property type"] != "Flat"]
built_form_differences = built_form_differences.merge(
data[["row_id", "Address letter or number", "Street address"]], on="row_id", how="inner"
)
print(built_form_differences)
# Check for energy rating
energy_rating_differences = differences[2]["Differences"].copy()
energy_rating_differences = energy_rating_differences.merge(
data[["row_id", "Address letter or number", "Street address"]], on="row_id", how="inner"
).merge(
epc_data[["row_id", "uprn"]], on="row_id", how="inner"
)
print(energy_rating_differences)
# Check for wall type
wall_type_differences = differences[3]["Differences"].copy()
wall_type_differences = wall_type_differences.merge(
data[["row_id", "Address letter or number", "Street address"]], on="row_id", how="inner"
).merge(
epc_data[["row_id", "uprn"]], on="row_id", how="inner"
)
print(wall_type_differences) # Many wall type differences
# Check for roof type
roof_type_differences = differences[4]["Differences"].copy()
roof_type_differences = roof_type_differences.merge(
data[["row_id", "Address letter or number", "Street address"]], on="row_id", how="inner"
).merge(
epc_data[["row_id", "uprn"]], on="row_id", how="inner"
)
print(roof_type_differences) # Many roof type differences
# Check for floor type
floor_type_differences = differences[5]["Differences"].copy()
floor_type_differences = floor_type_differences.merge(
data[["row_id", "Address letter or number", "Street address"]], on="row_id", how="inner"
).merge(
epc_data[["row_id", "uprn"]], on="row_id", how="inner"
)
print(floor_type_differences) # Many floor type differences
# TODO: 47 Ashtead Road [100021024699] shows solid brick wall on EPC - is probably cavity wall
# We have the EPC data. Let's check conservation area/historic/listed building status
portfolio_spatial_data = OpenUprnClient.get_spatial_data(
epc_data["uprn"].unique().tolist(), bucket_name="retrofit-data-dev"
)
portfolio_spatial_data["UPRN"] = portfolio_spatial_data["UPRN"].astype(str)
spatial_data = data[["row_id", "Planning constraints"]].merge(
epc_data[["row_id", "uprn"]], on="row_id", how="left",
).merge(
portfolio_spatial_data[["UPRN", "conservation_status", "is_listed_building", "is_heritage_building"]],
left_on="uprn",
right_on="UPRN", how="left"
)
spatial_data[
(spatial_data["Planning constraints"] == "None")
]["conservation_status"].value_counts()
# One property is in a conservation area, that was not picked up in the asset data
print(spatial_data[
(spatial_data["Planning constraints"] == "None") &
(spatial_data["conservation_status"] == True)
].merge(
data[["row_id", "Address letter or number", "Street address", "Postcode"]], on="row_id", how="left"
))
# All properties match up apart from one where the asset data indicates it's in a conservation area, however
# the sparital data indicates it's not. There do not appear to be any listed/heritage buildings in the portfolio
################################################################
# Draft archetyping
################################################################
cleaned = read_from_s3(
s3_file_name="cleaned_epc_data/cleaned.bson",
bucket_name="retrofit-data-dev"
)
cleaned = msgpack.unpackb(cleaned, raw=False)
epc_data = epc_data.merge(
pd.DataFrame(cleaned["walls-description"])[
['original_description',
'is_cavity_wall', 'is_filled_cavity', 'is_solid_brick', 'is_system_built', 'is_timber_frame',
'is_as_built', 'is_assumed', 'insulation_thickness']
].rename(
columns={
"is_solid_brick": "is_solid_brick_wall",
"is_system_built": "is_system_built_wall",
"is_timber_frame": "is_timber_frame_wall",
"is_assumed": "is_assumed_wall",
"insulation_thickness": "insulation_thickness_wall"
}
),
left_on="walls-description",
right_on="original_description"
).merge(
pd.DataFrame(cleaned["roof-description"])[
[
'original_description', 'is_pitched', 'is_roof_room', 'is_loft',
'is_flat', 'is_thatched', 'is_at_rafters', 'is_assumed',
'has_dwelling_above', 'insulation_thickness'
]
].rename(
columns={
"is_assumed": "is_assumed_roof",
}
),
left_on="roof-description",
right_on="original_description"
).merge(
pd.DataFrame(cleaned["floor-description"])[
[
'original_description', 'is_solid', 'is_suspended', 'is_assumed',
'insulation_thickness'
]
].rename(
columns={
"is_assumed": "is_assumed_floor",
"insulation_thickness": "insulation_thickness_floor"
}
),
left_on="floor-description",
right_on="original_description"
)
archetyping_data = data[
[
"row_id",
"Energy starting band (EPC)",
"Property type",
"Property year built",
"Gross internal area (sqm)",
"Current heating system type",
"Wall type",
"Floor type",
"Roof type",
"Window type",
"Location (Floor)",
]
].merge(
epc_metadata[["row_id", "floor"]],
how="left",
on="row_id"
).merge(
epc_data[
[
"row_id", "uprn", "current-energy-rating", "property-type", "built-form", "total-floor-area",
'is_cavity_wall', 'is_filled_cavity', 'is_solid_brick_wall', 'is_system_built_wall',
'is_timber_frame_wall', 'is_as_built', 'is_assumed_wall', 'insulation_thickness_wall',
'is_solid', 'is_suspended', 'is_assumed_floor', 'insulation_thickness_floor',
'is_pitched', 'is_roof_room', 'is_loft',
'is_flat', 'is_thatched', 'is_at_rafters', 'is_assumed_roof',
'has_dwelling_above', 'insulation_thickness', "mainheat-description",
"local-authority-label"
]
],
how="left",
on="row_id"
).merge(
spatial_data[["row_id", "conservation_status", ]],
on="row_id",
how="left"
)
if archetyping_data.shape[0] != data.shape[0]:
raise Exception("Mismatch in data")
# We create groups analogous to the Energy Company Obligation
# 0 - 72, 73 - 97, 98 - 199, 200+
archetyping_data["Floor_area_category"] = pd.cut(
archetyping_data["Gross internal area (sqm)"],
bins=[0, 72, 97, 199, 1000],
labels=["0-72", "73-97", "98-199", "200+"]
)
archetyping_data["Floor_area_category_backup"] = pd.cut(
archetyping_data["total-floor-area"].astype(float),
bins=[0, 72, 97, 199, 1000],
labels=["0-72", "73-97", "98-199", "200+"]
)
archetyping_data["Floor_area_category"] = archetyping_data["Floor_area_category"].fillna(
archetyping_data["Floor_area_category_backup"]
)
archetyping_data["Floor_area_category"] = archetyping_data["Floor_area_category"].astype(str)
archetyping_data["Floor_area_category"] = np.where(
pd.isnull(archetyping_data["Floor_area_category"]),
"Unknown",
archetyping_data["Floor_area_category"]
)
archetyping_data = archetyping_data.drop(columns=["Floor_area_category_backup"])
archetyping_data["property-type-reduced"] = np.where(
archetyping_data["property-type"].isin(["Flat", "Maisionette"]),
"Flat/Maisonette",
archetyping_data["property-type"]
)
archetyping_data["built-form-reduced"] = np.where(
archetyping_data["built-form"].isin(["End-Terrace", "Semi-Detached"]),
"End-Terrace/Semi-Detached",
archetyping_data["built-form"]
)
archetyping_data["built-form-reduced"] = np.where(
archetyping_data["property-type-reduced"] == "Flat/Maisonette",
"Flat/Maisonette",
archetyping_data["built-form-reduced"]
)
archetyping_data["Wall type"] = np.where(
archetyping_data["Wall type"].isin(['Solid ', 'Solid - internal lining ']),
"Solid",
archetyping_data["Wall type"]
)
archetyping_data["Wall type"] = np.where(
archetyping_data["Wall type"].isin(['Cavity ', 'cavity ']),
"Cavity",
archetyping_data["Wall type"]
)
# Proposed remaps based on discoveries
value_remaps = {
# 8 Filey Avenue
"100021040744": {
"variable": "Property type",
"newvalue": "House, mid-terrace",
},
# 7 Yetev Lev Court
"100021032043": {
"variable": "Wall type",
"newvalue": "Cavity",
},
# 14 Yetev Lev Court
"100021032050": {
"variable": "Wall type",
"newvalue": "Cavity",
},
# 23 Yetev Lev Court
"100021032059": {
"variable": "Wall type",
"newvalue": "Cavity",
},
# 30 Yetev Lev Court
"100021032066": {
"variable": "Wall type",
"newvalue": "Cavity",
},
# 34 Yetev Lev Court
"100021032070": {
"variable": "Wall type",
"newvalue": "Cavity",
},
# B 86 Bethune Road
"100021026285": {
"variable": "Wall type",
"newvalue": "Solid",
},
# A 80 Bethune Road
"100021026277": {
"variable": "Wall type",
"newvalue": "Solid",
},
# 140 Kyverdale Road
"100021052262": {
"variable": "Property type",
"newvalue": "House, mid-terrace",
},
# 6 Leabourne Road
"100021053799": {
"variable": "Wall type",
"newvalue": "Solid",
},
# 22 Britannia Gardens - needs confirmation
# 7 Satanita Road - needs confirmation
# 12 Cheltenham Crescent
"100011402969": {
"variable": "Wall type",
"newvalue": "Cavity",
},
"100021031752": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
# 79 Craven Park Road
"100021169682": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
# 88 Darenth Road
"100021036148": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
"100021036165": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
"100021036167": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
"100021053849": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
"100021054353": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
"100021054560": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
"100021059839": {
"variable": "Roof type",
"newvalue": "Room Roof"
},
"100021059848": {
"variable": "Roof type",
"newvalue": "Room Roof"
}
}
# Perform the remaps
for uprn, config in value_remaps.items():
archetyping_data[config["variable"]] = np.where(
archetyping_data["uprn"].astype(str) == uprn, config["newvalue"], archetyping_data[config["variable"]]
)
# row_id = data[
# # (data["Address letter or number"] == "C") &
# (data["Street address"].str.strip() == "41 Moresby Road")
# ]["row_id"]
# if len(row_id) != 1:
# raise Exception("Fail")
# print(epc_data[epc_data["row_id"] == row_id.values[0]]["uprn"])
# Map the year to the age band
def categorize_year(year):
if isinstance(year, str):
# Handle the case where year is in the format '1930s'
if 's' in year:
year = int(year[:4])
else:
year = int(year)
else:
year = int(year)
# Categorize based on year ranges
if year < 1900:
return 'A'
elif 1900 <= year <= 1929:
return 'B'
elif 1930 <= year <= 1949:
return 'C'
elif 1950 <= year <= 1966:
return 'D'
elif 1967 <= year <= 1975:
return 'E'
elif 1976 <= year <= 1982:
return 'F'
elif 1983 <= year <= 1990:
return 'G'
elif 1991 <= year <= 1995:
return 'H'
elif 1996 <= year <= 2002:
return 'I'
elif 2003 <= year <= 2006:
return 'J'
elif 2007 <= year <= 2011:
return 'K'
else: # year >= 2012
return 'L'
archetyping_data["SAP_age_band"] = archetyping_data["Property year built"].apply(
categorize_year
)
# Flag if the property is in London/Manchester
archetyping_data["Location"] = np.where(
archetyping_data["local-authority-label"].isin(
["Hackney", "Barnet", "Haringey"]
),
"London",
np.where(
archetyping_data["local-authority-label"].isin(
["Salford", "Bury"]
),
"Manchester",
"Southend"
)
)
# 9 Greenview is in manchester
archetyping_data["Location"] = np.where(
archetyping_data["row_id"] == data[data["Street address"] == "9 Greenview"]["row_id"].values[0],
"Manchester",
archetyping_data["Location"]
)
# We fix the location for B 80 Bethune Road
archetyping_data["Location"] = np.where(
(
archetyping_data["row_id"].isin(
data[
data["Street address"] == "80 Bethune Road"
]["row_id"].values.tolist()
)
) & (
archetyping_data["row_id"].isin(
data[
data["Address letter or number"] == "B"
]["row_id"].values.tolist()
)
),
"London",
archetyping_data["Location"]
)
# Hackney 73 - London
# Southend-on-Sea 6 - Southend
# Barnet 4 - London
# Castle Point 4 - Southend
# Haringey 3 - London
# Salford 2 - Manchester
# Bury 1 - Manchester
primary_archetyping_cols = [
'Property type',
"Location (Floor)",
'Current heating system type',
'Wall type',
'Roof type',
# "Location",
# 'current-energy-rating', 'property-type-reduced', 'built-form-reduced', 'is_cavity_wall',
# 'is_solid_brick_wall', 'is_system_built_wall', 'is_timber_frame_wall', 'is_as_built',
# 'is_solid', 'is_roof_room',
# 'is_loft', 'is_flat', 'is_thatched',
# 'is_at_rafters', 'has_dwelling_above',
# 'conservation_status',
]
secondary_cols = [
'SAP_age_band',
'is_filled_cavity',
'insulation_thickness_wall'
'insulation_thickness_floor'
'insulation_thickness',
'is_assumed_wall',
'is_assumed_roof',
'Floor_area_category'
]
archetypes = archetyping_data[primary_archetyping_cols].drop_duplicates()
# Hash the variables
archetypes["archetype_hash"] = archetypes.apply(
lambda x: hash(tuple(x.values)),
axis=1
)
archetypes = archetypes.sort_values("archetype_hash", ascending=True)
archetypes = archetypes.reset_index(drop=True)
archetypes["archetype_id"] = archetypes.index
archetypes.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/basic-archetypes.csv", index=False)
# We match properties to archetypes
archetyping_data = archetyping_data.merge(
archetypes,
on=primary_archetyping_cols,
how="left"
)
# We should choose a representative property for each archetype
archetyping_data = archetyping_data.merge(
epc_metadata[["row_id", "days_since_last_epc"]],
how="left",
on="row_id"
)
# Mark the property with the oldest EPC as the representative property
representative_properties = archetyping_data.sort_values(
["archetype_id", "days_since_last_epc"], ascending=[True, False]
).drop_duplicates("archetype_id")
archetyping_data["for_sample"] = np.where(
archetyping_data["row_id"].isin(representative_properties["row_id"]),
True,
False
)
# We save the archetyping data
archetyping_data.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/archetyping_data.csv",
index=False)
# Save the EPC data
epc_data.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/epc_data.csv", index=False)
# Save the spatial data
spatial_data = data[["row_id", "Address letter or number", "Street address", "Postcode"]].merge(
spatial_data,
on="row_id",
how="left"
)
spatial_data.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/spatial_data.csv", index=False)
# Save archetyping data
archetyping_data = data[["row_id", "Address letter or number", "Street address", "Postcode"]].merge(
archetyping_data,
on="row_id",
how="left"
)
archetyping_data.to_csv(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/archetyping_data.csv",
index=False
)

View file

@ -0,0 +1,62 @@
import pandas as pd
import numpy as np
import time
from tqdm import tqdm
from etl.bill_savings.data_collection import retrieve_find_my_epc_data, calculate_expiry_date
def main():
"""
This script handles pulling the surveyor names and acreditation details for Surveyors who have completed
the newest EPC for AIHA's properties
"""
epc_data = pd.read_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/epc_data.csv")
epc_data = epc_data[["uprn", "address", "address1", "postcode", "lodgement-date"]]
epc_collected_data = []
for _, unit in tqdm(epc_data.iterrows(), total=len(epc_data)):
time.sleep(np.random.uniform(0.2, 1.5))
uprn = int(unit["uprn"])
address = unit["address1"]
postcode = unit["postcode"]
expected_expiry_date = calculate_expiry_date(unit["lodgement-date"])
response = retrieve_find_my_epc_data(
uprn=uprn,
postcode=postcode,
address=address,
expected_expiry_date=expected_expiry_date
)
if response is None:
raise Exception("fix me")
epc_collected_data.append(response)
epc_collected_data = pd.DataFrame(epc_collected_data)
epc_collected_data = epc_data[["uprn", "address", "address1", "postcode"]].merge(
epc_collected_data, left_on="uprn", right_on="extracted_uprn"
)
elmhurst_surveys = epc_collected_data[
epc_collected_data["Accreditation scheme"].isin(
["NHER", "Stroma Certification Ltd", "Elmhurst Energy Systems Ltd"]
)
]
quidos_surveys = epc_collected_data[
epc_collected_data["Accreditation scheme"].isin(
["Quidos Limited"]
)
]
ecmk_surveys = epc_collected_data[
epc_collected_data["Accreditation scheme"].isin(
["ECMK"]
)
]
# Store the data:
elmhurst_surveys.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/Elmhurst Surveys.csv")
quidos_surveys.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/Quidos Surveys.csv")
ecmk_surveys.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/AIHA/ECMK Surveys.csv")

View file

@ -102,7 +102,7 @@ analysis_epcs = analysis_epcs[
[
"UPRN", "TENURE", "CURRENT_ENERGY_RATING", "WALLS_DESCRIPTION", "ROOF_DESCRIPTION",
"CONSTRUCTION_AGE_BAND", "TOTAL_FLOOR_AREA", "PROPERTY_TYPE", "BUILT_FORM", "MAINHEAT_DESCRIPTION",
"eligibility_type",
"eligibility_type", "PHOTO_SUPPLY", "ADDRESS1", "POSTCODE"
]
]
analysis_epcs["grouped_epc_band"] = np.where(
@ -110,6 +110,14 @@ analysis_epcs["grouped_epc_band"] = np.where(
"EPC D",
"EPC E-G"
)
analysis_epcs[pd.isnull(analysis_epcs["PHOTO_SUPPLY"])][["ADDRESS1", "POSTCODE"]].sample(1)
analysis_epcs["PHOTO_SUPPLY"] = analysis_epcs["PHOTO_SUPPLY"].fillna(0)
analysis_epcs["PHOTO_SUPPLY"] = analysis_epcs["PHOTO_SUPPLY"].astype(float)
analysis_epcs["has_solar"] = np.where(analysis_epcs["PHOTO_SUPPLY"] > 0, 1, 0)
analysis_epcs["has_solar"].value_counts()
analysis_epcs.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/bcc tender/analysis_epcs.csv", index=False)
# Create aggregations and we store this information

View file

@ -5,6 +5,7 @@ from sqlalchemy.orm import sessionmaker
from backend.app.db.connection import db_engine
from backend.app.db.models.recommendations import Recommendation, Plan, PlanRecommendations, Scenario
from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel
from utils.s3 import read_csv_from_s3
def get_data(portfolio_id, scenario_ids):
@ -415,3 +416,241 @@ def slides():
pd.set_option('display.max_rows', None)
# Show more characters in a column
pd.set_option('display.max_colwidth', None)
# preparing of this data for the following 2 needs:
# 1) dataset to share with Nextgen heating
# 2) Breakdown of results by property type
# get the asset list
asset_list = read_csv_from_s3(bucket_name="retrofit-plan-inputs-dev", filepath="8/90/pilot.csv")
asset_list = pd.DataFrame(asset_list)
# Get non-invasive recommendations
non_intrusive_recommendations = read_csv_from_s3(
bucket_name="retrofit-plan-inputs-dev",
filepath="8/90/non_invasive_recommendations.csv"
)
non_intrusive_recommendations = pd.DataFrame(non_intrusive_recommendations)
# Unnest this
import ast
survey_recs = []
for _, row in non_intrusive_recommendations.iterrows():
recs = ast.literal_eval(row["recommendations"])
ashp_rec = next((r for r in recs if r["type"] == "air_source_heat_pump"), None)
solar_rec = next((r for r in recs if r["type"] == "solar_pv"), None)
to_append = {
"uprn": row["uprn"]
}
if ashp_rec["suitable"]:
to_append = {
**to_append,
"ashp_suitable": True,
"ashp_size_kw": ashp_rec["size"],
"ashp_cost": ashp_rec["cost"],
}
if solar_rec["suitable"]:
to_append = {
**to_append,
"solar_suitable": True,
"solar_size_kwp": solar_rec["array_wattage"],
"solar_cost": solar_rec["cost"],
}
survey_recs.append(to_append)
survey_recs = pd.DataFrame(survey_recs)
asset_list["uprn"] = asset_list["uprn"].astype(int)
survey_recs["uprn"] = survey_recs["uprn"].astype(int)
vital_kwh = 7597
domna_kwh = 10850
scaling_factor = vital_kwh / domna_kwh
next_gen_dataset = properties_df[[
"uprn", "address", "postcode",
"property_type", "built_form", "current_energy_demand_heating_hotwater",
"mainfuel", "total_floor_area", "floor_height"
]].rename(
columns={
"mainfuel": "primary_fuel_type",
"total_floor_area": "gross_floor_area",
"current_energy_demand_heating_hotwater": "estimated_heating_hotwater_kwh"
}
).merge(
asset_list[["uprn", "number_of_floors"]],
how="left",
on="uprn"
).merge(
survey_recs,
how="left",
on="uprn"
)
next_gen_dataset["estimated_heating_hotwater_kwh_scaled"] = (
next_gen_dataset["estimated_heating_hotwater_kwh"] * scaling_factor
)
next_gen_dataset["ashp_suitable"] = next_gen_dataset["ashp_suitable"].fillna(False)
next_gen_dataset["solar_suitable"] = next_gen_dataset["solar_suitable"].fillna(False)
# We prepare the scenario outputs by property type
grouped_data = next_gen_dataset.copy()
grouped_data["property_sub_type"] = grouped_data["built_form"].copy()
# If a property is a flat, re-map sub_type just to flat
grouped_data.loc[grouped_data["property_type"] == "Flat", "property_sub_type"] = "Flat"
# Same for maisonettes
grouped_data.loc[grouped_data["property_type"] == "Maisonette", "property_sub_type"] = "Maisonette"
# We now pull out the recommendations impact by property type and sub type
property_scenario_impact = []
for scenario_id in scenario_ids:
# Get the recommendations for the scenario, default
scenario_recommendations = recommendations_df[
(recommendations_df["Scenario ID"] == scenario_id) &
(recommendations_df["default"] == True)
].copy()
scenario_recommendations['ligting_kwh'] = scenario_recommendations.apply(
lambda x: x['kwh_savings'] if x['type'] == 'low_energy_lighting' else 0,
axis=1)
scenario_recommendations['solar_kwh'] = scenario_recommendations.apply(
lambda x: x['kwh_savings'] if x['type'] == 'solar_pv' else 0, axis=1)
# Set 'Estimated Kwh Savings' to zero where specific kwh columns are used
scenario_recommendations['Estimated Kwh Savings'] = scenario_recommendations.apply(
lambda x: 0 if x['type'] in ['low_energy_lighting', 'solar_pv'] else x[
'kwh_savings'], axis=1)
scenario_grouped_data = scenario_recommendations.groupby(['property_id']).agg({
'Estimated Kwh Savings': 'sum',
"estimated_cost": "sum"
}).reset_index()
comparison = properties_df.drop_duplicates()[
["uprn", "property_id", "current_energy_demand_heating_hotwater"]
].merge(
scenario_grouped_data, on=["property_id"], how="left"
)
comparison["Estimated Kwh Savings"] = comparison["Estimated Kwh Savings"].fillna(0)
comparison["estimated_cost"] = comparison["estimated_cost"].fillna(0)
comparison["post_scenario_heating_hotwater_kwh"] = (
comparison["current_energy_demand_heating_hotwater"] - comparison["Estimated Kwh Savings"]
)
comparison["scenario_id"] = scenario_id
property_scenario_impact.append(comparison)
property_scenario_impact = pd.concat(property_scenario_impact)
property_scenario_impact = property_scenario_impact.drop(columns=["property_id", "Estimated Kwh Savings"])
# Scale
property_scenario_impact["post_scenario_heating_hotwater_kwh_scaled"] = (
property_scenario_impact["post_scenario_heating_hotwater_kwh"] * scaling_factor
)
grouped_data = grouped_data.merge(
property_scenario_impact, how="left", on="uprn"
)
# Agg the data
grouped_data = grouped_data.groupby(["property_type", "property_sub_type", "scenario_id"]).agg({
"estimated_heating_hotwater_kwh": "mean",
"estimated_heating_hotwater_kwh_scaled": "mean",
"estimated_cost": "mean",
"post_scenario_heating_hotwater_kwh": "mean",
"post_scenario_heating_hotwater_kwh_scaled": "mean"
}).reset_index()
scenario_names = pd.DataFrame(
[
{
"scenario_id": 47,
"scenario": "Demand Reduction cavity & roof insulation",
},
{
"scenario_id": 48,
"scenario": "Demand reduction no solid wall, floors or heating/renewables",
},
{
"scenario_id": 49,
"scenario": "Demand reduction no decant"
},
{
"scenario_id": 50,
"scenario": "Demand reduction no decant + heating & solar",
},
{
"scenario_id": 51,
"scenario": "Whole house retrofit"
}
]
)
grouped_data = grouped_data.merge(
scenario_names, how="left", on="scenario_id"
)
if not grouped_data[
grouped_data["estimated_heating_hotwater_kwh"] < grouped_data["post_scenario_heating_hotwater_kwh"]].empty:
raise Exception("someting went wrong")
if not grouped_data[grouped_data["estimated_heating_hotwater_kwh_scaled"] < grouped_data[
"post_scenario_heating_hotwater_kwh_scaled"]].empty:
raise Exception("someting went wrong")
# Reorder the columns
grouped_data = grouped_data[
[
'property_type',
'property_sub_type',
'scenario',
'estimated_heating_hotwater_kwh',
'post_scenario_heating_hotwater_kwh',
'estimated_heating_hotwater_kwh_scaled',
'post_scenario_heating_hotwater_kwh_scaled',
'estimated_cost',
]
]
grouped_data = grouped_data.rename(
columns={
"property_type": "Property Type",
"property_sub_type": "Property Sub Type",
"scenario": "Scenario",
"estimated_heating_hotwater_kwh": "Estimated Heating & Hot Water kwh",
"post_scenario_heating_hotwater_kwh": "Post Scenario Heating & Hot Water kwh",
"estimated_heating_hotwater_kwh_scaled": "Estimated Heating & Hot Water kwh (scaled)",
"post_scenario_heating_hotwater_kwh_scaled": "Post Scenario Heating & Hot Water kwh (scaled)",
"estimated_cost": "Estimated Cost or Retrofit",
}
)
grouped_data.to_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Newhaven/outputs/Scenario kWh Impact by Property "
"Type.xlsx",
index=False
)
property_scenario_impact = property_scenario_impact.merge(
scenario_names, how="left", on="scenario_id"
)
df_pivot = property_scenario_impact.pivot_table(index='uprn', columns='scenario',
values=['post_scenario_heating_hotwater_kwh',
'post_scenario_heating_hotwater_kwh_scaled'])
# Flattening multi-index columns
df_pivot.columns = [f'{col[0]}_{col[1]}' for col in df_pivot.columns]
# Reset the index to have a clean dataframe
df_pivot.reset_index(inplace=True)
next_gen_dataset = next_gen_dataset.merge(
df_pivot, how="left", on="uprn"
)
next_gen_dataset.to_csv(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Newhaven/outputs/next_gen_dataset.csv", index=False
)

View file

@ -44,7 +44,7 @@ def app():
# Rename the columns to the same format as the api returns
data.columns = [c.replace("_", "-").lower() for c in data.columns]
# Take just date before the date threshold
data = data[data["lodgement-date"] >= EARLIEST_EPC_DATE]
data = data[data["lodgement-date"] >= "2011-01-01"]
# Convert to list of dictioaries as returned by the api
data = data.to_dict("records")

View file

@ -11,7 +11,7 @@ class FloorAttributes(Definitions):
# For the short term, while we are still exploring the data, we maintain a list of error cases which
# we want to ignore and consider as no data.
OBSERVED_ERRORS = ["Conservatory"]
OBSERVED_ERRORS = ["Conservatory", "insulated"]
WELSH_TEXT = {
"(anheddiad arall islaw)": "(another dwelling below)",
@ -30,8 +30,10 @@ class FloorAttributes(Definitions):
"i ofod heb ei wresogi, wedigçöi inswleiddio": "to unheated space, insulated",
"solet, wedigçöi inswleiddio (rhagdybiaeth)": "solid, insulated (assumed)",
"solet, wedigçöi inswleiddio": "solid, insulated",
"solet, wedi???i inswleiddio (rhagdybiaeth)": "solid, insulated (assumed)",
"i ofod heb ei wresogi, dim inswleiddio (rhagdybiaeth)": "to unheated space, no insulation (assumed)",
"i ofod heb ei wresogi, dim inswleiddio": "to unheated space, no insulation"
"i ofod heb ei wresogi, heb ei inswleiddio (rhagdybiaeth)": "to unheated space, no insulation (assumed)",
"i ofod heb ei wresogi, dim inswleiddio": "to unheated space, no insulation",
}
def __init__(self, description: str):

View file

@ -19,6 +19,7 @@ class HotWaterAttributes(Definitions):
'solid fuel boiler', # burns solid materials to generate heat for water heating and/or space heating
'solid fuel range cooker',
'room heaters', # Generic/unspecified category
'electric multipoint',
]
# SYSTEM_TYPES refer to the larger system within which the heater operates.
@ -96,9 +97,11 @@ class HotWaterAttributes(Definitions):
WELSH_TEXT = {
"ogçör brif system": "from main system",
"o r brif system": "from main system",
"ogçör brif system, adfer gwres nwyon ffliw": "from main system, flue gas heat recovery",
"bwyler/cylchredydd nwy": "gas boiler/circulator",
"ogçör brif system, dim thermostat ar y silindr": "from main system, no cylinder thermostat",
"o r brif system, dim thermostat ar y silindr": "from main system, no cylinder thermostat",
"twymwr tanddwr, an-frig": "electric immersion, off-peak",
"ogçör brif system, gydag ynnigçör haul": "from main system, plus solar",
"twymwr tanddwr, tarriff safonol": "electric immersion, standard tariff",
@ -124,13 +127,21 @@ class HotWaterAttributes(Definitions):
"thermostat, flue gas heat recovery",
"ogçör brif system, gydag ynnigçör haul, adfer gwres nwyon ffliw": "from main system, plus solar, flue gas "
"heat recovery",
"o r brif system, gydag ynni r haul, dim thermostat ar y silindr": "from main system, plus solar, no cylinder "
"thermostat",
"o r brif system, gydag ynni r haul": "from main system, plus solar",
}
NODATA_DESCRIPTIONS = [
"sap05 hot-water",
"sap hot-water"
]
def __init__(self, description: str):
self.description: str = clean_description(description.lower()).strip()
self.nodata = not self.description or description in self.DATA_ANOMALY_MATCHES or (
self.description == "sap05 hot-water"
self.description in self.NODATA_DESCRIPTIONS
)
translation = self.WELSH_TEXT.get(self.description)

View file

@ -7,6 +7,7 @@ from etl.epc_clean.utils import correct_spelling
class LightingAttributes(Definitions):
WELSH_TEXT = {
"goleuadau ynni-isel ym mhob un ogçör mannau gosod": "low energy lighting in all fixed outlets",
"goleuadau ynni-isel ym mhob un o r mannau gosod": "low energy lighting in all fixed outlets",
"dim goleuadau ynni-isel": "no low energy lighting",
"goleuadau ynni-isel ym mhob un o'r mannau gosod": 'Low energy lighting in all fixed outlets'
}

View file

@ -50,7 +50,8 @@ class MainFuelAttributes(Definitions):
NO_INDIVIDUAL_HEATING_OR_COMMUNITY_NETWORK = [
'to be used only when there is no heatinghotwater system or data is from a community network',
'to be used only when there is no heatinghotwater system'
'to be used only when there is no heatinghotwater system',
'community heating schemes waste heat from power stations',
]
def __init__(self, description: str):

View file

@ -34,7 +34,10 @@ class MainHeatAttributes(Definitions):
"gwresogyddion ystafell, trydan": "room heaters, electric",
"pwmp gwres sygçön tarddu yn yr awyr, dan y llawr, trydan": "air source heat pump, underfloor heating, "
"electric",
"pwmp gwres sygçön tarddu yn yr awyr, dan y llawr, trydan, pwmp gwres sygçön tarddu yn yr awyr, dan y llawr, "
"trydan": "air source heat pump, underfloor heating, electric",
"cynllun cymunedol": "community scheme",
"cynllun cymunedol, heat from boilers - mains gas": "community scheme",
"bwyler a gwres dan y llawr, nwy prif gyflenwad": "boiler and underfloor heating, mains gas",
"bwyler a rheiddiaduron, logiau coed": 'boiler and radiators, wood logs',
"bwyler a rheiddiaduron, tanwydd di-fwg": "boiler and radiators, smokeless fuel",
@ -59,6 +62,16 @@ class MainHeatAttributes(Definitions):
"bwyler a rheiddiaduron, olew, st+¦r wresogyddion trydan": "boiler and radiators, oil, electric storage "
"heaters",
"pwmp gwres sygçön tarddu yn yr awyr, awyr gynnes, trydan": "air source heat pump, warm air, electric",
"stor wresogyddion trydan": "electric storage heaters",
# Not 100% certain - the translation is "bottled gas"
"bwyler a rheiddiaduron, nwy potel": "boiler and radiators, lpg",
"gwresogyddion trydan cludadwy wedi i ragdybio ar gyfer y rhan fwyaf o r ystafelloedd": "portable electric "
"heaters assumed for "
"most rooms",
"st r wresogyddion trydan": "electric storage heaters",
"dim system ar gael, rhagdybir bod gwresogyddion trydan, trydan": "no system present, electric heaters assumed",
# Should be handled by edge cases
", trydan": ", electric",
}
REMAP = {
@ -66,6 +79,13 @@ class MainHeatAttributes(Definitions):
"electric heat pumps": "electric heat pump",
"solar-assisted heat pump": "solar assisted heat pump",
"portable electric heating": "portable electric heaters",
"portable electric heating assumed for most rooms": "portable electric heaters assumed for most rooms",
"electric storage, electric": "electric storage heaters",
"radiator heating, electric": "room heaters, electric",
"hot-water-only systems, gas": "no system present, electric heaters assumed",
"gas-fired heat pumps, electric": "air source heat pump, electric",
"radiator heating, heat from boilers - gas": "boiler and radiators, mains gas",
"heat pump, warm air, mains gas": "air source heat pump, warm air, mains gas",
}
edge_case_result = {}
@ -97,6 +117,10 @@ class MainHeatAttributes(Definitions):
self.description = remapped
backup_remap = self.REMAP.get(self.description)
if backup_remap:
self.description = backup_remap
self.process_edge_cases()
if not self.nodata:
@ -138,6 +162,21 @@ class MainHeatAttributes(Definitions):
self.is_edge_case = True
return
if self.description == ', electric':
self.edge_case_result['has_electric'] = True
self.is_edge_case = True
return
if self.description == ', mains gas':
self.edge_case_result['has_mains_gas'] = True
self.is_edge_case = True
return
if self.description == 'community, community':
self.edge_case_result['has_community_scheme'] = True
self.is_edge_case = True
return
def process(self) -> Dict[str, Union[str, bool]]:
result: Dict[str, Union[str, bool]] = {f'has_{ds.replace(" ", "_")}': False for ds in self.DISTRIBUTION_SYSTEMS}

View file

@ -75,6 +75,8 @@ class MainheatControlAttributes(Definitions):
TO_REMAP = {
"celect control": 'celect-type control',
"celect controls": 'celect-type control',
"trv's, program & flow switch": 'trvs, programmer & flow switch',
'appliance thermostat': 'appliance thermostats',
}
WELSH_TEXT = {
@ -113,12 +115,20 @@ class MainheatControlAttributes(Definitions):
't+ól un gyfradd, trvs': 'single rate heating, trvs',
'trvs a falf osgoi': 'trvs and bypass',
'rheolaeth celect': 'celect-type control',
'rheoli r tal a llaw': 'manual charge control',
'tal un gyfradd, thermostat ystafell yn unig': 'flat rate charging, room thermostat only',
"rheoli'r t l llaw": "manual charge control",
}
NO_DATA_DESCRIPTIONS = [
"SAP05:Main-Heating-Controls",
"SAP:Main-Heating-Controls",
]
def __init__(self, description: str):
self.description: str = clean_description(description.lower()).strip()
self.nodata = not self.description or description in self.DATA_ANOMALY_MATCHES or (
description == "SAP05:Main-Heating-Controls"
description in self.NO_DATA_DESCRIPTIONS
)
translation = self.WELSH_TEXT.get(self.description)

View file

@ -6,31 +6,40 @@ from etl.epc_clean.epc_attributes.attribute_utils import extract_component_types
class RoofAttributes(Definitions):
ROOF_TYPES = ['pitched', 'roof room', 'loft', 'flat', 'thatched', 'at rafters', 'assumed']
DWELLING_ABOVE = ["another dwelling above", "other premises above"]
DWELLING_ABOVE = ["another dwelling above", "other premises above", "other dwelling above"]
WELSH_TEXT = {
"ar oleddf, dim inswleiddio": "pitched, no insulation",
"ar oleddf, dim inswleiddio (rhagdybiaeth)": "pitched, no insulation (assumed)",
"ar oleddf, wedigçöi inswleiddio (rhagdybiaeth)": "pitched, insulated (assumed)",
"ar oleddf, wedi?i inswleiddio (rhagdybiaeth)": "pitched, insulated (assumed)",
"ar oleddf, wedigçöi hinswleiddio (rhagdybiaeth)": "pitched, insulated (assumed)",
"ar oleddf, wedigçöi inswleiddio": "pitched, insulated",
"ar oleddf, wedi?i inswleiddio": "pitched, insulated",
"ar oleddf, inswleiddio cyfyngedig (rhagdybiaeth)": "pitched, limited insulation (assumed)",
"ar oleddf, inswleiddio cyfyngedig": "pitched, limited insulation",
"ar oleddf, wedigçöi inswleiddio wrth y trawstiau": 'pitched, insulated at rafters',
"ar oleddf, wedi?i inswleiddio wrth y trawstiau": 'pitched, insulated at rafters',
"ar oleddf, wedi?i inswleiddio wrth y trawstia": 'pitched, insulated at rafters',
"ar oleddf, wedigçöi inswleiddio wrth y trawstia": 'pitched, insulated at rafters',
"yn wastad, inswleiddio cyfyngedig (rhagdybiaeth)": "flat, limited insulation (assumed)",
"yn wastad, inswleiddio cyfyngedig": "flat, limited insulation",
"yn wastad, dim inswleiddio (rhagdybiaeth)": "flat, no insulation (assumed)",
"yn wastad, dim inswleiddio": "flat, no insulation",
"yn wastad, wedigçöi inswleiddio (rhagdybiaeth)": "flat, insulated (assumed)",
"yn wastad, wedi?i hinswleiddio (rhagdybiaeth)": "flat, insulated (assumed)",
"yn wastad, wedigçöi inswleiddio": "flat, insulated",
"(eiddo arall uwchben)": "(another dwelling above)",
"(annedd arall uwchben)": "(another dwelling above)",
"ystafell(oedd) to, wedigçöi hinswleiddio": "roof room(s), insulated",
"ystafell(oedd) to, wedi?i hinswleiddio (rhagdybiaeth)": "roof room(s), insulated (assumed)",
"ystafell(oedd) to, wedigçöi hinswleiddio (rhagdybiaeth)": "roof room(s), insulated (assumed)",
"ystafell(oedd) to, inswleiddio cyfyngedig (rhagdybiaeth)": "roof room(s), limited insulation (assumed)",
"ystafell(oedd) to, inswleiddio cyfyngedig": "roof room(s), limited insulation",
"ystafell(oedd) to, nenfwd wedigçöi inswleiddio": "roof room(s), ceiling insulated",
"ystafell(oedd) to, dim inswleiddio (rhagdybiaeth)": "roof room(s), no insulation (assumed)",
"ystafell(oedd) to, dim inswleiddio": "roof room(s), no insulation",
"to gwellt, gydag inswleiddio ychwanegol": "thatched, with additional insulation",
}
DEFAULT_KEYS = [
@ -62,10 +71,18 @@ class RoofAttributes(Definitions):
search for regular expressions and translate
"""
loft_insulation_thickness_match = re.search(r"ar oleddf, (\d+ mm) o inswleiddio yn y llofft", self.description)
loft_insulation_thickness_match2 = re.search(r"ar oleddf, (\d+ mm) lo inswleiddio yn y llof", self.description)
loft_insulation_thickness_match3 = re.search(r"ar oleddf, (\d+\+ mm) lo inswleiddio yn y llof",
self.description)
loft_insulation_regexes = [
r"ar oleddf, (\d+ mm) o inswleiddio yn y llofft",
r"ar oleddf, (\d+ mm) lo inswleiddio yn y llof",
r"ar oleddf, (\d+\+ mm) lo inswleiddio yn y llof",
r"ar oleddf, (\d+mm) o inswleiddio yn y llofft",
r"ar oleddf, (\d+\+ mm) o inswleiddio yn y llofft"
]
li_thickness_match = None
for regex in loft_insulation_regexes:
li_thickness_match = re.search(regex, self.description)
if li_thickness_match:
break
uvalue_search = re.search(r"trawsyriannedd thermol cyfartalog (\d+(\.\d+)?)\s*w/m-¦k", self.description)
uvalue_search2 = re.search(
@ -73,15 +90,8 @@ class RoofAttributes(Definitions):
)
# Step 2: Generalized translation with placeholder
if (loft_insulation_thickness_match is not None) | \
(loft_insulation_thickness_match2 is not None) | \
(loft_insulation_thickness_match3 is not None):
if loft_insulation_thickness_match is not None:
insulation_thickness = loft_insulation_thickness_match.group(1)
elif loft_insulation_thickness_match2 is not None:
insulation_thickness = loft_insulation_thickness_match2.group(1)
else:
insulation_thickness = loft_insulation_thickness_match3.group(1)
if li_thickness_match is not None:
insulation_thickness = li_thickness_match.group(1)
self.description = f"pitched, {insulation_thickness} loft insulation"
elif uvalue_search is not None or uvalue_search2 is not None:
@ -113,9 +123,8 @@ class RoofAttributes(Definitions):
# roof type
result, description = extract_component_types(result, description, list_of_components=self.ROOF_TYPES)
result["has_dwelling_above"] = (
"another dwelling above" in description or "other premises above" in description
)
result["has_dwelling_above"] = any([x in description for x in self.DWELLING_ABOVE])
for dwelling_above in self.DWELLING_ABOVE:
description = description.replace(dwelling_above, "")

View file

@ -27,18 +27,26 @@ class WindowAttributes(Definitions):
"gwydrau triphlyg llawn": "fully triple glazed",
"gwydrau triphlyg rhannol": "partial triple glazed",
"gwydrau triphlyg mwyaf": "mostly triple glazed",
"gwydrau triphlyg gan mwyaf": "mostly triple glazed",
"gwydrau eilaidd llawn": "full secondary glazing",
"gwydrau eilaidd mwyaf": "mostly secondary glazing",
"gwydrau eilaidd rhannol": "partial secondary glazing",
"gwydrau lluosog ym mhobman": "multiple glazing throughout",
}
# These are observed data anomalies that we want to ignore
NO_DATA_CASES = [
"SAP05:Windows",
"Solid, no insulation (assumed)", # A description typically associated with floors, not windows
"Suspended, no insulation (assumed)", # A description typically associated with floors, not windows
]
def __init__(self, description: str):
self.description: str = clean_description(description.lower())
# In the case of an empty description, we want to return a dictionary with all values set to False
# and indicate there was no data
self.nodata = not description or description in self.DATA_ANOMALY_MATCHES or description == "SAP05:Windows"
self.nodata = not description or description in self.DATA_ANOMALY_MATCHES or description in self.NO_DATA_CASES
translation = self.WELSH_TEXT.get(self.description)
if translation:

View file

@ -219,4 +219,9 @@ hotwater_cases = [
'heater_type': 'electric instantaneous', 'system_type': None, 'thermostat_characteristics': None,
'heating_scope': None, 'energy_recovery': 'waste water heat recovery', 'tariff_type': None, 'extra_features': None,
'chp_systems': None, 'distribution_system': None, 'no_system_present': None, 'assumed': False, "appliance": None},
{'original_description': 'Electric multipoint', 'heater_type': 'electric multipoint', 'system_type': None,
'thermostat_characteristics': None,
'heating_scope': None, 'energy_recovery': None, 'tariff_type': None, 'extra_features': None, 'chp_systems': None,
'distribution_system': None, 'no_system_present': None, 'appliance': None, 'assumed': False}
]

View file

@ -1664,5 +1664,49 @@ mainheat_cases = [
'has_mains_gas': False, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False,
'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False,
'has_b30k': False, 'has_assumed': True, 'has_electricaire': False, 'has_assumed_for_most_rooms': True,
'has_underfloor_heating': False}
'has_underfloor_heating': False},
{'original_description': 'Radiator heating, electric', 'has_radiators': False, 'has_fan_coil_units': False,
'has_pipes_in_screed_above_insulation': False,
'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': False,
'has_air_source_heat_pump': False, 'has_room_heaters': True, 'has_electric_storage_heaters': False,
'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False,
'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False,
'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, 'has_electric_heat_pump': False,
'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, 'has_exhaust_source_heat_pump': False,
'has_community_heat_pump': False, 'has_electric': True, 'has_mains_gas': False, 'has_wood_logs': False,
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
'has_assumed': False, 'has_electricaire': False, 'has_assumed_for_most_rooms': False,
'has_underfloor_heating': False},
{
'original_description': 'Hot-Water-Only Systems, gas',
'has_radiators': False, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False,
'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': False,
'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False,
'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False,
'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': True,
'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, 'has_electric_heat_pump': False,
'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, 'has_exhaust_source_heat_pump': False,
'has_community_heat_pump': False, 'has_electric': True, 'has_mains_gas': False, 'has_wood_logs': False,
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
'has_assumed': True, 'has_electricaire': False, 'has_assumed_for_most_rooms': False,
'has_underfloor_heating': False
},
{
"original_description": "heat pump, warm air, mains gas", # This gets remapped to air source heat pump
'has_radiators': False, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False,
'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': False,
'has_air_source_heat_pump': True, 'has_room_heaters': False, 'has_electric_storage_heaters': False,
'has_warm_air': True, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False,
'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False,
'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, 'has_electric_heat_pump': False,
'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, 'has_exhaust_source_heat_pump': False,
'has_community_heat_pump': False, 'has_electric': False, 'has_mains_gas': True, 'has_wood_logs': False,
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': False,
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False,
'has_assumed': False, 'has_electricaire': False, 'has_assumed_for_most_rooms': False,
'has_underfloor_heating': False
}
]

View file

@ -397,7 +397,7 @@ clean_roof_test_cases = [
'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'none'},
{'original_description': 'Average thermal transmittance 0.80 W/m+é-¦K', 'thermal_transmittance': 0.8,
'thermal_transmittance_unit': 'w/m-¦k', 'is_pitched': False, 'is_roof_room': False,
'thermal_transmittance_unit': 'w/m-¦k', 'is_pitched': False, 'is_roof_room': False,
'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': False,
'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': None}
]

View file

@ -0,0 +1,52 @@
import pandas as pd
from utils.s3 import save_csv_to_s3
def app():
"""
This script sets up
:return:
"""
portfolio_id = 108
# Read in the portfolio EPC data
epc_data = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/sfr/20240820 portfolio_epc_data.xlsx"
)
asset_list = epc_data[
[
"ADDRESS1", "POSTCODE", "UPRN"
]
].copy().rename(
columns={
"ADDRESS1": "address",
"POSTCODE": "postcode",
"UPRN": "uprn"
}
)
# Store data and prepare payload
filename = f"{8}/{portfolio_id}/asset_list.csv"
save_csv_to_s3(
dataframe=asset_list,
bucket_name="retrofit-plan-inputs-dev",
file_name=filename
)
body = {
"portfolio_id": str(portfolio_id),
"housing_type": "Private",
"goal": "Increasing EPC",
"goal_value": "C",
"trigger_file_path": filename,
"already_installed_file_path": "",
"patches_file_path": "",
"non_invasive_recommendations_file_path": "",
"budget": None,
"scenario_name": "EPC C Package",
"multi_plan": True,
}
print(body)

View file

@ -0,0 +1,209 @@
import msgpack
import pandas as pd
from utils.s3 import read_from_s3
from recommendations.recommendation_utils import (
estimate_number_of_floors, esimtate_pitched_roof_area, estimate_external_wall_area, estimate_perimeter
)
def app():
"""
Aims to estimate the amount of GBIS funding eligible
:return:
"""
cleaned = read_from_s3(
s3_file_name="cleaned_epc_data/cleaned.bson",
bucket_name="retrofit-data-dev"
)
cleaned = msgpack.unpackb(cleaned, raw=False)
epc_data = pd.read_excel(
"/Users/khalimconn-kowlessar/Downloads/20240820 portfolio_epc_data.xlsx"
)
# For simplicity, get roofs or cavities
epc_data = epc_data.merge(
pd.DataFrame(cleaned["roof-description"]),
how="left",
left_on="ROOF_DESCRIPTION",
right_on="original_description"
)
epc_data["needs_roof_work"] = epc_data["insulation_thickness"].isin(
[
None,
"100",
'150',
'50',
'75',
'below average',
'25',
'12'
]
) & (epc_data["is_flat"] | epc_data["is_pitched"])
epc_data = epc_data.merge(
pd.DataFrame(cleaned["walls-description"]),
how="left",
left_on="WALLS_DESCRIPTION",
right_on="original_description",
suffixes=("", "_wall")
)
epc_data["needs_cavity_done"] = epc_data["is_cavity_wall"] & epc_data["insulation_thickness_wall"].isin(
['none', "below average"]
)
epc_data["needs_solid_wall"] = (epc_data["is_solid_brick"] | epc_data["is_system_built"]) & epc_data[
"insulation_thickness_wall"].isin(['none', "below average"])
epc_data["could_take_solar"] = (epc_data["is_flat"] | epc_data["is_pitched"])
loft_insulation_per_m2 = 16.07
flat_roof_insulation_per_m2 = 195
cwi_per_m2 = 14.21
ewi_per_m2 = 200
gbis_abs = 30
eco4_abs = 24
solar_pv_cost = 4009
# We assume the work will take the home from a high D to a low D
def get_abs(floor_area):
if floor_area <= 72:
return 155
if floor_area <= 97:
return 169
if floor_area <= 199:
return 196.4
return 350.1
# We assume the work will take the home from a high E to a high C
def get_eco4_abs(floor_area):
if floor_area <= 72:
return 596.6
if floor_area <= 97:
return 650.2
if floor_area <= 199:
return 755.8
return 1347.1
estimated_costs = []
for _, home in epc_data.iterrows():
to_append = {
"uprn": home["UPRN"],
"address": home["ADDRESS"],
"postcode": home["POSTCODE"],
}
project_abs = get_abs(home["TOTAL_FLOOR_AREA"])
available_funding = project_abs * gbis_abs
n_floors = estimate_number_of_floors(home["PROPERTY_TYPE"])
floor_height = float(home["FLOOR_HEIGHT"]) if not pd.isnull(home["FLOOR_HEIGHT"]) else 2.5
# We estimate the amount of insulation required
est_perimeter = estimate_perimeter(
floor_area=float(home["TOTAL_FLOOR_AREA"]) / n_floors,
num_rooms=float(home["NUMBER_HABITABLE_ROOMS"]) / n_floors
)
insulation_needed = estimate_external_wall_area(
num_floors=n_floors,
floor_height=floor_height,
perimeter=est_perimeter,
built_form=home["BUILT_FORM"],
)
# At the very least we'll need solid wall + solar
if home["needs_solid_wall"] and home["could_take_solar"]:
measure = "EWI + Solar"
total_cost = insulation_needed * ewi_per_m2 + solar_pv_cost
eco4_project_abs = get_eco4_abs(home["TOTAL_FLOOR_AREA"])
eco4_available_funding = eco4_project_abs * eco4_abs
cost_of_work_after_funding = total_cost - eco4_available_funding
cost_of_work_after_funding = 0 if cost_of_work_after_funding < 0 else cost_of_work_after_funding
to_append = {
**to_append,
"scheme": "eco4",
"available_funding": eco4_available_funding,
"measure": measure,
"project_abs": eco4_project_abs,
"cost_of_work": total_cost,
"cost_of_work_after_funding": cost_of_work_after_funding,
}
estimated_costs.append(to_append)
continue
# Check if it needs the walls done
if home["needs_cavity_done"]:
cost_of_insulation = insulation_needed * cwi_per_m2
cost_of_work_after_funding = cost_of_insulation - available_funding
cost_of_work_after_funding = 0 if cost_of_work_after_funding < 0 else cost_of_work_after_funding
to_append = {
**to_append,
"scheme": "gbis",
"available_funding": available_funding,
"measure": "Cavity Wall Insulation",
"project_abs": project_abs,
"cost_of_work": cost_of_insulation,
"cost_of_work_after_funding": cost_of_work_after_funding
}
estimated_costs.append(to_append)
continue
if home["needs_roof_work"]:
# We estimate how much the cost of insulation would be
if home["is_pitched"]:
measure = "Loft Insulation"
roof_area = float(home["TOTAL_FLOOR_AREA"]) / n_floors
cost_of_insulation = roof_area * loft_insulation_per_m2
else:
measure = "Flat Roof Insulation"
roof_area = float(home["TOTAL_FLOOR_AREA"]) / n_floors
cost_of_insulation = roof_area * flat_roof_insulation_per_m2
cost_of_work_after_funding = cost_of_insulation - available_funding
cost_of_work_after_funding = 0 if cost_of_work_after_funding < 0 else cost_of_work_after_funding
to_append = {
**to_append,
"scheme": "gbis",
"available_funding": available_funding,
"measure": measure,
"project_abs": project_abs,
"cost_of_work": cost_of_insulation,
"cost_of_work_after_funding": cost_of_work_after_funding
}
estimated_costs.append(to_append)
continue
estimated_costs = pd.DataFrame(estimated_costs)
estimated_costs.to_csv("/Users/khalimconn-kowlessar/Documents/hestia/Customers/sfr/estimated_costs_gbis.csv")
# epc_data[["UPRN", "ADDRESS", "POSTCODE"]].to_csv(
# "/Users/khalimconn-kowlessar/Documents/hestia/sfr/council_tax_bands_sample.csv")
n_properties_for_ashp = epc_data[
(epc_data["PROPERTY_TYPE"] == "House") &
(epc_data["BUILT_FORM"].isin(["Detached", "Semi-Detached"]))
].shape[0]

View file

@ -37,6 +37,37 @@ MCS_SOLAR_PV_COST_DATA = {
"average_cost_per_kwh-Northern Ireland": 1347,
}
INSTALLER_SOLAR_COSTS = [
{'n_panels': 4, 'array_kwp': 1.6, 'cost': 3040.00, 'installer': 'CEG'},
{'n_panels': 5, 'array_kwp': 2.1, 'cost': 3201.00, 'installer': 'CEG'},
{'n_panels': 6, 'array_kwp': 2.5, 'cost': 3363.00, 'installer': 'CEG'},
{'n_panels': 7, 'array_kwp': 2.9, 'cost': 3524.00, 'installer': 'CEG'},
{'n_panels': 8, 'array_kwp': 3.3, 'cost': 3686.00, 'installer': 'CEG'},
{'n_panels': 9, 'array_kwp': 3.7, 'cost': 3847.00, 'installer': 'CEG'},
{'n_panels': 10, 'array_kwp': 4.1, 'cost': 4009.00, 'installer': 'CEG'},
{'n_panels': 11, 'array_kwp': 4.5, 'cost': 4170.00, 'installer': 'CEG'},
{'n_panels': 12, 'array_kwp': 4.9, 'cost': 4332.00, 'installer': 'CEG'},
{'n_panels': 13, 'array_kwp': 5.3, 'cost': 4835.00, 'installer': 'CEG'},
{'n_panels': 14, 'array_kwp': 5.7, 'cost': 5015.00, 'installer': 'CEG'},
{'n_panels': 15, 'array_kwp': 6.2, 'cost': 5176.00, 'installer': 'CEG'},
{'n_panels': 16, 'array_kwp': 6.6, 'cost': 5338.00, 'installer': 'CEG'},
{'n_panels': 17, 'array_kwp': 7.0, 'cost': 5500.00, 'installer': 'CEG'},
{'n_panels': 18, 'array_kwp': 7.4, 'cost': 6021.00, 'installer': 'CEG'}
]
# This is the maximum number of panels that we have a cost from the installers for
INSTALLER_MAX_PANELS = 18
# CEG uses use Solshare as an inverter to provide solar PV to multiple flats. This costs £7500 for the inverter alone
# https://midsummerwholesale.co.uk/buy/solshare
INSTALLER_SOLAR_PV_INVERTER_COST = 7500
INSTALLER_SOLAR_PV_INVERTER_LABOUR_COST = 500 # Just a rough guess to labour costs
INSTALLER_SCAFFOLDING_COSTS = [
{'stories': 1, 'description': '1 Story Scaffold', 'cost': 531.00, 'installer': 'CEG'},
{'stories': 2, 'description': '2 Story Scaffold', 'cost': 841.00, 'installer': 'CEG'},
{'stories': 3, 'description': '3 Story Scaffold', 'cost': 1077.00, 'installer': 'CEG'}
]
# This data is based on the MCS database, We use the larger figure between the 2023 and 2024 average,
# to be conservative
MCS_AIR_SOURCE_HEAT_PUMP_COST_DATA = {
@ -54,10 +85,27 @@ MCS_AIR_SOURCE_HEAT_PUMP_COST_DATA = {
"Scotland": 12586,
"Northern Ireland": 12000, # There are hardly any air source heat pump installs going on in Northern Ireland
}
INSTALLER_ASHP_COSTS = [
{'capacity_kw': 5.0, 'brand': 'Mitsubishi', 'tank_size_liters': 150, 'cost': 10149.53, 'installer': 'CEG'},
{'capacity_kw': 6.0, 'brand': 'Mitsubishi', 'tank_size_liters': 170, 'cost': 10823.48, 'installer': 'CEG'},
{'capacity_kw': 8.5, 'brand': 'Mitsubishi', 'tank_size_liters': 200, 'cost': 11312.43, 'installer': 'CEG'},
{'capacity_kw': 11.2, 'brand': 'Mitsubishi', 'tank_size_liters': 250, 'cost': 12156.75, 'installer': 'CEG'},
{'capacity_kw': 14.0, 'brand': 'Mitsubishi', 'tank_size_liters': 300, 'cost': 14405.54, 'installer': 'CEG'},
{'capacity_kw': 14.0, 'brand': 'Mitsubishi', 'tank_size_liters': 300, 'cost': 14405.54, 'installer': 'CEG'},
{'capacity_kw': 17.0, 'brand': 'Grant', 'tank_size_liters': 300, 'cost': 14445.00, 'installer': 'CEG'},
{'capacity_kw': 20.0, 'brand': 'Ecoforest', 'tank_size_liters': 400, 'cost': 21189.41, 'installer': 'CEG'},
{'capacity_kw': None, 'brand': '2 x cascaded ASHPs', 'tank_size_liters': 500, 'cost': 22950.00, 'installer': 'CEG'}
]
BOILER_UPGRADE_SCHEME_ASHP_VALUE = 7500
# This is based on quotes from installers
BATTERY_COST = 3500
INSTALLER_SOLAR_BATTERY_COSTS = [
{'capacity_kwh': 5, 'description': 'Battery Add on', 'cost': 2700.00, 'installer': 'CEG'},
{'capacity_kwh': 10, 'description': 'Battery Add on', 'cost': 4300.00, 'installer': 'CEG'},
{'capacity_kwh': 5, 'description': 'Battery Retrofit existing system', 'cost': 4250.00, 'installer': 'CEG'},
{'capacity_kwh': 10, 'description': 'Battery Retrofit Existing system', 'cost': 5950.00, 'installer': 'CEG'}
]
# This is based on https://www.checkatrade.com/blog/cost-guides/cost-smart-thermostat/
SMART_APPLIANCE_THERMOSTAT_COST = 400
@ -209,7 +257,6 @@ class Costs:
:return: A dictionary containing detailed cost breakdown.
"""
# CWI usually takes 1 day
labour_hours = 8
labour_days = 1
@ -224,118 +271,57 @@ class Costs:
"labour_days": labour_days,
}
material_cost_per_m2 = material["material_cost"]
base_material_cost = material_cost_per_m2 * wall_area
labour_cost = material["labour_cost"] * wall_area * self.labour_adjustment_factor
subtotal_before_profit = base_material_cost + labour_cost
contingency_cost = subtotal_before_profit * self.CONTINGENCY
preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
vat_cost = subtotal_before_vat * self.VAT_RATE
total_cost = subtotal_before_vat + vat_cost
total_including_vat = material["total_cost"] * wall_area
if is_extraction_and_refill:
# bump up the cost of the work
total_cost = total_cost + CAVITY_EXTRACTION_COST * wall_area
total_including_vat = CAVITY_EXTRACTION_COST * wall_area
# Additional 2 days work
labour_hours = labour_hours + (2 * 8)
labour_days = labour_days + 2
labour_hours += + (2 * 8)
labour_days += + 2
total_excluding_vat = total_including_vat / (1 + self.VAT_RATE)
vat_cost = total_including_vat - total_excluding_vat
return {
"total": total_cost,
"subtotal": subtotal_before_vat,
"total": total_including_vat,
"subtotal": total_excluding_vat,
"vat": vat_cost,
"contingency": contingency_cost,
"preliminaries": preliminaries_cost,
"material": base_material_cost,
"profit": profit_cost,
"labour_hours": labour_hours,
"labour_cost": labour_cost,
"labour_days": labour_days
}
def loft_insulation(self, floor_area, material):
def loft_and_flat_insulation(self, floor_area, material):
"""
Calculates the total cost for cavity wall insulation based on material and labor costs,
Calculates the total cost for loft/flat roof insulation based on material and labor costs,
including contingency, preliminaries, profit, and VAT.
:return: A dictionary containing detailed cost breakdown.
"""
labour_hours = material["labour_hours_per_unit"] * floor_area
# Assume a team of 1 person
labour_days = labour_hours / 8
if material["is_installer_quote"]:
total_cost = material["total_cost"] * floor_area
return {
"total": total_cost,
"labour_hours": labour_hours,
"labour_days": labour_days,
"labour_hours": 8,
"labour_days": 1,
}
material_cost_per_m2 = material["material_cost"]
# We inflate material costs due to recent price increases
material_cost_per_m2 = material_cost_per_m2 * 1.5
base_material_cost = material_cost_per_m2 * floor_area
labour_cost = material["labour_cost"] * floor_area * self.labour_adjustment_factor
subtotal_before_profit = base_material_cost + labour_cost
# We use high risk contingency because of the possibility of access issues and clearing existing insulation
contingency_cost = subtotal_before_profit * self.HIGH_RISK_CONTINGENCY
preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
vat_cost = subtotal_before_vat * self.VAT_RATE
total_cost = subtotal_before_vat + vat_cost
total_including_vat = material["total_cost"] * floor_area
total_excluding_vat = total_including_vat / (1 + self.VAT_RATE)
vat_cost = total_including_vat - total_excluding_vat
return {
"total": total_cost,
"subtotal": subtotal_before_vat,
"total": total_including_vat,
"subtotal": total_excluding_vat,
"vat": vat_cost,
"contingency": contingency_cost,
"preliminaries": preliminaries_cost,
"material": base_material_cost,
"profit": profit_cost,
"labour_hours": labour_hours,
"labour_cost": labour_cost,
"labour_days": labour_days
"labour_hours": 8,
"labour_days": 1
}
def internal_wall_insulation(self, wall_area, material, non_insulation_materials):
def solid_wall_insulation(self, wall_area, material):
"""
Broadly speaking, the high level steps to an internal wall insulation job are the following:
1) Demolition: This involves removing existing wall linings, fittings, and any other obstacles.
It's important to factor in the disposal of debris and the potential need for additional protective
measures to ensure the safety of the work area.
2) Insulation Installation: This is the core part of the process where the chosen insulation material is
applied. The choice of insulation material will depend on several factors including thermal performance,
wall construction, and space constraints.
3) Vapour Barrier Installation: This is crucial for preventing moisture from penetrating the insulation,
which can compromise its effectiveness and lead to mold growth.
4) Re-decoration: This involves applying plaster to the wall and then painting.
The quality of finish here is important for both aesthetic and functional reasons.
5) Trim and Finishing Work: Post-insulation, tasks such as re-installing skirting boards, door frames,
or window sills might be necessary.
Implements costing methodology now that we have direct quotes from installers.
:return:
"""
@ -355,74 +341,25 @@ class Costs:
"labour_days": labour_days,
}
# Extract and check the different types of data we'll need
demolition_data = [x for x in non_insulation_materials if x["type"] == "iwi_wall_demolition"]
vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "iwi_vapour_barrier"]
redecoration_data = [x for x in non_insulation_materials if x["type"] == "iwi_redecoration"]
if not demolition_data:
raise ValueError("No data found for iwi_wall_demolition")
if (len(vapour_barrier_data) != 1) or (len(redecoration_data) != 3):
raise ValueError("Incorrect number of data entries for non-insulation materials")
# Break out the individual material costs
# Since we don't know the exact wall construction, we take an average for demolition costs, since
# the cost will depend on the type of wall construction
demolition_material_costs = np.mean([x["material_cost"] * wall_area for x in demolition_data])
insulation_material_costs = material["material_cost"] * wall_area
vapour_barrier_material_costs = vapour_barrier_data[0]["material_cost"] * wall_area
redecoration_material_costs = sum([x["material_cost"] * wall_area for x in redecoration_data])
demolition_plant_costs = np.mean([x["plant_cost"] * wall_area for x in demolition_data])
# Again for demolition, we average since we aren't sure which demolition process will be used
demolition_labour_costs = np.mean([x["labour_cost"] * wall_area for x in demolition_data])
insulation_labour_costs = material["labour_cost"] * wall_area
vapour_barrier_labour_costs = vapour_barrier_data[0]["labour_cost"] * wall_area
redecoration_labour_costs = sum([x["labour_cost"] * wall_area for x in redecoration_data])
labour_costs = (demolition_labour_costs + insulation_labour_costs + vapour_barrier_labour_costs +
redecoration_labour_costs)
labour_costs = labour_costs * self.labour_adjustment_factor
materials_costs = (demolition_material_costs + insulation_material_costs + vapour_barrier_material_costs +
redecoration_material_costs)
subtotal_before_profit = labour_costs + materials_costs + demolition_plant_costs
contingency_cost = subtotal_before_profit * self.IWI_CONTINGENCY
preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
vat_cost = subtotal_before_vat * self.VAT_RATE
total_cost = subtotal_before_vat + vat_cost
demolition_labour_hours = np.mean([x["labour_hours_per_unit"] * wall_area for x in demolition_data])
insulation_labour_hours = material["labour_hours_per_unit"] * wall_area
vapour_barrier_labour_hours = vapour_barrier_data[0]["labour_hours_per_unit"] * wall_area
redecoration_labour_hours = sum([x["labour_hours_per_unit"] * wall_area for x in redecoration_data])
labour_hours = (demolition_labour_hours + insulation_labour_hours + vapour_barrier_labour_hours +
redecoration_labour_hours)
total_including_vat = material["total_cost"] * wall_area
total_excluding_vat = total_including_vat / (1 + self.VAT_RATE)
vat_cost = total_including_vat - total_excluding_vat
# We estimate 1 weeks worth of work
labour_hours = 160
# To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5 people
labour_days = (labour_hours / 8) / 4
return {
"total": total_cost,
"subtotal": subtotal_before_vat,
"total": total_including_vat,
"subtotal": total_excluding_vat,
"vat": vat_cost,
"contingency": contingency_cost,
"preliminaries": preliminaries_cost,
"material": materials_costs,
"profit": profit_cost,
"labour_hours": labour_hours,
"labour_days": labour_days,
"labour_cost": labour_costs
}
def suspended_floor_insulation(self, insulation_floor_area, material, non_insulation_materials):
@ -639,151 +576,6 @@ class Costs:
"labour_cost": labour_costs
}
def external_wall_insulation(self, wall_area, material, non_insulation_materials):
"""
We characterise external wall insulation as the following steps:
1) Preparation of the Area: Tidying up the surroundings, trimming back foliage, and laying down protective
sheets to protect the flooring and landscaping around the work area.
2) Scaffolding Setup (if needed): Erecting scaffolding for safe access to the walls of semi-detached or
detached houses. For terraced houses or lower-level work, scaffolding might not be necessary.
3) Wall Surface Preparation: Cleaning the wall surface, removing any loose or flaking material,
and possibly applying a primer. If the existing wall is weak or damaged, partial or full replacement
of the top surface may be necessary.
4) Applying Primer: If the existing wall is suitable, applying a primer to improve adhesion of the insulation
boards and stabilize the wall surface, especially if it's old or weathered.
5) Insulation Application: Attaching insulation boards to the primed wall using adhesive, mechanical fixings,
or a combination of both.
6) Basecoat and Mesh Application: Applying a basecoat embedded with a reinforcing mesh over the insulation.
This layer provides strength and helps prevent cracking.
7) Decorative Finish: Applying a decorative finish, such as render or cladding, which protects the insulation
and provides an aesthetic look.
8) Reinstalling Fixtures: Reattaching any fixtures like downpipes, satellite dishes, or lighting fixtures that
were removed during preparation. Extensions or adjustments may be required due to the increased wall thickness.
9) Inspection and Cleanup: Conducting a thorough inspection to ensure quality and integrity of the EWI system,
followed by cleaning up the site to remove all debris and materials.
In the actual materials data, at this point, we have costing for:
- wall preparation, hacking off existing wall finishes, linings, etc (ewi_wall_demolition)
- wall surface cleaning and priming (ewi_wall_preparation)
- insulation (external_wall_insulation)
- basecoat and mesh with decorative render topcoat finish (ewi_basecoat_and_mesh)
All of this data comes from SPONS, however there are some clear features missing. Because we could not find
suitable cost records in SPONS for steps like cleaning the area, setting up small scale scaffolding,
re-attaching any fitings and cleaning up the area afterwards, instead we have accounted for these steps by
increasing the preliminaries rate. It is acknowldeged though, that this is not ideal and that the cost of these
steps should be included in the materials data. We will look to improve this in the future, with data from
installers
:param wall_area:
:param material:
:param non_insulation_materials:
:return:
"""
if material["is_installer_quote"]:
total_cost = material["total_cost"] * wall_area
# Add on a buffer for scaffolding
if self.property.data["property-type"] == "House":
total_cost += self.EWI_SCAFFOLDING_PRELIMINARIES * total_cost
labour_hours = material["labour_hours_per_unit"] * wall_area
# To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5
# people
labour_days = (labour_hours / 8) / 4
return {
"total": total_cost,
"labour_hours": labour_hours,
"labour_days": labour_days,
}
# For semi detatched and detatched houses, as well as maisonettes, we price for scaffolding
if self.property.data["property-type"] == "House":
if self.property.data["built-form"] in ['Semi-Detached', 'Detached', "End-Terrace"]:
preliminaries_rate = self.EWI_SCAFFOLDING_PRELIMINARIES
else:
preliminaries_rate = self.EWI_NO_SCAFFOLDING_PRELIMINARIES
elif self.property.data["property-type"] in ["Maisonette", "Flat"]:
preliminaries_rate = self.EWI_SCAFFOLDING_PRELIMINARIES
elif self.property.data["property-type"] == "Bungalow":
preliminaries_rate = self.EWI_NO_SCAFFOLDING_PRELIMINARIES
demolition_data = [x for x in non_insulation_materials if x["type"] == "ewi_wall_demolition"]
preparation_data = [x for x in non_insulation_materials if x["type"] == "ewi_wall_preparation"]
redecoration_data = [x for x in non_insulation_materials if x["type"] == "ewi_wall_redecoration"]
if (len(demolition_data) != 3) or (len(preparation_data) != 1) or (len(redecoration_data) != 1):
raise ValueError("Incorrect number of data entries for non-insulation materials")
# Break out the individual material costs
# Since we don't know the exact wall construction, we take an average for demolition costs, since
# the cost will depend on the type of wall construction
demolition_material_costs = np.mean([x["material_cost"] * wall_area for x in demolition_data])
insulation_material_costs = material["material_cost"] * wall_area
preparation_material_costs = preparation_data[0]["material_cost"] * wall_area
redecoration_material_costs = redecoration_data[0]["material_cost"] * wall_area
demolition_plant_costs = np.mean([x["plant_cost"] * wall_area for x in demolition_data])
demolition_labour_costs = np.mean([x["labour_cost"] * wall_area for x in demolition_data])
insulation_labour_costs = material["labour_cost"] * wall_area
preparation_labour_costs = preparation_data[0]["labour_cost"] * wall_area
redecoration_labour_costs = redecoration_data[0]["labour_cost"] * wall_area
labour_costs = (demolition_labour_costs + insulation_labour_costs + redecoration_labour_costs +
preparation_labour_costs)
labour_costs = labour_costs * self.labour_adjustment_factor
materials_costs = (demolition_material_costs + insulation_material_costs + preparation_material_costs +
redecoration_material_costs)
subtotal_before_profit = labour_costs + materials_costs + demolition_plant_costs
contingency_cost = subtotal_before_profit * self.CONTINGENCY
preliminaries_cost = subtotal_before_profit * preliminaries_rate
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
vat_cost = subtotal_before_vat * self.VAT_RATE
total_cost = subtotal_before_vat + vat_cost
demolition_labour_hours = np.mean([x["labour_hours_per_unit"] * wall_area for x in demolition_data])
insulation_labour_hours = material["labour_hours_per_unit"] * wall_area
preparation_labour_hours = preparation_data[0]["labour_hours_per_unit"] * wall_area
redecoration_labour_hours = redecoration_data[0]["labour_hours_per_unit"] * wall_area
labour_hours = (demolition_labour_hours + insulation_labour_hours + redecoration_labour_hours +
preparation_labour_hours)
# Assume a team of 3-5 people for a small to medium size project
labour_days = (labour_hours / 8) / 4
return {
"total": total_cost,
"subtotal": subtotal_before_vat,
"vat": vat_cost,
"contingency": contingency_cost,
"preliminaries": preliminaries_cost,
"material": materials_costs,
"profit": profit_cost,
"labour_hours": labour_hours,
"labour_days": labour_days,
"labour_cost": labour_costs
}
def low_energy_lighting(self, number_of_lights, number_current_lel_lights, material):
"""
@ -832,92 +624,6 @@ class Costs:
"labour_cost": labour_cost
}
def flat_roof_insulation(self, floor_area, material, non_insulation_materials):
"""
A model of a warm, flat roof construction can be seen in this video:
https://www.youtube.com/watch?v=WZ6Ng6YI9OA
Warm, flat roof insulation will normally be 100-125mm in depth
We break this measure down into the following jobs to be done
1) Preparation of the room. This involves cleaning the existing roof surface, removing any debris and repairing
any damage. Additionally, an edge barrier will likely need to be installed, to protect the sides of the
roof from water ingress.
2) Primer Application. A layer of primer is applied to the clean roof surface to enhance the adhestia of
subsequent layers, and seal the existing roof surface.
3) Vapour Proof Layer Installation. Lay a vapour control layer to prevent moisture ingress from inside the
building, which is essential in warm roof construction.
4) Insulation Layer Application. Place and securely fix insulation boards over the roof. These could be rigid
boards like PIR (Polyisocyanurate).
5) Waterproofing Membrane Installation: Cover the insulation (and timber layer, if used) with a
waterproofing membrane, like EPDM, PVC, or bituminous felt. Carefully seal all joints, edges, and around any
roof penetrations to ensure water tightness
:param floor_area: Area of the flat roof to be insulated, based on the area of the floor
:param material: Selected insulation material
:param non_insulation_materials: Non-insulation materials required for the job
:return:
"""
preparation_data_m2 = [
x for x in non_insulation_materials if
(x["type"] == "flat_roof_preparation") and (x["cost_unit"] == "gbp_per_m2")
]
vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "flat_roof_vapour_barrier"]
waterproofing_data = [x for x in non_insulation_materials if x["type"] == "flat_roof_waterproofing"]
if (len(preparation_data_m2) != 2) or (len(vapour_barrier_data) != 1) or (
len(waterproofing_data) != 1):
raise ValueError("Incorrect number of data entries for non-insulation materials")
# Break out the individual material costs
preparation_m2_material_costs = sum([x["material_cost"] * floor_area for x in preparation_data_m2])
vapour_barrier_material_costs = vapour_barrier_data[0]["material_cost"] * floor_area
insulation_material_costs = material["material_cost"] * floor_area
preparation_m2_labour_costs = sum([x["labour_cost"] * floor_area for x in preparation_data_m2])
vapour_barrier_labour_costs = vapour_barrier_data[0]["labour_cost"] * floor_area
# For waterproofing and upstand, we only have a total cost
waterproofing_total_costs = waterproofing_data[0]["total_cost"] * floor_area
labour_costs = preparation_m2_labour_costs + vapour_barrier_labour_costs
labour_costs = labour_costs * self.labour_adjustment_factor
materials_costs = preparation_m2_material_costs + vapour_barrier_material_costs + insulation_material_costs
subtotal_before_profit = labour_costs + materials_costs + waterproofing_total_costs
contingency_cost = subtotal_before_profit * self.FLAT_ROOF_CONTINGENCY
preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
vat_cost = subtotal_before_vat * self.VAT_RATE
total_cost = subtotal_before_vat + vat_cost
preparation_m2_labour_hours = sum([x["labour_hours_per_unit"] * floor_area for x in preparation_data_m2])
vapour_barrier_labour_hours = vapour_barrier_data[0]["labour_hours_per_unit"] * floor_area
waterproofing_labour_hours = waterproofing_data[0]["labour_hours_per_unit"] * floor_area
labour_hours = preparation_m2_labour_hours + vapour_barrier_labour_hours + waterproofing_labour_hours
# To install flat roof insulation, assume a small/medium project might be conducted by a team of 2-4.
# We'll assume a team of 2 since a lot of the roofs will be on the smaller side and will review this later
labour_days = (labour_hours / 8) / 2
return {
"total": total_cost,
"subtotal": subtotal_before_vat,
"vat": vat_cost,
"contingency": contingency_cost,
"preliminaries": preliminaries_cost,
"material": materials_costs,
"profit": profit_cost,
"labour_hours": labour_hours,
"labour_days": labour_days,
"labour_cost": labour_costs
}
def window_glazing(self, number_of_windows, material, is_secondary_glazing=False):
"""
We characterise the jobs to be done for window glazing as the following:
@ -1013,7 +719,15 @@ class Costs:
"labour_days": labour_days
}
def solar_pv(self, wattage: float, has_battery: bool = False, array_cost=None):
def solar_pv(
self,
n_panels: int | float,
has_battery: bool = False,
array_cost=None,
n_floors: int = 1,
battery_kwh: int = 5,
needs_inverter=False
):
"""
Calculates the total cost for solar PV based data provided by the MCS dashboard, which contains
@ -1025,23 +739,40 @@ class Costs:
Price can also be benchmarked against this checkatrade article:
https://www.checkatrade.com/blog/cost-guides/cost-of-solar-panel-installation/
:param wattage: Peak wattage of the solar PV system]
:param n_panels: Number of solar panels
:param has_battery: Bool, whether the system includes a battery
:param array_cost: float, containing the cost of the solar PV array
:param n_floors: int, number of floors in the property, used to estimate the cost of scaffolding
:param battery_kwh: int, capacity of the battery in kWh. Defaulted to 5
:param needs_inverter: Bool, whether the system needs an inverter, where the solar panels are feeding multiple
units
"""
# Get the cost data relevant to the region
regional_cost = MCS_SOLAR_PV_COST_DATA["-".join(["average_cost_per_kwh", self.region])]
if array_cost is not None:
total_cost = array_cost
if n_panels > INSTALLER_MAX_PANELS:
base_cost = [c for c in INSTALLER_SOLAR_COSTS if c["n_panels"] == INSTALLER_MAX_PANELS][0]["cost"]
cost_per_panel = [
c for c in INSTALLER_SOLAR_COSTS if c["n_panels"] == (INSTALLER_MAX_PANELS - 1)
][0]["cost"]
cost_per_panel = base_cost - cost_per_panel
system_cost = base_cost + (n_panels - INSTALLER_MAX_PANELS) * cost_per_panel
else:
kw = wattage / 1000
total_cost = kw * regional_cost
system_cost = [c for c in INSTALLER_SOLAR_COSTS if c["n_panels"] == n_panels][0]["cost"]
total_cost = array_cost if array_cost is not None else system_cost
if has_battery:
# The battery cost is based on the £3500 quote, recieved from installers
total_cost += BATTERY_COST
battery_cost = [c for c in INSTALLER_SOLAR_BATTERY_COSTS if c["capacity_kwh"] == battery_kwh][0]["cost"]
total_cost += battery_cost
scaffolding_cost = [c for c in INSTALLER_SCAFFOLDING_COSTS if c["stories"] == n_floors][0]["cost"]
total_cost += scaffolding_cost
if needs_inverter:
total_cost += INSTALLER_SOLAR_PV_INVERTER_COST
# We also add an additional labour cost
total_cost += INSTALLER_SOLAR_PV_INVERTER_LABOUR_COST
# We add an additional cost for scaffolding
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)

View file

@ -9,7 +9,7 @@ class FireplaceRecommendations(Definitions):
"""
# This is our base assumption for the cost of the work
COST_OF_WORK = 300
COST_OF_WORK = 235
def __init__(
self,

View file

@ -68,7 +68,8 @@ class FloorRecommendations(Definitions):
measures = MEASURE_MAP["floor_insulation"] if measures is None else measures
if not measures:
# If we have no measures or none of the measures are relevant, we can't recommend anything
if not measures or not any(x in measures for x in MEASURE_MAP["floor_insulation"]):
return
u_value = self.property.floor["thermal_transmittance"]
@ -223,7 +224,9 @@ class FloorRecommendations(Definitions):
simulation_config = {
**floor_simulation_config,
"floor_thermal_transmittance_ending": new_u_value,
# We don't simulate the impact using this U-value, but rather the average because this
# variable is way too volatile. Will likely be removed from the model
"floor_thermal_transmittance_ending": 0.685593,
}
self.recommendations.append(

View file

@ -12,8 +12,11 @@ class HeatingControlRecommender:
self.recommendation = []
def recommend(self, heating_description):
def recommend(self, heating_description, description_prefix="", description_suffix=""):
# TODO: Many of these functions are quite similar. We can possibly create a single wrapper function that
# takes in the heating description and the description prefix/suffix, and then creates the appropriate
# output
# Reset the recommendations
self.recommendation = []
@ -24,14 +27,14 @@ class HeatingControlRecommender:
return
if heating_description in ["Electric storage heaters", "Electric storage heaters, radiators"]:
self.recommend_high_heat_retention_controls()
self.recommend_high_heat_retention_controls(description_prefix=description_prefix)
return
if heating_description in ["Boiler and radiators, mains gas"]:
# We can recommend roomstat programmer trvs
self.recommend_roomstat_programmer_trvs()
self.recommend_roomstat_programmer_trvs(description_suffix=description_suffix)
# We can also recommend time and temperature zone controls
self.recommend_time_temperature_zone_controls()
self.recommend_time_temperature_zone_controls(description_suffix=description_suffix)
return
@ -94,16 +97,22 @@ class HeatingControlRecommender:
# We don't implement any other recommendations right now
return
def recommend_high_heat_retention_controls(self):
def recommend_high_heat_retention_controls(self, description_prefix=""):
"""
When applicable, we recommend upgrading the heating controls to high heat retention controls. This is a
specific type of control system that is designed to work with electric storage heaters. It is a more
efficient control system than the standard controls that come with electric storage heaters.
We can then consider the heating system itself
If there is a description prefix, this means there is a dual heating system and so we need to add this to the
description
:return:
"""
new_description = "Controls for high heat retention storage heaters"
if description_prefix:
new_description = f"{description_prefix}, {new_description}"
# We recommend upgrading to Celect type controls
ending_config = MainheatControlAttributes(new_description).process()
@ -112,7 +121,10 @@ class HeatingControlRecommender:
new_config=ending_config, old_config=self.property.main_heating_controls
)
# This upgrade will only take the heating system to average energy efficiency
simulation_config["mainheatc_energy_eff_ending"] = "Good"
if self.property.data["mainheatc-energy-eff"] in ["Poor", "Very Poor", "Average"]:
simulation_config["mainheatc_energy_eff_ending"] = "Good"
else:
simulation_config["mainheatc_energy_eff_ending"] = self.property.data["mainheatc-energy-eff"]
description_simulation = {
"mainheatcont-description": new_description,
@ -121,7 +133,7 @@ class HeatingControlRecommender:
self.recommendation.append(
{
"description": "upgrade heating controls to High Heat Retention Storage Heater Controls",
"description": "Upgrade heating controls to High Heat Retention Storage Heater Controls",
**self.costs.celect_type_controls(),
"simulation_config": simulation_config,
"description_simulation": description_simulation
@ -131,7 +143,7 @@ class HeatingControlRecommender:
# We don't implement any other recommendations right now
return
def recommend_roomstat_programmer_trvs(self):
def recommend_roomstat_programmer_trvs(self, description_suffix=""):
"""
If the home has a boiler and radiators, mains gas, we start by identifying potential heating controls that could
be upgraded, that would provide a practical impact.
@ -163,6 +175,8 @@ class HeatingControlRecommender:
return
new_controls_description = "Programmer, room thermostat and TRVS"
if description_suffix:
new_controls_description = f"{new_controls_description}, {description_suffix}"
ending_config = MainheatControlAttributes(new_controls_description).process()
# We use this to determine how we should be updating the config
@ -192,7 +206,7 @@ class HeatingControlRecommender:
has_trvs=has_trvs
)
description = "upgrade heating controls to Room thermostat, programmer and TRVs"
description = "Upgrade heating controls to Room thermostat, programmer and TRVs"
already_installed = "heating_control" in self.property.already_installed
if already_installed:
@ -216,7 +230,7 @@ class HeatingControlRecommender:
return
def recommend_time_temperature_zone_controls(self):
def recommend_time_temperature_zone_controls(self, description_suffix=""):
"""
If the home has a boiler, we can recommend time and temperature zone controls. This is a more advanced
and more efficient control system than the standard controls that come with a boiler. However, it may come
@ -238,6 +252,8 @@ class HeatingControlRecommender:
return
new_controls_description = "Time and temperature zone control"
if description_suffix:
new_controls_description = f"{new_controls_description}, {description_suffix}"
ending_config = MainheatControlAttributes(new_controls_description).process()
@ -260,8 +276,10 @@ class HeatingControlRecommender:
number_heated_rooms=int(self.property.data["number-heated-rooms"])
)
description = ("Upgrade heating controls to Smart Thermostats, room sensors and smart radiator valves (time & "
"temperature zone control)")
description = (
"Upgrade heating controls to Smart Thermostats, room sensors and smart radiator valves (time & "
"temperature zone control)"
)
already_installed = "heating_control" in self.property.already_installed
if already_installed:

View file

@ -1,5 +1,7 @@
from recommendations.Costs import Costs, BOILER_UPGRADE_SCHEME_ASHP_VALUE
from recommendations.recommendation_utils import check_simulation_difference, override_costs
from recommendations.recommendation_utils import (
check_simulation_difference, override_costs, combine_recommendation_configs
)
from backend.Property import Property
from backend.app.plan.schemas import MEASURE_MAP
from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes
@ -9,15 +11,53 @@ from recommendations.HeatingControlRecommender import HeatingControlRecommender
class HeatingRecommender:
ELECTRIC_HEATING_DESCRIPTIONS = [
"Room heaters, electric",
"Electric storage heaters",
"Electric storage heaters, radiators",
"Portable electric heaters assumed for most rooms",
]
high_heat_retention_contols_desc = "Controls for high heat retention storage heaters"
DUAL_HEATING_DESCRIPTIONS = {
"Boiler and radiators, mains gas, electric storage heaters": {
"hhr": {
"mainheating_description": "Boiler and radiators, mains gas, Electric storage heaters",
"recommendation_description": "Install high heat retention electric storage heaters alongside the "
"boiler. The current electric heaters may be retrofit with high heat "
"retention storage controls"
" however this is dependent on the existing system and may not be "
"possible.",
"controls_prefix": "current_controls"
},
"boiler": {
"mainheating_description": "Boiler and radiators, mains gas, electric storage heaters",
"recommendation_description": "Upgrade the existing boiler to a new, more efficient condensing "
"boiler. ",
"controls_suffix": "Manual charge controls"
},
# These are the heating types we need to produce a dual heating recommendation
"dual": {
"recommendation_description": "Upgrade both the existing boiler to a new condensing boiler and"
" upgrade storage heaters to high heat retention storage heaters.",
"types": [
# type 1
"boiler_upgrade",
# type 2
"high_heat_retention_storage_heater",
]
}
},
"Portable electric heaters assumed for most rooms, room heaters, electric": {
"hhr": {
"mainheating_description": "Electric storage heaters, radiators",
"recommendation_description": "Install high heat retention electric storage heaters.",
"controls_prefix": ""
},
"boiler": {
"mainheating_description": "Boiler and radiators, mains gas",
"recommendation_description": "Upgrade to a new condensing boiler.",
"controls_suffix": ""
},
# These are the heating types we need to produce a dual heating recommendation
"dual": None
}
}
def __init__(self, property_instance: Property):
self.property = property_instance
self.costs = Costs(self.property)
@ -26,25 +66,50 @@ class HeatingRecommender:
self.heating_control_recommendations = []
self.has_electric_heating_description = (
self.property.main_heating["clean_description"] in self.ELECTRIC_HEATING_DESCRIPTIONS
self.property.main_heating["has_electric"] or self.property.main_heating["has_electricaire"]
)
self.has_ashp = self.property.main_heating["has_air_source_heat_pump"]
self.has_room_heaters = (
self.property.main_heating["has_room_heaters"] or
self.property.main_heating["has_portable_electric_heaters"]
)
self.has_boiler = self.property.main_heating["has_boiler"]
self.dual_heating = self.identify_dual_heating()
def identify_dual_heating(self):
# All heat systems are in here so we identify whether two of these are true
# MainHeatAttributes.HEAT_SYSTEMS
n_trues = 0
for heat_system in MainHeatAttributes.HEAT_SYSTEMS:
if self.property.main_heating[f"has_{heat_system.replace(' ', '_')}"]:
n_trues += 1
if n_trues > 2 or n_trues == 0:
raise Exception("Implement me")
if n_trues == 1:
return False
return True
def is_high_heat_retention_valid(self, ashp_only_heating_recommendation, measures):
"""
Check conditions if high heat retention storage is valid
If there's already an ASHP in place, we don't recommend HHR
:return:
"""
# If the property has assumed electric heating, regardless of whether or not it has a mains connection, we
# can consider hhr storage heaters
electric_heating_assumed = (
self.property.main_heating["clean_description"] in ["No system present, electric heaters assumed"]
)
# We can also recommend hhr if the property doesn't have a mains has connection
no_mains = not self.property.data["mains-gas-flag"]
has_electric = self.has_electric_heating_description or electric_heating_assumed
# If the property already has room heaters then we recommend HHR as an option since the home already has
# a variation of room heaters
hhr_suitable = no_mains or self.has_electric_heating_description or self.has_room_heaters
return (
has_electric and (not ashp_only_heating_recommendation) and
hhr_suitable and (not ashp_only_heating_recommendation) and not self.has_ashp and
("high_heat_retention_storage_heater" in measures)
)
@ -55,7 +120,8 @@ class HeatingRecommender:
"""
# 1) if the property has mains heating with boiler and radiators, we recommend optimal heating controls
has_boiler = self.property.main_heating["clean_description"] in ["Boiler and radiators, mains gas"]
# If it's NOT a gas boiler, we'll potentially recommend a boiler
has_gas_boiler = self.has_boiler and self.property.main_heating["has_mains_gas"]
# 2) If the property doesn't have a heating system, but it has access to the mains gas
no_heating_has_mains = self.property.main_heating["clean_description"] in [
@ -63,33 +129,100 @@ class HeatingRecommender:
] and self.property.data["mains-gas-flag"]
# The property is using portable heaters and has access to gas mains
has_room_heaters = (
self.property.main_heating["clean_description"] in ["Room heaters, mains gas", "Room heaters, electric"] and
self.property.data["mains-gas-flag"]
)
has_room_heaters = self.has_room_heaters and self.property.data["mains-gas-flag"]
# We also check if the property has electric heating, but it has access to the mains gas
electic_heating_has_mains = self.has_electric_heating_description and self.property.data["mains-gas-flag"]
portable_heaters_has_mains = (
self.property.main_heating["clean_description"] in ["Portable electric heaters assumed for most rooms"]
and
self.property.main_heating["has_portable_electric_heaters"] and self.property.data["mains-gas-flag"]
)
# The next condition is if the home has a non-gas boiler, such as an oil boiler, with a mains gas connection
non_gas_boiler = (
self.property.main_heating["has_boiler"] and
not self.property.main_heating["has_mains_gas"] and
self.property.data["mains-gas-flag"]
)
# Additionally, if the property has a gas connection, is using gas heating but doesn't have a boiler,
# we recommend a boiler
non_boiler_gas_heating = (
self.property.data["mains-gas-flag"] and
self.property.main_heating["has_mains_gas"] and
not self.property.main_heating["has_boiler"]
)
is_valid = (
(
has_boiler or
has_gas_boiler or
no_heating_has_mains or
electic_heating_has_mains or
has_room_heaters or
portable_heaters_has_mains
portable_heaters_has_mains or
non_gas_boiler or
non_boiler_gas_heating
) and
(not ashp_only_heating_recommendation) and
("boiler_upgrade" in measures)
("boiler_upgrade" in measures) and
(not self.has_ashp)
)
return is_valid, has_boiler
return is_valid, has_gas_boiler
def recommend_dual_heating(self):
if self.property.main_heating["clean_description"] not in self.DUAL_HEATING_DESCRIPTIONS:
return
# if we have set dual to None, we do not produce a dual heating recommendation
if self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["dual"] is None:
return
dual_heating_description = self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["dual"]["types"]
recommendation_system_types = list(set([x["system_type"] for x in self.heating_recommendations]))
# We check if we have the required type
if not any([x in recommendation_system_types for x in dual_heating_description]):
return
type_1_recommendations = [
x for x in self.heating_recommendations if x["system_type"] == dual_heating_description[0]
]
type_2_recommendations = [
x for x in self.heating_recommendations if x["system_type"] == dual_heating_description[1]
]
# we combine the two recommendations
combined_recommendations = []
for rec in type_1_recommendations:
for rec2 in type_2_recommendations:
combined_rec = rec.copy()
# Update the description
combined_rec["description"] = self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["dual"]["recommendation_description"]
# Combine simulation_config
# Make sure we end up with the best efficiecy values
combined_rec["simulation_config"] = combine_recommendation_configs(
rec["simulation_config"], rec2["simulation_config"]
)
# Combine description_simulation
combined_rec["description_simulation"] = combine_recommendation_configs(
rec["description_simulation"], rec2["description_simulation"]
)
# Combine costs
for k in ["total", "subtotal", "vat", "labour_hours", "labour_days"]:
combined_rec[k] = rec[k] + rec2[k]
combined_recommendations.append(combined_rec)
self.heating_recommendations.extend(combined_recommendations)
def recommend(self, has_cavity_or_loft_recommendations, phase=0, measures=None):
"""
@ -130,26 +263,26 @@ class HeatingRecommender:
if hhr_valid:
# Recommend high heat retention storage heaters
# TODO: We need to allow for the possibility that the property aleady has storage heaters, but just
# needs the controls
self.recommend_hhr_storage_heaters(phase=phase, system_change=True, heating_controls_only=False)
gas_boiler_suitable, has_boiler = self.is_boiler_upgrade_suitable(
gas_boiler_suitable, has_gas_boiler = self.is_boiler_upgrade_suitable(
measures=measures, ashp_only_heating_recommendation=ashp_only_heating_recommendation
)
if gas_boiler_suitable:
# This indicates that the home previously did not have a boiler in place and so would require
# an overhaul to the system - right now, this is all reasons, apart from if there is an existing boiler
system_change = not has_boiler
exising_room_heaters = self.property.main_heating["clean_description"] in [
"Room heaters, electric", "Room heaters, mains gas"
]
system_change = not has_gas_boiler
exising_room_heaters = self.property.main_heating["has_room_heaters"]
self.recommend_boiler_upgrades(
phase=phase, system_change=system_change, exising_room_heaters=exising_room_heaters
)
# If we have dual heating and we allow for a combined recommendation, to upgrade both systems
if self.dual_heating:
self.recommend_dual_heating()
# We recommend air source heat pumps
# Heat pumps are suitable for all property types:
# https://energysavingtrust.org.uk/from-flats-to-terraced-houses-heat-pumps-are-suitable-for-all-property-types/
@ -157,7 +290,11 @@ class HeatingRecommender:
# In the future, we'll allow overrides, so that non-intrusive surveys can contradict these conditions
# and either allow or prevent the recommendation of an air source heat pump
if self.property.is_ashp_valid(measures=measures) and non_invasive_ashp_recommendation["suitable"]:
if (
self.property.is_ashp_valid(measures=measures) and
non_invasive_ashp_recommendation["suitable"] and
not self.has_ashp
):
self.recommend_air_source_heat_pump(
phase=phase,
has_cavity_or_loft_recommendations=has_cavity_or_loft_recommendations,
@ -418,7 +555,8 @@ class HeatingRecommender:
description,
phase,
heating_controls_only,
system_change
system_change,
system_type
):
"""
Given a recommendation for heating controls, and a recommendation for the heating system, we combine the two
@ -433,6 +571,7 @@ class HeatingRecommender:
:param system_change: Indicates if we are recommending a different type of heating system, compared to the
current system. If we have a system change and we have a heat control recommendation, we only recommend
both heating and controls together
:param system_type: The type of heating system we are recommending
:return:
"""
@ -467,12 +606,8 @@ class HeatingRecommender:
}
controls_description = controls_recommendations[0]['description']
# Make the first letter of the description lowercase
controls_description = (
controls_description[0].lower() + controls_description[1:]
)
recommendation_description = f"{description} and {controls_description}"
recommendation_description = f"{description} {controls_description}"
already_installed = "heating_controls" in self.property.already_installed
if already_installed:
@ -492,7 +627,9 @@ class HeatingRecommender:
"already_installed": already_installed,
**total_costs,
"simulation_config": recommendation_simulation_config,
"description_simulation": recommendation_description_simulation
"description_simulation": recommendation_description_simulation,
# We insert the heating system type here
"system_type": system_type
}
output.append(recommendation)
@ -548,6 +685,14 @@ class HeatingRecommender:
We will recommend upgrading to a high heat retention storage system, if the current system is not already
high heat retention storage
If the property currently has electric storage heaters, with automatic charge control, we allow for a high
heat retention stoarage heaters recommendation. This is because the automatic charge control is not the same
as the high heat retention storage heaters. HHR storage heaters aren't guaranteed to be more efficient but
we can at least present the option to the end user and they can decide if they want to go ahead with the
recommendation or not. There's a useful guide by quidos, describing the differences between some of the
different storage heater options:
https://www.quidos.co.uk/wp-content/uploads/2017/04/Technical-Bulletin-010417-Storage-Heatersv2.pdf
:param phase: The phase of the recommendation
:param system_change: Indicates if we are recommending a different type of heating system, compared to the
current system
@ -562,7 +707,24 @@ class HeatingRecommender:
# We only recommend Celect-type controls if the current heating system is not Celect-type controls
if self.property.main_heating_controls["clean_description"] != self.high_heat_retention_contols_desc:
controls_recommender.recommend(heating_description="Electric storage heaters, radiators")
if self.dual_heating:
controls_prefix = self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["hhr"]["controls_prefix"]
if controls_prefix == "current_controls":
description_prefix = self.property.main_heating_controls["clean_description"]
elif controls_prefix == "":
description_prefix = ""
else:
raise NotImplementedError("Implement me")
else:
description_prefix = ""
controls_recommender.recommend(
heating_description="Electric storage heaters, radiators", description_prefix=description_prefix
)
has_hhr = self.is_hhr_already_installed()
# Conditions for not recommending electric storage heaters
@ -570,7 +732,13 @@ class HeatingRecommender:
# No recommendation needed
return
new_heating_description = "Electric storage heaters, radiators"
# We check if the property has dual heating in place with a boiler and storage heaters
if self.dual_heating:
new_heating_description = self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["hhr"]["mainheating_description"]
else:
new_heating_description = "Electric storage heaters, radiators"
# Set up artefacts, suitable for the simulation and regardless of controls
heating_ending_config = MainHeatAttributes(new_heating_description).process()
@ -578,7 +746,10 @@ class HeatingRecommender:
new_config=heating_ending_config, old_config=self.property.main_heating
)
# This upgrade will only take the heating system to average energy efficiency
heating_simulation_config["mainheat_energy_eff_ending"] = "Average"
if self.property.data["mainheat-energy-eff"] in ["Very Poor", "Poor"]:
heating_simulation_config["mainheat_energy_eff_ending"] = "Average"
else:
heating_simulation_config["mainheat_energy_eff_ending"] = self.property.data["mainheat-energy-eff"]
# If the property is off-gas and has no heating system in place, the number of heated rooms will actually
# be 0, so we use the number of rooms as the figure
@ -589,11 +760,30 @@ class HeatingRecommender:
self.property.number_of_rooms
)
)
# To be conservative, we adjust if we still have 1 room
if (number_heated_rooms == 1) and (self.property.number_of_rooms > 2):
number_heated_rooms = self.property.number_of_rooms - 1
# Upgrade to electric storage heaters
costs = self.costs.high_heat_electric_storage_heaters(
number_heated_rooms=number_heated_rooms
)
description = "Install high heat retention electric storage heaters"
if self.dual_heating:
description = self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["hhr"]["recommendation_description"]
else:
description = "Install high heat retention electric storage heaters."
# We check the existing heating system and controls
if (
self.property.main_heating["has_electric_storage_heaters"] and
self.property.main_heating_controls["charging_system"] in
["automatic charge control", "manual charge control"]
):
description += (" The current electric heaters may be retrofit with high heat retention storage controls"
" however this is dependent on the existing system and may not be possible.")
heating_description_simulation = {
"mainheat-description": new_heating_description,
@ -608,7 +798,8 @@ class HeatingRecommender:
description=description,
phase=phase,
heating_controls_only=heating_controls_only,
system_change=system_change
system_change=system_change,
system_type="high_heat_retention_storage_heater"
)
if _return:
return recommendations
@ -688,12 +879,13 @@ class HeatingRecommender:
has_inefficient_space_heating = self.property.data["mainheat-energy-eff"] in ["Very Poor", "Poor", "Average"]
has_inefficient_mains_water = (
self.property.hotwater["clean_description"] in ["From main system"] and
# We check if there's a mains connection and the hot water is inefficient, as this will improve with a boiler
has_inefficient_water = (
self.property.data["mains-gas-flag"] and
self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor", "Average"]
)
if has_inefficient_space_heating or has_inefficient_mains_water:
if has_inefficient_space_heating or has_inefficient_water:
boiler_size = self.estimate_boiler_size(
property_type=self.property.data["property-type"],
built_form=self.property.data["built-form"],
@ -702,11 +894,26 @@ class HeatingRecommender:
num_heated_rooms=self.property.data["number-heated-rooms"],
)
description = "Upgrade to a new condensing boiler"
if self.dual_heating:
description = self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["boiler"]["recommendation_description"]
else:
description = "Upgrade to a new condensing boiler."
new_heating_eff = (
"Good" if self.property.data["mainheat-energy-eff"] in ["Very Poor", "Poor", "Average"]
else self.property.data["mainheat-energy-eff"]
)
new_hotwater_eff = (
"Good" if self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor", "Average"]
else self.property.data["hot-water-energy-eff"]
)
simulation_config = {
"mainheat_energy_eff_ending": "Good",
"hot_water_energy_eff_ending": "Good"
"mainheat_energy_eff_ending": new_heating_eff,
"hot_water_energy_eff_ending": new_hotwater_eff
}
description_simulation = {
@ -717,7 +924,13 @@ class HeatingRecommender:
if system_change:
# Installation of a boiler improves the hot water system so we need to reflect this in
# the outcome of the recommendation
new_heating_description = "Boiler and radiators, mains gas"
if self.dual_heating:
new_heating_description = self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["boiler"]["mainheating_description"]
else:
new_heating_description = "Boiler and radiators, mains gas"
new_hotwater_description = "From main system"
new_fuel_description = "mains gas (not community)"
@ -775,13 +988,23 @@ class HeatingRecommender:
"already_installed": already_installed,
"simulation_config": simulation_config,
"description_simulation": description_simulation,
**boiler_costs
**boiler_costs,
"system_type": "boiler_upgrade",
}
# We recommend the heating controls
# If the property did not previously have a boiler, we combine
controls_recommender = HeatingControlRecommender(self.property)
controls_recommender.recommend(heating_description="Boiler and radiators, mains gas")
if self.dual_heating:
description_suffix = self.DUAL_HEATING_DESCRIPTIONS[
self.property.main_heating["clean_description"]
]["boiler"]["controls_suffix"]
else:
description_suffix = ""
controls_recommender.recommend(
heating_description="Boiler and radiators, mains gas",
description_suffix=description_suffix
)
# We may have 2 recommendations from the heating controls
if not controls_recommender.recommendation and not boiler_recommendation:
@ -803,24 +1026,24 @@ class HeatingRecommender:
description=boiler_recommendation["description"],
phase=recommendation_phase,
heating_controls_only=False,
system_change=True
system_change=True,
system_type="boiler_upgrade"
)
combined_recommendations.extend(combined_recommendation)
# Overwrite the existing boiler recommendation
self.heating_recommendations.extend(combined_recommendations)
else:
# We increment the recommendation phase, since the heating controls are separate from the boiler upgrade
# but we'll only upgrade if we have a heating recommendation
has_heating_recommendation = any(
rec["type"] == "heating" for rec in self.heating_recommendations
)
if has_heating_recommendation:
recommendation_phase += 1
# The heating controls recommendation is distrinct from the boiler upgrade recommendation
# We insert phase into the recommendations for heating controls
# We consider a heating control upgrade as a measure which occures in the same phase as a boiler upgrade
# Namely, we have the following options within this phase
# 1) Boiler + heating controls
# 2) Boiler only
# 3) Heating controls only
# But they are options that are not mutually exclusive
# So, we actually set heating controls as a heating recommendation
for recommendation in controls_recommender.recommendation:
recommendation["phase"] = recommendation_phase
# recommendation["type"] = "heating"
self.heating_control_recommendations.extend(controls_recommender.recommendation)

View file

@ -1,3 +1,5 @@
import pandas as pd
from backend.Property import Property
from typing import List
from recommendations.Costs import Costs
@ -30,6 +32,37 @@ class LightingRecommendations:
self.material = material[0]
self.recommendation = []
@classmethod
def get_sap_limit(cls, lighting_energy_efficiency: str, lighting_proportion: float):
"""
Lighting seems to be a more straight forward measure to estimate SAP points for, based on the starting
energy efficiency rating.
We seem to have the following brackes based on % of LEDs in outlets
Very poor: 0 - 9%
Poor: 10 - 24%
Average: 25 - 44%
Good: 45 - 69%
Very good: 70 - 100%
:return:
"""
if lighting_energy_efficiency == "Very Good":
return 0
if lighting_energy_efficiency in ["Good", "Average"]:
return cls.SAP_LOWER_LIMIT
# If lighting_energy_efficiency is missing, we'll use the proportion of low energy lighting
if not lighting_energy_efficiency or pd.isnull(lighting_energy_efficiency):
if lighting_proportion >= 0.7:
return 0
if lighting_proportion >= 0.25:
return cls.SAP_LOWER_LIMIT
return cls.SAP_LIMIT
return cls.SAP_LIMIT
@staticmethod
def estimate_lighting_impact(number_of_bulbs: int):
"""

View file

@ -18,9 +18,8 @@ from recommendations.DraughtProofingRecommendations import DraughtProofingRecomm
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
from backend.apis.GoogleSolarApi import GoogleSolarApi
import backend.app.assumptions as assumptions
from backend.app.plan.schemas import TYPICAL_MEASURE_TYPES, SPECIFIC_MEASURES, MEASURE_MAP
from backend.app.plan.schemas import SPECIFIC_MEASURES, MEASURE_MAP, NON_INVASIVE_SPECIFIC_MEASURES
ASHP_COP = 3
STARTING_DUMMY_ID_VALUE = -9999
@ -50,8 +49,11 @@ class Recommendations:
self.exclusions = exclusions if exclusions else []
self.inclusions = inclusions if inclusions else []
self.all_typical_measures = TYPICAL_MEASURE_TYPES
self.all_specific_measures = SPECIFIC_MEASURES
self.all_non_invase_measures = NON_INVASIVE_SPECIFIC_MEASURES
self.non_invasive_recommendation_types = [
r["type"] for r in self.property_instance.non_invasive_recommendations
]
self.floor_recommender = FloorRecommendations(property_instance=property_instance, materials=materials)
self.wall_recomender = WallRecommendations(property_instance=property_instance, materials=materials)
@ -78,16 +80,29 @@ class Recommendations:
inclusions_full = [MEASURE_MAP[x] if x in MEASURE_MAP else x for x in self.inclusions]
exclusions_full = [MEASURE_MAP[x] if x in MEASURE_MAP else x for x in self.exclusions]
# We need to unlist any lists, but we should check if they're lists first
inclusions_full = [
item for sublist in inclusions_full for item in (sublist if isinstance(sublist, list) else [sublist])
]
exclusions_full = [
item for sublist in exclusions_full for item in (sublist if isinstance(sublist, list) else [sublist])
]
if inclusions_full and exclusions_full:
# All typical measures
return self.all_specific_measures
# If inclusions and exclusions are empty, it means that nothing was specified, so we allow
# all recommendation types
if not inclusions_full and not exclusions_full:
# All typical measures - this does not include non-invasive measures inless they are specified
return self.all_specific_measures + self.non_invasive_recommendation_types
if inclusions_full:
return inclusions_full
if exclusions_full:
return [m for m in self.all_specific_measures if m not in exclusions_full]
measures = [
m for m in self.all_specific_measures + self.non_invasive_recommendation_types
if m not in exclusions_full
]
return measures
def recommend(self):
@ -144,15 +159,20 @@ class Recommendations:
if self.draught_proofing_recommender.recommendation:
property_recommendations.append(self.draught_proofing_recommender.recommendation)
if "floor_insulation" in measures:
self.floor_recommender.recommend(phase=phase, measures=measures)
if self.floor_recommender.recommendations:
property_recommendations.append(self.floor_recommender.recommendations)
self.floor_recommender.recommend(phase=phase, measures=measures)
if self.floor_recommender.recommendations:
property_recommendations.append(self.floor_recommender.recommendations)
phase += 1
if "low_energy_lighting" in measures:
self.lighting_recommender.recommend(phase=phase)
if self.lighting_recommender.recommendation:
property_recommendations.append(self.lighting_recommender.recommendation)
phase += 1
if "windows" in measures and "mixed_glazing" not in non_invasive_recommendation_types:
if "mixed_glazing" not in non_invasive_recommendation_types:
# If we have a mixed glazing recommendation, we prioritise this over the windows recommendation
self.windows_recommender.recommend(phase=phase)
self.windows_recommender.recommend(phase=phase, measures=measures)
if self.windows_recommender.recommendation:
property_recommendations.append(self.windows_recommender.recommendation)
phase += 1
@ -226,12 +246,6 @@ class Recommendations:
property_recommendations.append(self.hotwater_recommender.recommendations)
phase += 1
if "low_energy_lighting" in measures:
self.lighting_recommender.recommend(phase=phase)
if self.lighting_recommender.recommendation:
property_recommendations.append(self.lighting_recommender.recommendation)
phase += 1
if "secondary_heating" in measures:
self.secondary_heating_recommender.recommend(phase=phase)
if self.secondary_heating_recommender.recommendation:
@ -531,11 +545,11 @@ class Recommendations:
# For the moment, we cap the number of SAP points that can be achieved by LEDs at 2
if rec["type"] == "low_energy_lighting":
lighting_sap_limit = LightingRecommendations.get_sap_limit(
property_instance.data["lighting-energy-eff"],
property_instance.lighting["low_energy_proportion"]
)
if property_instance.data["low-energy-lighting"] < 50:
lighting_sap_limit = LightingRecommendations.SAP_LIMIT
else:
lighting_sap_limit = LightingRecommendations.SAP_LOWER_LIMIT
property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit)
property_phase_impact["carbon"] = min(
property_phase_impact["carbon"], rec["co2_equivalent_savings"]

View file

@ -44,20 +44,14 @@ class RoofRecommendations:
self.recommendations = []
self.loft_insulation_materials = [
part for part in materials if part["type"] == "loft_insulation"
part for part in materials if (part["type"] == "loft_insulation") and (part["is_installer_quote"])
]
self.loft_non_insulation_materials = []
# We don't have proper installer quotes for flat roof insulation
self.flat_roof_insulation_materials = [
part for part in materials if part["type"] == "flat_roof_insulation"
]
self.flat_roof_non_insulation_materials = [
part for part in materials if part["type"] in [
"flat_roof_preparation", "flat_roof_vapour_barrier", "flat_roof_waterproofing"
]
]
# Extract the insulation thickness from the roof, which is used throughout this method
self.insulation_thickness = convert_thickness_to_numeric(
self.property.roof["insulation_thickness"],
@ -138,7 +132,7 @@ class RoofRecommendations:
# The Roof is already compliant
return
if self.property.data["transaction-type"] == "new dwelling":
if self.property.data["transaction-type"] in ["new dwelling", "not sale or rental"]:
return
raise NotImplementedError("Implement me")
@ -251,10 +245,8 @@ class RoofRecommendations:
if is_pitched:
insulation_materials = self.loft_insulation_materials
non_insulation_materials = self.loft_non_insulation_materials
elif is_flat:
insulation_materials = self.flat_roof_insulation_materials
non_insulation_materials = self.flat_roof_non_insulation_materials
else:
raise ValueError("Roof is not pitched or flat")
@ -266,7 +258,6 @@ class RoofRecommendations:
lowest_selected_u_value = None
recommendations = []
for _, insulation_material_group in insulation_materials.groupby("description"):
for _, material in insulation_material_group.iterrows():
# We make sure we hit a depth of 270mm. We should factor in any existing insulation if the
@ -297,14 +288,16 @@ class RoofRecommendations:
if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value)
cost_result = self.costs.loft_and_flat_insulation(
floor_area=self.property.insulation_floor_area,
material=material
)
already_installed = material["type"] in self.property.already_installed
if already_installed:
cost_result = override_costs(cost_result)
if material["type"] == "loft_insulation":
cost_result = self.costs.loft_insulation(
floor_area=self.property.insulation_floor_area,
material=material
)
already_installed = "loft_insulation" in self.property.already_installed
if already_installed:
cost_result = override_costs(cost_result)
new_thickness = insulation_thickness + material["depth"]
# This is based on the values we have in the training data
@ -341,14 +334,6 @@ class RoofRecommendations:
new_description = f"Pitched, {int(proposed_depth)}mm loft insulation"
elif material["type"] == "flat_roof_insulation":
cost_result = self.costs.flat_roof_insulation(
floor_area=self.property.insulation_floor_area,
material=material,
non_insulation_materials=non_insulation_materials
)
already_installed = "flat_roof_insulation" in self.property.already_installed
if already_installed:
cost_result = override_costs(cost_result)
new_description = "Flat, insulated"
new_efficiency = "Good"
else:

View file

@ -104,8 +104,13 @@ class SolarPvRecommendations:
roof_coverage_percent = round(recommendation_config["panneled_roof_area"] / total_roof_area * 100)
else:
raise Exception("IMPLEMENT ME")
# Spread the cost to the individual units - adding a 20% contingency
total_cost = recommendation_config["total_cost"] / n_units
total_cost = self.costs.solar_pv(
array_cost=recommendation_config.get("cost", None),
n_panels=recommendation_config["n_panels"],
n_floors=self.property.number_of_storeys["number_of_storeys"],
needs_inverter=True,
)["total"] / n_units
kw = np.floor(recommendation_config["array_wattage"] / 100) / 10
# Default to a weeks work for a team of 3 people doing 8 hour days
labour_days = 5
@ -194,9 +199,10 @@ class SolarPvRecommendations:
roof_coverage_percent = np.ceil(roof_coverage_percent / 10) * 10
for has_battery in [False, True]:
cost_result = self.costs.solar_pv(
wattage=recommendation_config["array_wattage"],
has_battery=has_battery,
array_cost=non_invasive_recommendation.get("cost", None)
array_cost=non_invasive_recommendation.get("cost", None),
n_panels=recommendation_config["n_panels"],
n_floors=self.property.number_of_floors
)
kw = np.floor(recommendation_config["array_wattage"] / 100) / 10
if has_battery:

View file

@ -52,7 +52,7 @@ class VentilationRecommendations(Definitions):
already_installed = "cavity_wall_insulation" in self.property.already_installed
estimated_cost = n_units * part[0]["cost"] if not already_installed else 0
estimated_cost = n_units * part[0]["total_cost"] if not already_installed else 0
labour_hours = 4 * n_units if not already_installed else 0
labour_days = 4 * n_units / 8.0 if not already_installed else 0

View file

@ -69,6 +69,7 @@ class WallRecommendations(Definitions):
"Timber frame, as built, no insulation": "Timber frame, with external insulation",
'Timber frame, as built, partial insulation': 'Timber frame, with external insulation',
"Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with external insulation",
"Sandstone, as built, no insulation": "Sandstone, with external insulation",
}
# These are the ending descriptions we consider for walls with internal insulation
@ -83,6 +84,7 @@ class WallRecommendations(Definitions):
"Timber frame, as built, no insulation": "Timber frame, with internal insulation",
'Timber frame, as built, partial insulation': 'Timber frame, with internal insulation',
"Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with internal insulation",
"Sandstone, as built, no insulation": "Sandstone, with internal insulation",
}
def __init__(
@ -106,23 +108,10 @@ class WallRecommendations(Definitions):
part for part in materials if part["type"] == "internal_wall_insulation"
]
self.internal_wall_non_insulation_materials = [
part
for part in materials
if part["type"]
in ["iwi_wall_demolition", "iwi_vapour_barrier", "iwi_redecoration"]
]
self.external_wall_insulation_materials = [
part for part in materials if part["type"] == "external_wall_insulation"
]
self.external_wall_non_insulation_materials = [
part
for part in materials
if part["type"] in ["ewi_wall_demolition", "ewi_wall_preparation", "ewi_wall_redecoration"]
]
def ewi_valid(self):
"""
This method check available data, to determine if a property is suitable for external wall insulation
@ -185,7 +174,6 @@ class WallRecommendations(Definitions):
ewi_recommendations = self._find_insulation(
u_value=u_value,
insulation_materials=pd.DataFrame(self.external_wall_insulation_materials),
non_insulation_materials=self.external_wall_non_insulation_materials,
phase=phase
)
@ -450,7 +438,7 @@ class WallRecommendations(Definitions):
return simulation_config
def _find_insulation(self, u_value, insulation_materials, non_insulation_materials, phase):
def _find_insulation(self, u_value, insulation_materials, phase):
lowest_selected_u_value = None
recommendations = []
@ -495,6 +483,15 @@ class WallRecommendations(Definitions):
lowest_selected_u_value, new_u_value
)
cost_result = self.costs.solid_wall_insulation(
wall_area=self.property.insulation_wall_area,
material=material.to_dict(),
)
already_installed = material["type"] in self.property.already_installed
if already_installed:
cost_result = override_costs(cost_result)
if material["type"] == "internal_wall_insulation":
if iwi_non_invasive_recommendations.get("cost") is not None:
@ -505,18 +502,6 @@ class WallRecommendations(Definitions):
sap_points = iwi_non_invasive_recommendations.get("sap_points", None)
survey = iwi_non_invasive_recommendations.get("survey", False)
cost_result = self.costs.internal_wall_insulation(
wall_area=self.property.insulation_wall_area,
material=material.to_dict(),
non_insulation_materials=non_insulation_materials,
)
already_installed = (
"internal_wall_insulation"
in self.property.already_installed
)
if already_installed:
cost_result = override_costs(cost_result)
new_description = self.get_internal_external_wall_description(
self.INTERNALLY_INSULATED_WALL_DESCRIPTIONS, new_u_value
)
@ -526,18 +511,6 @@ class WallRecommendations(Definitions):
sap_points = ewi_non_invasive_recommendations.get("sap_points", None)
survey = ewi_non_invasive_recommendations.get("survey", False)
cost_result = self.costs.external_wall_insulation(
wall_area=self.property.insulation_wall_area,
material=material.to_dict(),
non_insulation_materials=non_insulation_materials,
)
already_installed = (
"external_wall_insulation"
in self.property.already_installed
)
if already_installed:
cost_result = override_costs(cost_result)
new_description = self.get_internal_external_wall_description(
self.EXTERNALLY_INSULATED_WALL_DESCRIPTIONS, new_u_value
)
@ -608,7 +581,6 @@ class WallRecommendations(Definitions):
insulation_materials=pd.DataFrame(
self.external_wall_insulation_materials
),
non_insulation_materials=self.external_wall_non_insulation_materials,
phase=phase,
)
@ -617,7 +589,6 @@ class WallRecommendations(Definitions):
iwi_recommendations = self._find_insulation(
u_value=u_value,
insulation_materials=pd.DataFrame(self.internal_wall_insulation_materials),
non_insulation_materials=self.internal_wall_non_insulation_materials,
phase=phase,
)

View file

@ -3,6 +3,7 @@ from typing import List
import numpy as np
from backend.Property import Property
from backend.app.plan.schemas import MEASURE_MAP
from etl.epc_clean.epc_attributes.WindowAttributes import WindowAttributes
from recommendations.Costs import Costs
from recommendations.recommendation_utils import override_costs, check_simulation_difference
@ -32,7 +33,7 @@ class WindowsRecommendations:
raise ValueError("There should only be one window glazing material")
self.glazing_material = self.glazing_material[0]
def recommend(self, phase=0):
def recommend(self, measures=None, phase=0):
"""
This method will recommend the best possible glazing options for a property.
@ -41,14 +42,26 @@ class WindowsRecommendations:
:return:
"""
measures = MEASURE_MAP["windows"] if measures is None else measures
# If we have no windows recs, leave
if not any(x in measures for x in MEASURE_MAP["windows"]):
return
# If the property is in a conservation area or is a listed building, it becomes more difficult to install
# double glazing. Therefore, we don't recommend it. It is still possible but is not practical as it
# requires planning permission and might require a more expensive window type, such as timber.
number_of_windows = self.property.number_of_windows
is_secondary_glazing = self.property.restricted_measures or (
self.property.windows["glazing_type"] == "secondary"
)
if "double_glazing" in measures and "secondary_glazing" not in measures:
is_secondary_glazing = False
elif "secondary_glazing" in measures and "double_glazing" not in measures:
is_secondary_glazing = True
else:
is_secondary_glazing = self.property.restricted_measures or (
self.property.windows["glazing_type"] == "secondary"
)
windows_area = self.property.windows_area
if not number_of_windows:
@ -60,7 +73,8 @@ class WindowsRecommendations:
return
if windows_area is not None:
raise Exception("We have windows area, we should use this data for our recommendations!!!")
# TODO - we don't have a price for this so we can't recommend it
print("We have windows area, we should use this data for our recommendations!!!")
# We scale the number of windows based on the proportion of existing glazing
if self.property.data["multi-glaze-proportion"] != "":

View file

@ -800,3 +800,44 @@ def override_costs(costs):
costs[k] = 0
return costs
def combine_recommendation_configs(recommendation_config1, recommendation_config2):
"""
Given two simulation configs, this function will combine them into one
:param recommendation_config1:
:param recommendation_config2:
:return:
"""
# Efficiency values - keys which contain _energy_eff_ending
eff_1 = {
k: v for k, v in recommendation_config1.items() if ("_energy_eff_ending" in k) or ("-energy-eff" in k)
}
eff_2 = {
k: v for k, v in recommendation_config2.items() if ("_energy_eff_ending" in k) or ("-energy-eff" in k)
}
# We combine the simulation configs
combined = {
**recommendation_config1,
**recommendation_config2
}
# Find overlapping keys
overlapping_keys = set(eff_1.keys()).intersection(set(eff_2.keys()))
if overlapping_keys:
# We make sure we take the best value - map efficiency values to numbers
numerical_embedding = {
"Very poor": 1,
"Poor": 2,
"Average": 3,
"Good": 4,
"Very good": 5,
}
for key in overlapping_keys:
if numerical_embedding[eff_1[key]] >= numerical_embedding[eff_2[key]]:
combined[key] = eff_1[key]
else:
combined[key] = eff_2[key]
return combined

File diff suppressed because it is too large Load diff

View file

@ -54,16 +54,6 @@ class TestHeatingRecommendations:
:return:
"""
if test_case["epc"]["uprn"] == 100090311351:
raise Exception(
"This test has electric storage heaters with automatic charge control - this case should be researched"
"and checked that a high heat retention storage recommendation is actually sensible. If it's not, "
"we should adjust accordingly or perhaps have just a control recommendation"
)
if test_case["epc"]["uprn"] == 100021560521:
raise Exception("Finish this test - could do so while on the train")
epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []}
epc_record = EPCRecord(