From 0f3e325f5b65623e26dbd4f8b151199ad9a8ae60 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Nov 2023 16:35:56 +0000 Subject: [PATCH 01/23] Generalising api class for prediction in router: --- backend/app/plan/router.py | 25 +++----- .../{sap_change_model => }/__init__.py | 0 .../ml_models/{sap_change_model => }/api.py | 63 +++++++++++++++++-- 3 files changed, 66 insertions(+), 22 deletions(-) rename backend/ml_models/{sap_change_model => }/__init__.py (100%) rename backend/ml_models/{sap_change_model => }/api.py (52%) diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index a20369cc..025b252e 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -25,7 +25,7 @@ from backend.app.plan.utils import ( ) from backend.app.utils import epc_to_sap_lower_bound, read_csv_from_s3, read_parquet_from_s3 -from backend.ml_models.sap_change_model.api import SAPChangeModelAPI +from backend.ml_models.api import ModelApi from backend.Property import Property from etl.epc.DataProcessor import DataProcessor from etl.epc.settings import COLUMNS_TO_MERGE_ON @@ -234,30 +234,19 @@ async def trigger_plan(body: PlanTriggerRequest): recommendations_scoring_data = DataProcessor.clean_efficiency_variables(recommendations_scoring_data) - sap_change_model_api = SAPChangeModelAPI(portfolio_id=body.portfolio_id, timestamp=created_at) - file_location = sap_change_model_api.upload_scoring_data( - df=recommendations_scoring_data, bucket=get_settings().DATA_BUCKET + model_api = ModelApi(portfolio_id=body.portfolio_id, timestamp=created_at) + all_predictions = model_api.predict_all( + df=recommendations_scoring_data, + bucket=get_settings().DATA_BUCKET, + predictions_bucket=get_settings().PREDICTIONS_BUCKET ) - response = sap_change_model_api.predict( - file_location="s3://{DATA_BUCKET}/".format(DATA_BUCKET=get_settings().DATA_BUCKET) + file_location, - ) - - # Retrieve the predictions - predictions = pd.DataFrame( - read_parquet_from_s3( - bucket_name=get_settings().PREDICTIONS_BUCKET, - file_key=response["storage_filepath"].split(get_settings().PREDICTIONS_BUCKET + "/")[1] - ) - ) - - predictions["predictions"] = predictions["predictions"].astype(float).round(1) - predictions[['property_id', 'recommendation_id']] = predictions['id'].str.split('+', expand=True) # Insert the predictions into the recommendations and run the optimiser logger.info("Optimising recommendations") for property_id in recommendations.keys(): property = [p for p in input_properties if p.id == property_id][0] + predictions = all_predictions["sap_change_predictions"] property_predictions = predictions[predictions["property_id"] == str(property_id)] for recommendations_by_type in recommendations[property_id]: diff --git a/backend/ml_models/sap_change_model/__init__.py b/backend/ml_models/__init__.py similarity index 100% rename from backend/ml_models/sap_change_model/__init__.py rename to backend/ml_models/__init__.py diff --git a/backend/ml_models/sap_change_model/api.py b/backend/ml_models/api.py similarity index 52% rename from backend/ml_models/sap_change_model/api.py rename to backend/ml_models/api.py index 2eb7d706..6c92df2a 100644 --- a/backend/ml_models/sap_change_model/api.py +++ b/backend/ml_models/api.py @@ -3,11 +3,24 @@ import requests from requests.exceptions import RequestException from utils.logger import setup_logger from utils.s3 import save_dataframe_to_s3_parquet +from backend.app.utils import read_parquet_from_s3 logger = setup_logger() -class SAPChangeModelAPI: +class ModelApi: + MODEL_PREFIXES = [ + "sap_change_predictions", + "heat_demand_predictions", + "carbon_change_predictions" + ] + + MODEL_URLS = { + "sap_change_predictions": "sapmodel", + "heat_demand_predictions": "heatmodel", + "carbon_change_predictions": "carbonmodel" + } + def __init__( self, portfolio_id, @@ -15,6 +28,9 @@ class SAPChangeModelAPI: base_url="https://api.dev.hestia.homes", ): """ + This class handles the communication with the Model APIs. These models include SAP change, heat demain change + and carbon change + property_id (int, optional): : :param portfolio_id: The portfolio ID to be passed in the request payload. Defaults to 4. :param timestamp: The creation timestamp to be passed in the request payload. Defaults to None. @@ -24,7 +40,7 @@ class SAPChangeModelAPI: self.portfolio_id = portfolio_id self.timestamp = timestamp - def upload_scoring_data(self, df: pd.DataFrame, bucket: str) -> str: + def upload_scoring_data(self, df: pd.DataFrame, bucket: str, model_prefix: str) -> str: """ The sap model api needs a scoring data that is sitting in s3 to use as a dataset to score on This method allows the user to upload a table as a parquet file. This method will return the file @@ -32,9 +48,13 @@ class SAPChangeModelAPI: :param df: Pandas dataframe with scoring data to be uploaded to s3 :param bucket: Name of the bucket in s3 to upload to + :param model_prefix: The model prefix to be used in the file location :return: """ + if model_prefix not in self.MODEL_PREFIXES: + raise ValueError(f"Model prefix specified is not in {self.MODEL_PREFIXES}") + # Store parquet file in s3 for scoring file_location = "sap_change_predictions/{portfolio_id}/{timestamp}.parquet".format( portfolio_id=self.portfolio_id, @@ -50,17 +70,18 @@ class SAPChangeModelAPI: return file_location - def predict(self, file_location): + def predict(self, file_location, model_prefix: str): """Makes a POST request to the SAP Change Model API with the provided parameters. Args: file_location (str): The file location to be passed in the request payload. + model_prefix (str): The model prefix to be used in the request URL. Returns: dict: The API response as a dictionary if the request was successful, None otherwise. """ logger.info("Making request to sap change api") - url = f"{self.base_url}/sapmodel/predict" + url = f"{self.base_url}/{self.MODEL_URLS[model_prefix]}/predict" payload = { "file_location": file_location, "property_id": "", # This should get removed @@ -81,3 +102,37 @@ class SAPChangeModelAPI: # In case of an error, you might want to return None or raise the exception # depending on how you want to handle errors in your application return None + + def predict_all(self, df, bucket, predictions_bucket) -> dict: + + """ + For each model prefix, this method will upload the scoring data to s3 and then make a request to the + model api to generate predictions. The predictions will be stored in the predictions bucket. + This method will then fetch the stored predictions and format them, returning all of the predictions as + a dictionary of panaas dataframes + :param df: Pandas dataframe with scoring data to be uploaded to s3 + :param bucket: Name of the bucket in s3 to upload to + :param predictions_bucket: Name of the bucket in s3 to store predictions + :return: + """ + + predictions = {} + for model_prefix in self.MODEL_PREFIXES: + logger.info(f"Scoring for model prefix: {model_prefix}") + file_location = self.upload_scoring_data(df, bucket, model_prefix) + response = self.predict(file_location, model_prefix) + + # Retrieve the predictions + predictions_df = pd.DataFrame( + read_parquet_from_s3( + bucket_name=predictions_bucket, + file_key=response["storage_filepath"].split(predictions_bucket + "/")[1] + ) + ) + + predictions_df["predictions"] = predictions_df["predictions"].astype(float).round(1) + predictions_df[['property_id', 'recommendation_id']] = predictions_df['id'].str.split('+', expand=True) + + predictions[model_prefix] = predictions_df + + return predictions From 3166ce5521db7de38fced262188ff00e82869bc9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Nov 2023 16:37:51 +0000 Subject: [PATCH 02/23] fixed model api url --- backend/ml_models/api.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/ml_models/api.py b/backend/ml_models/api.py index 6c92df2a..02d6ecac 100644 --- a/backend/ml_models/api.py +++ b/backend/ml_models/api.py @@ -56,10 +56,7 @@ class ModelApi: raise ValueError(f"Model prefix specified is not in {self.MODEL_PREFIXES}") # Store parquet file in s3 for scoring - file_location = "sap_change_predictions/{portfolio_id}/{timestamp}.parquet".format( - portfolio_id=self.portfolio_id, - timestamp=self.timestamp - ) + file_location = f"{model_prefix}/{self.portfolio_id}/{self.timestamp}.parquet" logger.info("Storing scoring data to s3") save_dataframe_to_s3_parquet( From 5d5422637865dc7ebdced280cd45a8b20cec0d6e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Nov 2023 17:10:47 +0000 Subject: [PATCH 03/23] Working on adding new Recommendations class for updating recommendations with impact --- .../db/functions/recommendations_functions.py | 2 + backend/app/plan/router.py | 26 ++++------ recommendations/Recommendations.py | 52 +++++++++++++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 recommendations/Recommendations.py diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index 34c4ef96..ec22fad2 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -80,6 +80,8 @@ def upload_recommendations(session: Session, recommendations_to_upload, property "starting_u_value": rec.get("starting_u_value"), "new_u_value": rec.get("new_u_value"), "sap_points": rec["sap_points"], + "heat_demand": rec["heat_demand"], + "co2_equivalent_savings": rec["co2_equivalent_savings"], "total_work_hours": rec["labour_hours"], } for rec in recommendations_to_upload diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 025b252e..2e262082 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -37,6 +37,7 @@ from recommendations.optimiser.CostOptimiser import CostOptimiser from recommendations.optimiser.GainOptimiser import GainOptimiser from recommendations.optimiser.optimiser_functions import prepare_input_measures from recommendations.WallRecommendations import WallRecommendations +from recommendations.Recommendations import Recommendations from utils.logger import setup_logger from utils.s3 import read_dataframe_from_s3_parquet @@ -245,28 +246,21 @@ async def trigger_plan(body: PlanTriggerRequest): logger.info("Optimising recommendations") for property_id in recommendations.keys(): - property = [p for p in input_properties if p.id == property_id][0] - predictions = all_predictions["sap_change_predictions"] - property_predictions = predictions[predictions["property_id"] == str(property_id)] + property_instance = [p for p in input_properties if p.id == property_id][0] - for recommendations_by_type in recommendations[property_id]: - for rec in recommendations_by_type: - new_sap = property_predictions[property_predictions["recommendation_id"] == str( - rec["recommendation_id"] - )]["predictions"].values[0] + recommendations_with_impact = Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions, + recommendations=recommendations + ) - rec["sap_points"] = new_sap - float(property.data["current-energy-efficiency"]) - - if rec["sap_points"] is None: - raise ValueError("Sap points missing") - - input_measures = prepare_input_measures(recommendations[property_id], body.goal) + input_measures = prepare_input_measures(recommendations_with_impact, body.goal) if body.budget: optimiser = GainOptimiser(input_measures, max_cost=body.budget) else: # The minimum gain is the minimum number of SAP points required to get to the target SAP band - current_sap_points = int(property.data["current-energy-efficiency"]) + current_sap_points = int(property_instance.data["current-energy-efficiency"]) target_sap_points = epc_to_sap_lower_bound(body.goal_value) # If the gain is negative, the optimiser will return an empty solution @@ -286,7 +280,7 @@ async def trigger_plan(body: PlanTriggerRequest): {**rec, "default": True if rec["recommendation_id"] in selected_recommendations else False} for rec in recommendations_by_type ] - for recommendations_by_type in recommendations[property_id] + for recommendations_by_type in recommendations_with_impact ] # We'll also unlist the recommendations so they're a bit easier to handle from here onwards diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py new file mode 100644 index 00000000..5e4d1cc3 --- /dev/null +++ b/recommendations/Recommendations.py @@ -0,0 +1,52 @@ +class Recommendations: + """ + High level recommendations class, which sits above the measure specific recommendation classes + """ + + @classmethod + def calculate_recommendation_impact(cls, property_instance, all_predictions, recommendations): + + """ + Given predictions from the model apis, with method will update the recommendations with the predicted + impact of the recommendation on the property + + :param property_instance: Instance of the Property class, for the home associated to property_id + :param all_predictions: dictionary of predictions from the model apis + :param recommendations: dictionary of recommendations for the property + :return: + """ + + property_sap_predictions = all_predictions["sap_change_predictions"][ + all_predictions["sap_change_predictions"]["property_id"] == str(property_instance.id) + ] + property_heat_predictions = all_predictions["heat_demand_predictions"][ + all_predictions["heat_demand_predictions"]["property_id"] == str(property_instance.id) + ] + property_carbon_predictions = all_predictions["carbon_change_predictions"][ + all_predictions["carbon_change_predictions"]["property_id"] == str(property_instance.id) + ] + + property_recommendations = recommendations[property_instance.id].copy() + + for recommendations_by_type in property_recommendations: + for rec in recommendations_by_type: + new_sap = property_sap_predictions[property_sap_predictions["recommendation_id"] == str( + rec["recommendation_id"] + )]["predictions"].values[0] + + new_heat_demand = property_heat_predictions[property_heat_predictions["recommendation_id"] == str( + rec["recommendation_id"] + )]["predictions"].values[0] + + new_carbon = property_carbon_predictions[property_carbon_predictions["recommendation_id"] == str( + rec["recommendation_id"] + )]["predictions"].values[0] + + rec["sap_points"] = new_sap - float(property_instance.data["current-energy-efficiency"]) + rec["co2_equivalent_savings"] = float(property_instance.data["co2-emissions-current"]) - new_carbon + rec["heat_demand"] = float(property_instance.data["co2-emissions-current"]) - new_heat_demand + + if rec["sap_points"] is None: + raise ValueError("Sap points missing") + + return property_recommendations From f4e0528aa0602099ba3af9d72c0f041e65143055 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Nov 2023 17:12:20 +0000 Subject: [PATCH 04/23] Addde ValueError on missing impact measure --- recommendations/Recommendations.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 5e4d1cc3..aa66159b 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -46,7 +46,8 @@ class Recommendations: rec["co2_equivalent_savings"] = float(property_instance.data["co2-emissions-current"]) - new_carbon rec["heat_demand"] = float(property_instance.data["co2-emissions-current"]) - new_heat_demand - if rec["sap_points"] is None: - raise ValueError("Sap points missing") + if (rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or ( + rec["heat_demand"] is None): + raise ValueError("sap points, co2 or heat demand is missing") return property_recommendations From 1da9433ee2b998550659712b0c542a88f0bf769c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Nov 2023 11:41:34 +0000 Subject: [PATCH 05/23] Adding in mvp valuation and energy cost savings for keyzy demo --- backend/app/config.py | 4 +- .../app/db/functions/portfolio_functions.py | 11 +- .../db/functions/recommendations_functions.py | 1 + backend/app/plan/router.py | 60 ++--------- backend/app/plan/utils.py | 31 ++---- backend/ml_models/AnnualBillSavings.py | 28 +++++ backend/ml_models/Valuation.py | 43 ++++++++ backend/ml_models/api.py | 12 ++- recommendations/Costs.py | 2 +- recommendations/Recommendations.py | 102 +++++++++++++++++- 10 files changed, 209 insertions(+), 85 deletions(-) create mode 100644 backend/ml_models/AnnualBillSavings.py create mode 100644 backend/ml_models/Valuation.py diff --git a/backend/app/config.py b/backend/app/config.py index 40aef822..22621972 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,9 @@ class Settings(BaseSettings): SECRET_KEY: str ENVIRONMENT: str DATA_BUCKET: str - PREDICTIONS_BUCKET: str + SAP_PREDICTIONS_BUCKET: str + CARBON_PREDICTIONS_BUCKET: str + HEAT_PREDICTIONS_BUCKET: str PLAN_TRIGGER_BUCKET: str EPC_AUTH_TOKEN: str DB_HOST: str diff --git a/backend/app/db/functions/portfolio_functions.py b/backend/app/db/functions/portfolio_functions.py index 08e15a32..fc9add42 100644 --- a/backend/app/db/functions/portfolio_functions.py +++ b/backend/app/db/functions/portfolio_functions.py @@ -9,9 +9,9 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int): session.query( func.sum(Recommendation.estimated_cost).label("cost"), func.sum(Recommendation.total_work_hours).label("total_work_hours"), - # For future usage we will aggregate multiple fields in this step - # func.sum(Recommendation.heat_demand).label("total_heat_demand"), - # func.sum(Recommendation.energy_savings).label("total_energy_savings") + func.sum(Recommendation.heat_demand).label("total_heat_demand"), + func.sum(Recommendation.energy_savings).label("total_energy_savings"), + func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings") ) .join(PlanRecommendations, PlanRecommendations.recommendation_id == Recommendation.id) .join(Plan, Plan.id == PlanRecommendations.plan_id) @@ -22,8 +22,9 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int): aggregates_dict = { "cost": aggregates.cost or 0, "total_work_hours": aggregates.total_work_hours or 0, - # "total_heat_demand": aggregates.total_heat_demand or 0, - # "total_energy_savings": aggregates.total_energy_savings or 0 + "total_heat_demand": aggregates.total_heat_demand or 0, + "total_energy_savings": aggregates.total_energy_savings or 0, + "energy_cost_savings": aggregates.energy_cost_savings or 0, } # Get the portfolio and update the fields diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index ec22fad2..772e1184 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -83,6 +83,7 @@ def upload_recommendations(session: Session, recommendations_to_upload, property "heat_demand": rec["heat_demand"], "co2_equivalent_savings": rec["co2_equivalent_savings"], "total_work_hours": rec["labour_hours"], + "energy_cost_savings": rec["energy_cost_savings"] } for rec in recommendations_to_upload ] diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 2e262082..408b4101 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -20,23 +20,16 @@ from backend.app.db.functions.recommendations_functions import ( from backend.app.db.models.portfolio import rating_lookup from backend.app.dependencies import validate_token from backend.app.plan.schemas import PlanTriggerRequest -from backend.app.plan.utils import ( - create_recommendation_scoring_data, get_cleaned, insert_temp_recommendation_id -) +from backend.app.plan.utils import create_recommendation_scoring_data, get_cleaned from backend.app.utils import epc_to_sap_lower_bound, read_csv_from_s3, read_parquet_from_s3 from backend.ml_models.api import ModelApi from backend.Property import Property from etl.epc.DataProcessor import DataProcessor from etl.epc.settings import COLUMNS_TO_MERGE_ON -from recommendations.FloorRecommendations import FloorRecommendations -from recommendations.RoofRecommendations import RoofRecommendations -from recommendations.VentilationRecommendations import VentilationRecommendations -from recommendations.FireplaceRecommendations import FireplaceRecommendations from recommendations.optimiser.CostOptimiser import CostOptimiser from recommendations.optimiser.GainOptimiser import GainOptimiser from recommendations.optimiser.optimiser_functions import prepare_input_measures -from recommendations.WallRecommendations import WallRecommendations from recommendations.Recommendations import Recommendations from utils.logger import setup_logger from utils.s3 import read_dataframe_from_s3_parquet @@ -130,49 +123,8 @@ async def trigger_plan(body: PlanTriggerRequest): # Property recommendations p.get_components(cleaned) - property_recommendations = [] - - # Floor recommendations - floor_recommender = FloorRecommendations(property_instance=p, materials=materials) - floor_recommender.recommend() - - if floor_recommender.recommendations: - property_recommendations.append(floor_recommender.recommendations) - - # Wall recommendations - - wall_recomender = WallRecommendations(property_instance=p, materials=materials) - wall_recomender.recommend() - - if wall_recomender.recommendations: - property_recommendations.append(wall_recomender.recommendations) - - # Roof recommendations - roof_recommender = RoofRecommendations(property_instance=p, materials=materials) - roof_recommender.recommend() - - if roof_recommender.recommendations: - property_recommendations.append(roof_recommender.recommendations) - - # Ventilation recommendations - ventilation_recomender = VentilationRecommendations( - property_instance=p, - materials=[part for part in materials if part["type"] == "mechanical_ventilation"] - ) - ventilation_recomender.recommend() - - if ventilation_recomender.recommendation: - property_recommendations.append(ventilation_recomender.recommendation) - - # Fireplace sealing recommendations - fireplace_recommender = FireplaceRecommendations(property_instance=p) - fireplace_recommender.recommend() - - if fireplace_recommender.recommendation: - property_recommendations.append(fireplace_recommender.recommendation) - - # We insert temporary ids into the recommendations which is important for the optimiser later - property_recommendations = insert_temp_recommendation_id(property_recommendations) + recommender = Recommendations(property_instance=p, materials=materials) + property_recommendations = recommender.recommend() if not property_recommendations: continue @@ -239,7 +191,11 @@ async def trigger_plan(body: PlanTriggerRequest): all_predictions = model_api.predict_all( df=recommendations_scoring_data, bucket=get_settings().DATA_BUCKET, - predictions_bucket=get_settings().PREDICTIONS_BUCKET + prediction_buckets={ + "sap_change_predictions": get_settings().SAP_PREDICTIONS_BUCKET, + "heat_demand_predictions": get_settings().HEAT_PREDICTIONS_BUCKET, + "carbon_change_predictions": get_settings().CARBON_PREDICTIONS_BUCKET + } ) # Insert the predictions into the recommendations and run the optimiser diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py index 20b5db5b..5bb8e42a 100644 --- a/backend/app/plan/utils.py +++ b/backend/app/plan/utils.py @@ -8,25 +8,6 @@ from backend.app.config import get_settings import msgpack -def insert_temp_recommendation_id(property_recommendations): - """ - Creates a temporary recommendation id which is needed for - filtering recommendations between default and no, after the optimiser has been - run - :param property_recommendations: nested list of recommendations, grouped by data_types - :return: Updated recommendations_to_upload, where where recommendation has a "recommendation_id" - integer inserted - """ - idx = 0 - - for recs in property_recommendations: - for rec in recs: - rec["recommendation_id"] = idx - idx += 1 - - return property_recommendations - - def get_cleaned(): """ This function will retrieve the cleaned dataset from s3 which has the cleaned @@ -154,7 +135,17 @@ def create_recommendation_scoring_data( if len(parts) != 1: raise ValueError("More than one part for roof insulation - investiage me") - scoring_dict["roof_insulation_thickness_ENDING"] = str(int(parts[0]["depth"])) + # This is based on the values we have in the training data + valid_numeric_values = [ + 12, 25, 50, 75, 100, 150, 200, 250, 270, 300, 350, 400 + ] + + proposed_depth = int(parts[0]["depth"]) + if proposed_depth not in valid_numeric_values: + # Take the nearest value for scoring + proposed_depth = min(valid_numeric_values, key=lambda x: abs(x - proposed_depth)) + + scoring_dict["roof_insulation_thickness_ENDING"] = str(proposed_depth) scoring_dict["ROOF_ENERGY_EFF_ENDING"] = "Very Good" else: # Fill missing roof u-values - this fill is not based on recommended upgrades diff --git a/backend/ml_models/AnnualBillSavings.py b/backend/ml_models/AnnualBillSavings.py new file mode 100644 index 00000000..c057f4aa --- /dev/null +++ b/backend/ml_models/AnnualBillSavings.py @@ -0,0 +1,28 @@ +class AnnualBillSavings: + """ + This is a simple class which will estimate the annual bill savings, based on the kwh savings. + This class uses data from Ofgem, including their price caps, to provide us with an estimate for + 1KWH of energy. + """ + + # These gas an electricity consumption figures are based off of figures presented by Ofgem + # https://www.ofgem.gov.uk/information-consumers/energy-advice-households/average-gas-and-electricity-use-explained + AVERAGE_ELECTRICITY_CONSUMPTION = 2700 + AVERAGE_GAS_CONSUMPTION = 11500 + + # Latest price cap figures from Ofgem are for January 2024 + # https://www.ofgem.gov.uk/publications/changes-energy-price-cap-1-january-2024 + ELECTRICITY_PRICE_CAP = 0.29 + GAS_PRICE_CAP = 0.07 + + # This is a weighted mean of the price caps, using the consumption figures above as weights + PRICE_FACTOR = 0.11183098591549295 + + @classmethod + def estimate(cls, kwh: float): + """ + Estimate the annual bill savings based on the kwh savings + :param kwh: The kwh savings + :return: An estimate for annual bill savings + """ + return cls.PRICE_FACTOR * kwh diff --git a/backend/ml_models/Valuation.py b/backend/ml_models/Valuation.py new file mode 100644 index 00000000..1b2f8cf4 --- /dev/null +++ b/backend/ml_models/Valuation.py @@ -0,0 +1,43 @@ +class PropertyValuation: + """ + This is a placeholder class for the property valuation model + """ + + UPRN_VALUE_LOOKUP = { + 15038202: 202000, + 37024763: 213000, + } + + VALUE_INCREASE_MAPPING = [ + { + "starting_epc": "D", + "ending_epc": "C", + "increase_percentage": 0.057, + }, + { + "starting_epc": "D", + "ending_epc": "B", + "increase_percentage": 0.057, + }, + ] + + @classmethod + def estimate(cls, property_instance, target_epc): + current_value = cls.UPRN_VALUE_LOOKUP.get(property_instance.uprn) + + if not current_value: + raise ValueError("Have not implemented valuation for this property") + + valuation_increases = [ + v for v in cls.VALUE_INCREASE_MAPPING if + v["starting_epc"] == property_instance.epc_band and v["ending_epc"] == target_epc + ] + + if len(valuation_increases) != 1: + raise ValueError("Valuation increase mapping not found") + + new_valuation = (1 + valuation_increases[0]["increase_percentage"]) * current_value + + increase = round(new_valuation - current_value, 2) + + return increase diff --git a/backend/ml_models/api.py b/backend/ml_models/api.py index 02d6ecac..e6947906 100644 --- a/backend/ml_models/api.py +++ b/backend/ml_models/api.py @@ -77,7 +77,7 @@ class ModelApi: Returns: dict: The API response as a dictionary if the request was successful, None otherwise. """ - logger.info("Making request to sap change api") + logger.info(f"Making request to {model_prefix} change api") url = f"{self.base_url}/{self.MODEL_URLS[model_prefix]}/predict" payload = { "file_location": file_location, @@ -100,7 +100,7 @@ class ModelApi: # depending on how you want to handle errors in your application return None - def predict_all(self, df, bucket, predictions_bucket) -> dict: + def predict_all(self, df, bucket, prediction_buckets) -> dict: """ For each model prefix, this method will upload the scoring data to s3 and then make a request to the @@ -109,7 +109,7 @@ class ModelApi: a dictionary of panaas dataframes :param df: Pandas dataframe with scoring data to be uploaded to s3 :param bucket: Name of the bucket in s3 to upload to - :param predictions_bucket: Name of the bucket in s3 to store predictions + :param prediction_buckets: Dictionary containing the prediction buckets for each model prefix :return: """ @@ -117,7 +117,11 @@ class ModelApi: for model_prefix in self.MODEL_PREFIXES: logger.info(f"Scoring for model prefix: {model_prefix}") file_location = self.upload_scoring_data(df, bucket, model_prefix) - response = self.predict(file_location, model_prefix) + response = self.predict( + "s3://{DATA_BUCKET}/".format(DATA_BUCKET=bucket) + file_location, model_prefix + ) + + predictions_bucket = prediction_buckets[model_prefix] # Retrieve the predictions predictions_df = pd.DataFrame( diff --git a/recommendations/Costs.py b/recommendations/Costs.py index a96e1215..6fc62db2 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -58,7 +58,7 @@ class Costs: EWI_SCAFFOLDING_PRELIMINARIES = 0.15 VAT_RATE = 0.2 - PROFIT_MARGIN = 0.15 + PROFIT_MARGIN = 0.2 def __init__(self, property_instance): """ diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index aa66159b..17dcc5df 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -1,8 +1,98 @@ +from backend.Property import Property +from typing import List +from recommendations.FloorRecommendations import FloorRecommendations +from recommendations.WallRecommendations import WallRecommendations +from recommendations.RoofRecommendations import RoofRecommendations +from recommendations.VentilationRecommendations import VentilationRecommendations +from recommendations.FireplaceRecommendations import FireplaceRecommendations +from backend.ml_models.AnnualBillSavings import AnnualBillSavings + + class Recommendations: """ High level recommendations class, which sits above the measure specific recommendation classes """ + def __init__( + self, + property_instance: Property, + materials: List + ): + """ + :param property_instance: Instance of the Property class, for the home associated to property_id + :param materials: List of materials to be used in the recommendations + """ + + self.property_instance = property_instance + self.materials = materials + + self.floor_recommender = FloorRecommendations(property_instance=property_instance, materials=materials) + self.wall_recomender = WallRecommendations(property_instance=property_instance, materials=materials) + self.roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials) + self.ventilation_recomender = VentilationRecommendations( + property_instance=property_instance, + materials=[part for part in materials if part["type"] == "mechanical_ventilation"] + ) + self.fireplace_recommender = FireplaceRecommendations(property_instance=property_instance) + + def recommend(self): + + """ + This method runs the recommendations for the individual measures and then appends them to a list for output + :return: + """ + + property_recommendations = [] + + # Floor recommendations + self.floor_recommender.recommend() + if self.floor_recommender.recommendations: + property_recommendations.append(self.floor_recommender.recommendations) + + # Wall recommendations + self.wall_recomender.recommend() + if self.wall_recomender.recommendations: + property_recommendations.append(self.wall_recomender.recommendations) + + # Roof recommendations + self.roof_recommender.recommend() + if self.roof_recommender.recommendations: + property_recommendations.append(self.roof_recommender.recommendations) + + # Ventilation recommendations + self.ventilation_recomender.recommend() + if self.ventilation_recomender.recommendation: + property_recommendations.append(self.ventilation_recomender.recommendation) + + # Fireplace sealing recommendations + self.fireplace_recommender.recommend() + if self.fireplace_recommender.recommendation: + property_recommendations.append(self.fireplace_recommender.recommendation) + + # We insert temporary ids into the recommendations which is important for the optimiser later + property_recommendations = self.insert_temp_recommendation_id(property_recommendations) + + return property_recommendations + + @staticmethod + def insert_temp_recommendation_id(property_recommendations): + """ + Creates a temporary recommendation id which is needed for + filtering recommendations between default and no, after the optimiser has been + run + :param property_recommendations: nested list of recommendations, grouped by data_types + :return: Updated recommendations_to_upload, where where recommendation has a "recommendation_id" + integer inserted + """ + idx = 0 + + for recs in property_recommendations: + for rec in recs: + rec["recommendation_id"] = idx + idx += 1 + + return property_recommendations + @classmethod def calculate_recommendation_impact(cls, property_instance, all_predictions, recommendations): @@ -30,6 +120,7 @@ class Recommendations: for recommendations_by_type in property_recommendations: for rec in recommendations_by_type: + new_sap = property_sap_predictions[property_sap_predictions["recommendation_id"] == str( rec["recommendation_id"] )]["predictions"].values[0] @@ -44,10 +135,17 @@ class Recommendations: rec["sap_points"] = new_sap - float(property_instance.data["current-energy-efficiency"]) rec["co2_equivalent_savings"] = float(property_instance.data["co2-emissions-current"]) - new_carbon - rec["heat_demand"] = float(property_instance.data["co2-emissions-current"]) - new_heat_demand + + # Energy consumption current is per meter squared, so we need to multiply by the floor area to get + # an absolute figure for the home + rec["heat_demand"] = ( + (float(property_instance.data["energy-consumption-current"]) - new_heat_demand + ) * property_instance.floor_area) + + rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["heat_demand"]) if (rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or ( - rec["heat_demand"] is None): + rec["heat_demand"] is None) or (rec["energy_cost_savings"] is None): raise ValueError("sap points, co2 or heat demand is missing") return property_recommendations From 7ebfb3b99c5096e5b47a16c771e7bbf4cfb1cbc4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Nov 2023 12:39:11 +0000 Subject: [PATCH 06/23] Added labour days to cavity walls costs --- .../app/db/functions/portfolio_functions.py | 17 +++++++++------ .../db/functions/recommendations_functions.py | 3 ++- backend/app/db/models/portfolio.py | 1 + backend/app/db/models/recommendations.py | 1 + backend/app/plan/router.py | 21 +++++++++++++++++-- backend/ml_models/Valuation.py | 6 +++--- recommendations/Costs.py | 12 +++++++++-- recommendations/FireplaceRecommendations.py | 3 ++- recommendations/VentilationRecommendations.py | 3 ++- 9 files changed, 51 insertions(+), 16 deletions(-) diff --git a/backend/app/db/functions/portfolio_functions.py b/backend/app/db/functions/portfolio_functions.py index fc9add42..b3936eb9 100644 --- a/backend/app/db/functions/portfolio_functions.py +++ b/backend/app/db/functions/portfolio_functions.py @@ -3,15 +3,16 @@ from backend.app.db.models.recommendations import Plan, PlanRecommendations, Rec from backend.app.db.models.portfolio import Portfolio -def aggregate_portfolio_recommendations(session, portfolio_id: int): +def aggregate_portfolio_recommendations(session, portfolio_id: int, total_valuation_increase: float): # Aggregate multiple fields aggregates = ( session.query( func.sum(Recommendation.estimated_cost).label("cost"), func.sum(Recommendation.total_work_hours).label("total_work_hours"), - func.sum(Recommendation.heat_demand).label("total_heat_demand"), - func.sum(Recommendation.energy_savings).label("total_energy_savings"), - func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings") + func.sum(Recommendation.heat_demand).label("energy_savings"), + func.sum(Recommendation.co2_equivalent_savings).label("co2_equivalent_savings"), + func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings"), + func.sum(Recommendation.labour_days).label("labour_days"), ) .join(PlanRecommendations, PlanRecommendations.recommendation_id == Recommendation.id) .join(Plan, Plan.id == PlanRecommendations.plan_id) @@ -22,9 +23,10 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int): aggregates_dict = { "cost": aggregates.cost or 0, "total_work_hours": aggregates.total_work_hours or 0, - "total_heat_demand": aggregates.total_heat_demand or 0, - "total_energy_savings": aggregates.total_energy_savings or 0, + "energy_savings": aggregates.energy_savings or 0, + "co2_equivalent_savings": aggregates.co2_equivalent_savings or 0, "energy_cost_savings": aggregates.energy_cost_savings or 0, + "labour_days": aggregates.labour_days or 0, } # Get the portfolio and update the fields @@ -33,6 +35,9 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int): for key, value in aggregates_dict.items(): setattr(portfolio, key, value) + # Insert total valuation increase + portfolio.property_valuation_increase = total_valuation_increase + # Merge the updated portfolio back into the session session.merge(portfolio) session.flush() diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index 772e1184..f7fcb7a3 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -83,7 +83,8 @@ def upload_recommendations(session: Session, recommendations_to_upload, property "heat_demand": rec["heat_demand"], "co2_equivalent_savings": rec["co2_equivalent_savings"], "total_work_hours": rec["labour_hours"], - "energy_cost_savings": rec["energy_cost_savings"] + "energy_cost_savings": rec["energy_cost_savings"], + "labour_days": rec["labour_days"] } for rec in recommendations_to_upload ] diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py index 8279a978..efcda359 100644 --- a/backend/app/db/models/portfolio.py +++ b/backend/app/db/models/portfolio.py @@ -42,6 +42,7 @@ class Portfolio(Base): property_valuation_increase = Column(Float) # Unit is always £ so we don't need to store the unit for the moment rental_yield_increase = Column(Float) # Unit is always £ so we don't need to store the unit for the moment total_work_hours = Column(Float) + labour_days = Column(Float) created_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc)) updated_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc)) diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index 5515b90d..ff7aa642 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -28,6 +28,7 @@ class Recommendation(Base): property_valuation_increase = Column(Float) rental_yield_increase = Column(Float) total_work_hours = Column(Float) + labour_days = Column(Float) class RecommendationMaterials(Base): diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 408b4101..ed568bdd 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -21,7 +21,7 @@ from backend.app.db.models.portfolio import rating_lookup from backend.app.dependencies import validate_token from backend.app.plan.schemas import PlanTriggerRequest from backend.app.plan.utils import create_recommendation_scoring_data, get_cleaned -from backend.app.utils import epc_to_sap_lower_bound, read_csv_from_s3, read_parquet_from_s3 +from backend.app.utils import epc_to_sap_lower_bound, read_csv_from_s3, read_parquet_from_s3, sap_to_epc from backend.ml_models.api import ModelApi from backend.Property import Property @@ -33,6 +33,7 @@ from recommendations.optimiser.optimiser_functions import prepare_input_measures from recommendations.Recommendations import Recommendations from utils.logger import setup_logger from utils.s3 import read_dataframe_from_s3_parquet +from backend.ml_models.Valuation import PropertyValuation logger = setup_logger() @@ -250,6 +251,7 @@ async def trigger_plan(body: PlanTriggerRequest): # 3) the recommendations logger.info("Uploading recommendations to the database") + property_valuation_increases = [] session.commit() for i in range(0, len(input_properties), BATCH_SIZE): try: @@ -289,6 +291,16 @@ async def trigger_plan(body: PlanTriggerRequest): session, plan_id=new_plan_id, recommendation_ids=uploaded_recommendation_ids ) + # Get defaults + default_recommendations = [r for r in recommendations_to_upload if r["default"]] + total_sap_points = sum([r["sap_points"] for r in default_recommendations]) + new_sap_points = float(p.data["current-energy-efficiency"]) + total_sap_points + new_epc = sap_to_epc(new_sap_points) + + property_valuation_increases.append( + PropertyValuation.estimate(property_instance=p, target_epc=new_epc) + ) + # Commit the session after each batch session.commit() @@ -304,7 +316,12 @@ async def trigger_plan(body: PlanTriggerRequest): # way to do this, but it's the simplest and will be a process that we can re-use since when we change a # recommendation from being default to not default, we'll need to re-run this process to re-calculate the # the portfolion level impact - aggregate_portfolio_recommendations(session, portfolio_id=body.portfolio_id) + + total_valuation_increase = sum(property_valuation_increases) + + aggregate_portfolio_recommendations( + session, portfolio_id=body.portfolio_id, total_valuation_increase=total_valuation_increase + ) # Commit final changes session.commit() diff --git a/backend/ml_models/Valuation.py b/backend/ml_models/Valuation.py index 1b2f8cf4..92d019a4 100644 --- a/backend/ml_models/Valuation.py +++ b/backend/ml_models/Valuation.py @@ -12,12 +12,12 @@ class PropertyValuation: { "starting_epc": "D", "ending_epc": "C", - "increase_percentage": 0.057, + "increase_percentage": 0.03625, }, { "starting_epc": "D", "ending_epc": "B", - "increase_percentage": 0.057, + "increase_percentage": 0.05725, }, ] @@ -30,7 +30,7 @@ class PropertyValuation: valuation_increases = [ v for v in cls.VALUE_INCREASE_MAPPING if - v["starting_epc"] == property_instance.epc_band and v["ending_epc"] == target_epc + v["starting_epc"] == property_instance.data["current-energy-rating"] and v["ending_epc"] == target_epc ] if len(valuation_increases) != 1: diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 6fc62db2..c9ead002 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -115,6 +115,9 @@ class Costs: labour_hours = material["labour_hours_per_unit"] * wall_area + # Assume a team of 2 + labour_days = (labour_hours / 8) / 2 + return { "total": total_cost, "subtotal": subtotal_before_vat, @@ -124,7 +127,8 @@ class Costs: "material": base_material_cost, "profit": profit_cost, "labour_hours": labour_hours, - "labour_cost": labour_cost + "labour_cost": labour_cost, + "labour_days": labour_days } def loft_insulation(self, floor_area, material): @@ -153,6 +157,9 @@ class Costs: labour_hours = material["labour_hours_per_unit"] * floor_area + # Assume a team of 1 person + labour_days = labour_hours / 8 + return { "total": total_cost, "subtotal": subtotal_before_vat, @@ -162,7 +169,8 @@ class Costs: "material": base_material_cost, "profit": profit_cost, "labour_hours": labour_hours, - "labour_cost": labour_cost + "labour_cost": labour_cost, + "labour_days": labour_days } def internal_wall_insulation(self, wall_area, material, non_insulation_materials): diff --git a/recommendations/FireplaceRecommendations.py b/recommendations/FireplaceRecommendations.py index 30ab1ad2..c193b7ce 100644 --- a/recommendations/FireplaceRecommendations.py +++ b/recommendations/FireplaceRecommendations.py @@ -45,6 +45,7 @@ class FireplaceRecommendations(Definitions): "sap_points": None, "total": estimated_cost, # Take a very basic estimate of 6 hours, multipled by the number of open fireplaces to seal - "labour_hours": 6 * number_open_fireplaces + "labour_hours": 6 * number_open_fireplaces, + "labour_days": 6 * number_open_fireplaces / 8, # Assume 8 hour day } ] diff --git a/recommendations/VentilationRecommendations.py b/recommendations/VentilationRecommendations.py index 419029a3..b42d136f 100644 --- a/recommendations/VentilationRecommendations.py +++ b/recommendations/VentilationRecommendations.py @@ -67,6 +67,7 @@ class VentilationRecommendations(Definitions): "sap_points": None, "total": estimated_cost, # We use a very simple and rough estimate of 4 hours per unit - "labour_hours": 4 * n_units + "labour_hours": 4 * n_units, + "labour_days": 4 * n_units / 8.0 # Assume 8 hour day } ] From e441e5f018f0fc73b61e2c1a31413e556bd53898 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Nov 2023 15:48:26 +0000 Subject: [PATCH 07/23] Added low energy lighting to costs etl --- backend/ml_models/Valuation.py | 2 ++ etl/costs/app.py | 2 ++ recommendations/Costs.py | 18 ++++++++++++ recommendations/LightingRecommendations.py | 34 ++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 recommendations/LightingRecommendations.py diff --git a/backend/ml_models/Valuation.py b/backend/ml_models/Valuation.py index 92d019a4..4a720fc8 100644 --- a/backend/ml_models/Valuation.py +++ b/backend/ml_models/Valuation.py @@ -25,6 +25,8 @@ class PropertyValuation: def estimate(cls, property_instance, target_epc): current_value = cls.UPRN_VALUE_LOOKUP.get(property_instance.uprn) + raise ValueError("NEED TO UPDATE THIS") + if not current_value: raise ValueError("Have not implemented valuation for this property") diff --git a/etl/costs/app.py b/etl/costs/app.py index 1ecbbb5f..98a324bc 100644 --- a/etl/costs/app.py +++ b/etl/costs/app.py @@ -73,6 +73,7 @@ def app(): suspended_floor_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="suspended_floor_insulation", header=0) solid_floor_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="solid_floor_insulation", header=0) ewi_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="external_wall_insulation", header=0) + lel_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="low_energy_lighting", header=0) # Form a single table to be uploaded costs = pd.concat( @@ -83,6 +84,7 @@ def app(): suspended_floor_costs, solid_floor_costs, ewi_costs, + lel_costs ] ) diff --git a/recommendations/Costs.py b/recommendations/Costs.py index c9ead002..e9fe4495 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -577,3 +577,21 @@ class Costs: "labour_days": labour_days, "labour_cost": labour_costs } + + def low_energy_lighting(self, number_of_lights, number_current_lel_lights, material): + + """ + Calculates the total cost for low energy lighting based on material and labor costs, + including contingency, preliminaries, profit, and VAT. + + :param number_of_lights: Int, number of light + :param number_current_lel_lights: Int, number of low energy lights currently installed in the home + :material: Dict, material data containing costs of fittings + """ + + # If there are no lights fitted in the property, we increase the contingency in case there are potential wiring + # blockers + if number_current_lel_lights == 0: + contingency = self.HIGH_RISK_CONTINGENCY + else: + contingency = self.CONTINGENCY diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py new file mode 100644 index 00000000..48bb2c0f --- /dev/null +++ b/recommendations/LightingRecommendations.py @@ -0,0 +1,34 @@ +from backend.Property import Property +from typing import List + + +class LightingRecommendations: + + def __init__(self, property_instance: Property, materials: List): + """ + :param property_instance: Instance of the Property class, for the home associated to property_id + :param materials: List of materials to be used in the recommendations + """ + + self.property = property_instance + self.materials = materials + + def recommend(self): + """ + This method will check if there are any lighting fittings that aren't low energy. + + If there are, the will recommend fitting the rest of the outlets with low energy lighting fittings + :return: + """ + + if self.property.lighting["low_energy_proportion"] == 100: + return + + number_lighting_outlets = self.property.number_lighting_outlets + + # Number non lel outlets + number_non_lel_outlets = number_lighting_outlets - ( + self.property.lighting["low_energy_proportion"] * number_lighting_outlets + ) + + number_non_lel_outlets = round(number_non_lel_outlets) From 96b49aa680100391ebdea023fb5e1b63025cb7e2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Nov 2023 16:54:14 +0000 Subject: [PATCH 08/23] working on costing for led installation --- backend/Property.py | 14 ++++++++- backend/app/db/models/materials.py | 1 + backend/app/plan/router.py | 8 +++++ recommendations/Costs.py | 30 +++++++++++++++++++ recommendations/LightingRecommendations.py | 34 +++++++++++++++++++++- recommendations/Recommendations.py | 7 +++++ 6 files changed, 92 insertions(+), 2 deletions(-) diff --git a/backend/Property.py b/backend/Property.py index a3328156..0d7553a5 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -83,6 +83,7 @@ class Property(Definitions): self.floor_area = None self.pitched_roof_area = None self.insulation_floor_area = None + self.number_lighting_outlets = None if epc_client: self.epc_client = epc_client @@ -703,7 +704,6 @@ class Property(Definitions): 'PROPERTY_TYPE', 'UPRN', 'NUMBER_OPEN_FIREPLACES', - 'FIXED_LIGHTING_OUTLETS_COUNT', 'MULTI_GLAZE_PROPORTION', 'MECHANICAL_VENTILATION', 'PHOTO_SUPPLY', @@ -752,9 +752,21 @@ class Property(Definitions): "FLOOR_HEIGHT": self.floor_height, "NUMBER_HABITABLE_ROOMS": self.number_of_rooms, "TOTAL_FLOOR_AREA": self.floor_area, + "FIXED_LIGHTING_OUTLETS_COUNT": self.number_lighting_outlets, **epc_raw_data, "BUILT_FORM": built_form, "POSTCODE": self.data["postcode"], } return property_data + + def set_number_lighting_outlets(self, cleaned_property_data): + """ + Extracts and cleans the estimated number of lighting outlets + :return: + """ + + if self.data["fixed-lighting-outlets-count"] == "": + self.number_lighting_outlets = round(cleaned_property_data["FIXED_LIGHTING_OUTLETS_COUNT"].values[0]) + else: + self.number_lighting_outlets = float(self.data["fixed-lighting-outlets-count"]) diff --git a/backend/app/db/models/materials.py b/backend/app/db/models/materials.py index e191c5ee..64c5e166 100644 --- a/backend/app/db/models/materials.py +++ b/backend/app/db/models/materials.py @@ -32,6 +32,7 @@ class MaterialType(enum.Enum): ewi_wall_demolition = "ewi_wall_demolition" ewi_wall_preparation = "ewi_wall_preparation" ewi_wall_redecoration = "ewi_wall_redecoration" + low_energy_lighting_installation = "low_energy_lighting_installation" class DepthUnit(enum.Enum): diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index ed568bdd..c2f1de4c 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -124,6 +124,14 @@ async def trigger_plan(body: PlanTriggerRequest): # Property recommendations p.get_components(cleaned) + # This is temp - this should happen after scoring + cleaned_property_data = DataProcessor.apply_averages_cleaning( + data_to_clean=pd.DataFrame([dict(**p.get_model_data(), LOCAL_AUTHORITY=p.data["local-authority"])]), + cleaning_data=cleaning_data, + cols_to_merge_on=['PROPERTY_TYPE', 'BUILT_FORM', 'CONSTRUCTION_AGE_BAND', 'LOCAL_AUTHORITY'], + ) + p.set_number_lighting_outlets(cleaned_property_data) + recommender = Recommendations(property_instance=p, materials=materials) property_recommendations = recommender.recommend() diff --git a/recommendations/Costs.py b/recommendations/Costs.py index e9fe4495..32ab32aa 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -595,3 +595,33 @@ class Costs: contingency = self.HIGH_RISK_CONTINGENCY else: contingency = self.CONTINGENCY + + material_cost = material["material_cost"] * number_of_lights + labour_cost = material["labour_cost"] * number_of_lights * self.labour_adjustment_factor + + subtotal_before_profit = material_cost + labour_cost + + contingency_cost = subtotal_before_profit * contingency + 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 + + labour_hours = material["labour_hours_per_unit"] * number_of_lights + # Assume a single electrician installing + labour_days = (labour_hours / 8) + + return { + "total": total_cost, + "subtotal": subtotal_before_vat, + "vat": vat_cost, + "contingency": contingency_cost, + "preliminaries": preliminaries_cost, + "material": material_cost, + "profit": profit_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + "labour_cost": labour_cost + } diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py index 48bb2c0f..85b440b5 100644 --- a/recommendations/LightingRecommendations.py +++ b/recommendations/LightingRecommendations.py @@ -1,5 +1,6 @@ from backend.Property import Property from typing import List +from recommendations.Costs import Costs class LightingRecommendations: @@ -11,7 +12,16 @@ class LightingRecommendations: """ self.property = property_instance - self.materials = materials + self.costs = Costs(self.property) + + material = [ + material for material in materials if material["type"] == "low_energy_lighting_installation" + ] + if len(material) != 1: + raise ValueError("Incorrect number of low energy lighting materials specified") + + self.material = material[0] + self.recommendation = [] def recommend(self): """ @@ -32,3 +42,25 @@ class LightingRecommendations: ) number_non_lel_outlets = round(number_non_lel_outlets) + + if number_non_lel_outlets == 0: + return + + # Get the cost of the fittings + cost_result = self.costs.low_energy_lighting( + number_of_lights=number_non_lel_outlets, + number_current_lel_lights=number_lighting_outlets - number_non_lel_outlets, + material=self.material + ) + + self.recommendation = [ + { + "parts": [], + "type": "sealing_open_fireplace", + "description": "Install low energy lighting in %s outlets" % str(number_non_lel_outlets), + "starting_u_value": None, + "new_u_value": None, + "sap_points": None, + **cost_result + } + ] diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 17dcc5df..0d39c9cf 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -5,6 +5,7 @@ from recommendations.WallRecommendations import WallRecommendations from recommendations.RoofRecommendations import RoofRecommendations from recommendations.VentilationRecommendations import VentilationRecommendations from recommendations.FireplaceRecommendations import FireplaceRecommendations +from recommendations.LightingRecommendations import LightingRecommendations from backend.ml_models.AnnualBillSavings import AnnualBillSavings @@ -34,6 +35,7 @@ class Recommendations: materials=[part for part in materials if part["type"] == "mechanical_ventilation"] ) self.fireplace_recommender = FireplaceRecommendations(property_instance=property_instance) + self.lighting_recommender = LightingRecommendations(property_instance=property_instance, materials=materials) def recommend(self): @@ -69,6 +71,11 @@ class Recommendations: if self.fireplace_recommender.recommendation: property_recommendations.append(self.fireplace_recommender.recommendation) + # Lighting recommendations + self.lighting_recommender.recommend() + if self.lighting_recommender.recommendation: + property_recommendations.append(self.lighting_recommender.recommendation) + # We insert temporary ids into the recommendations which is important for the optimiser later property_recommendations = self.insert_temp_recommendation_id(property_recommendations) From 7ad88efbd49ac34609f3ca67b4c3ae6f52bc7b0b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Nov 2023 19:40:50 +0000 Subject: [PATCH 09/23] Working through new costs --- backend/app/plan/router.py | 16 ++++++++++++++++ backend/app/plan/utils.py | 5 +++++ recommendations/Costs.py | 6 +++--- recommendations/LightingRecommendations.py | 9 +++++++-- recommendations/WallRecommendations.py | 7 +++++-- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index c2f1de4c..7a50ac98 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -219,6 +219,22 @@ async def trigger_plan(body: PlanTriggerRequest): recommendations=recommendations ) + print("GET RID OF ME!") + rec_df = [] + for rec_group in recommendations_with_impact: + for rec in rec_group: + rec_df.append( + { + "type": rec["type"], + "description": rec["description"], + "sap": rec["sap_points"], + "total": rec["total"], + "co2_equivalent_savings": rec["co2_equivalent_savings"], + "heat_demand": rec["heat_demand"] + } + ) + rec_df = pd.DataFrame(rec_df) + input_measures = prepare_input_measures(recommendations_with_impact, body.goal) if body.budget: diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py index 5bb8e42a..b60bc20f 100644 --- a/backend/app/plan/utils.py +++ b/backend/app/plan/utils.py @@ -171,8 +171,13 @@ def create_recommendation_scoring_data( if recommendation["type"] == "sealing_open_fireplace": scoring_dict["NUMBER_OPEN_FIREPLACES_ENDING"] = 0 + if recommendation["type"] == "low_energy_lighting": + scoring_dict["LOW_ENERGY_LIGHTING_ENDING"] = 100 + scoring_dict["LIGHTING_ENERGY_EFF_STARTING"] = "Very Good" + if recommendation["type"] not in [ "wall_insulation", "floor_insulation", "roof_insulation", "mechanical_ventilation", "sealing_open_fireplace", + "low_energy_lighting" ]: raise NotImplementedError("Implement me") diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 32ab32aa..5c2b28f2 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -41,7 +41,7 @@ class Costs: CONTINGENCY = 0.1 # Where there is more uncertainty, a higher contingency rate is used - HIGH_RISK_CONTINGENCY = 0.15 + HIGH_RISK_CONTINGENCY = 0.2 # When there is less uncertainty, a lower contingency rate is used LOW_RISK_CONTINGENCY = 0.05 @@ -54,8 +54,8 @@ class Costs: # have a preliminaries of 12-14% so we use 12% as the median for the preliminaries rate. # For External wall insulation (EWI), we use 15% as the preliminaries rate if we think the property might # need scaffolding, otherwise we use 12%. This is to account for any site preparation that might be required - EWI_NO_SCAFFOLDING_PRELIMINARIES = 0.12 - EWI_SCAFFOLDING_PRELIMINARIES = 0.15 + EWI_NO_SCAFFOLDING_PRELIMINARIES = 0.15 + EWI_SCAFFOLDING_PRELIMINARIES = 0.20 VAT_RATE = 0.2 PROFIT_MARGIN = 0.2 diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py index 85b440b5..f898eaf5 100644 --- a/recommendations/LightingRecommendations.py +++ b/recommendations/LightingRecommendations.py @@ -53,11 +53,16 @@ class LightingRecommendations: material=self.material ) + if number_non_lel_outlets == 1: + description = "Install low energy lighting in %s 1 remaining outlet" + else: + description = "Install low energy lighting in %s outlets" % str(number_non_lel_outlets) + self.recommendation = [ { "parts": [], - "type": "sealing_open_fireplace", - "description": "Install low energy lighting in %s outlets" % str(number_non_lel_outlets), + "type": "low_energy_lighting", + "description": description, "starting_u_value": None, "new_u_value": None, "sap_points": None, diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py index acc74ead..17fa4ad4 100644 --- a/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -263,12 +263,14 @@ class WallRecommendations(Definitions): material=material.to_dict(), non_insulation_materials=non_insulation_materials ) + description = "Install " + self._make_description(material) + " on internal walls" elif material["type"] == "external_wall_insulation": cost_result = self.costs.external_wall_insulation( wall_area=self.property.insulation_wall_area, material=material.to_dict(), non_insulation_materials=non_insulation_materials ) + description = "Install " + self._make_description(material) + " on external walls" else: raise ValueError("Invalid material type") @@ -283,7 +285,7 @@ class WallRecommendations(Definitions): ) ], "type": "wall_insulation", - "description": "Install " + self._make_description(material), + "description": description, "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, @@ -321,7 +323,8 @@ class WallRecommendations(Definitions): self.recommendations += ewi_recommendations + iwi_recommendations - self.prune_diminishing_recommendations() + # We remove this temporarily + # self.prune_diminishing_recommendations() @staticmethod def _make_description(material): From d97a91eec7feb60d38c65f93c0a7fb4eb0cd0d25 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Nov 2023 22:32:12 +0000 Subject: [PATCH 10/23] Put in placeholder method to break down carbon and energy savings --- .../app/db/functions/portfolio_functions.py | 9 +- backend/app/plan/router.py | 175 ++++++++++++++++-- backend/ml_models/Valuation.py | 35 +--- recommendations/LightingRecommendations.py | 2 +- 4 files changed, 170 insertions(+), 51 deletions(-) diff --git a/backend/app/db/functions/portfolio_functions.py b/backend/app/db/functions/portfolio_functions.py index b3936eb9..a8a882bd 100644 --- a/backend/app/db/functions/portfolio_functions.py +++ b/backend/app/db/functions/portfolio_functions.py @@ -3,7 +3,9 @@ from backend.app.db.models.recommendations import Plan, PlanRecommendations, Rec from backend.app.db.models.portfolio import Portfolio -def aggregate_portfolio_recommendations(session, portfolio_id: int, total_valuation_increase: float): +def aggregate_portfolio_recommendations( + session, portfolio_id: int, total_valuation_increase: float, labour_days: float +): # Aggregate multiple fields aggregates = ( session.query( @@ -12,7 +14,6 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int, total_valuat func.sum(Recommendation.heat_demand).label("energy_savings"), func.sum(Recommendation.co2_equivalent_savings).label("co2_equivalent_savings"), func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings"), - func.sum(Recommendation.labour_days).label("labour_days"), ) .join(PlanRecommendations, PlanRecommendations.recommendation_id == Recommendation.id) .join(Plan, Plan.id == PlanRecommendations.plan_id) @@ -26,7 +27,6 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int, total_valuat "energy_savings": aggregates.energy_savings or 0, "co2_equivalent_savings": aggregates.co2_equivalent_savings or 0, "energy_cost_savings": aggregates.energy_cost_savings or 0, - "labour_days": aggregates.labour_days or 0, } # Get the portfolio and update the fields @@ -35,8 +35,9 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int, total_valuat for key, value in aggregates_dict.items(): setattr(portfolio, key, value) - # Insert total valuation increase + # Insert total valuation increase and labour days portfolio.property_valuation_increase = total_valuation_increase + portfolio.labour_days = labour_days # Merge the updated portfolio back into the session session.merge(portfolio) diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 7a50ac98..0c086a87 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -1,5 +1,6 @@ from datetime import datetime +import numpy as np import pandas as pd from epc_api.client import EpcClient from fastapi import APIRouter, Depends @@ -34,6 +35,7 @@ from recommendations.Recommendations import Recommendations from utils.logger import setup_logger from utils.s3 import read_dataframe_from_s3_parquet from backend.ml_models.Valuation import PropertyValuation +from backend.ml_models.AnnualBillSavings import AnnualBillSavings logger = setup_logger() @@ -118,6 +120,7 @@ async def trigger_plan(body: PlanTriggerRequest): recommendations = {} recommendations_scoring_data = [] + property_scoring_data = {} for p in input_properties: @@ -156,6 +159,12 @@ async def trigger_plan(body: PlanTriggerRequest): # We update the ending record with the recommended updates and we set lodgement date to today ending_epc_data["DAYS_TO_ENDING"] = data_processor.calculate_days_to(created_at) + property_scoring_data[p.id] = { + "starting_epc_data": starting_epc_data, + "ending_epc_data": ending_epc_data, + "fixed_data": fixed_data + } + for recommendations_by_type in property_recommendations: for i, rec in enumerate(recommendations_by_type): scoring_dict = create_recommendation_scoring_data( @@ -219,22 +228,6 @@ async def trigger_plan(body: PlanTriggerRequest): recommendations=recommendations ) - print("GET RID OF ME!") - rec_df = [] - for rec_group in recommendations_with_impact: - for rec in rec_group: - rec_df.append( - { - "type": rec["type"], - "description": rec["description"], - "sap": rec["sap_points"], - "total": rec["total"], - "co2_equivalent_savings": rec["co2_equivalent_savings"], - "heat_demand": rec["heat_demand"] - } - ) - rec_df = pd.DataFrame(rec_df) - input_measures = prepare_input_measures(recommendations_with_impact, body.goal) if body.budget: @@ -270,6 +263,148 @@ async def trigger_plan(body: PlanTriggerRequest): ] recommendations[property_id] = final_recommendations + # This is a temporary step, to estimate the impact of the measured on heat demand and carbon + combined_recommendations_scoring_data = [] + representative_recs = {} + for property_id, property_recommendations in recommendations.items(): + default_recommendations = [r for r in property_recommendations if r["default"]] + default_types = {x["type"] for x in default_recommendations} + + # Missing types + missing_types = list(set([r["type"] for r in property_recommendations if r["type"] not in default_types])) + if missing_types: + for missed_type in missing_types: + missed = [r for r in property_recommendations if r["type"] == missed_type] + median_cost = np.median([r["total"] for r in missed]) + # Grab a representative, based on median cost + representative_rec = [r for r in property_recommendations if r["total"] == median_cost] + default_recommendations.append(representative_rec[0]) + + representative_recs[property_id] = default_recommendations + + property_instance = [p for p in input_properties if p.id == property_id][0] + + property_scoring_datasets = property_scoring_data[property_id] + starting_epc_data = property_scoring_datasets["starting_epc_data"].copy() + ending_epc_data = property_scoring_datasets["ending_epc_data"].copy() + fixed_data = property_scoring_datasets["fixed_data"].copy() + + scoring_dict = {} + for rec in default_recommendations: + scoring_dict = create_recommendation_scoring_data( + property=property_instance, + recommendation=rec, + starting_epc_data=starting_epc_data, + ending_epc_data=ending_epc_data, + fixed_data=fixed_data, + ) + # At each iteration, we want to update the ending_epc_data, so in the end, ending_epc_data contains + # all of the updates + for k in scoring_dict.keys(): + if k in ending_epc_data.columns: + ending_epc_data[k] = scoring_dict[k] + + combined_recommendations_scoring_data.append(scoring_dict) + + # PERFORM SAME STEPS AGAIN - TODO: TO BE REMOVED + combined_recommendations_scoring_data = pd.DataFrame(combined_recommendations_scoring_data) + + # Perform the same cleaning as in the model - first clean number of room variables though + combined_recommendations_scoring_data = DataProcessor.apply_averages_cleaning( + data_to_clean=combined_recommendations_scoring_data, + cleaning_data=cleaning_data, + cols_to_merge_on=['PROPERTY_TYPE', 'BUILT_FORM', 'CONSTRUCTION_AGE_BAND', 'LOCAL_AUTHORITY'], + colnames=["NUMBER_HABITABLE_ROOMS", "NUMBER_HEATED_ROOMS"], + ) + + combined_recommendations_scoring_data = DataProcessor.apply_averages_cleaning( + data_to_clean=combined_recommendations_scoring_data, + cleaning_data=cleaning_data, + cols_to_merge_on=COLUMNS_TO_MERGE_ON + ["LOCAL_AUTHORITY"], + ).drop(columns=["LOCAL_AUTHORITY"]) + + combined_recommendations_scoring_data = DataProcessor.clean_missings_after_description_process( + combined_recommendations_scoring_data, + ignore_cols=[c for c in combined_recommendations_scoring_data.columns if ("thermal_transmittance" in c) or ( + "insulation_thickness" in c) or ("ENERGY_EFF" in c)] + ) + + combined_recommendations_scoring_data = DataProcessor.clean_efficiency_variables( + combined_recommendations_scoring_data + ) + + model_api = ModelApi(portfolio_id=body.portfolio_id, timestamp=created_at) + all_combined_predictions = model_api.predict_all( + df=combined_recommendations_scoring_data, + bucket=get_settings().DATA_BUCKET, + prediction_buckets={ + "sap_change_predictions": get_settings().SAP_PREDICTIONS_BUCKET, + "heat_demand_predictions": get_settings().HEAT_PREDICTIONS_BUCKET, + "carbon_change_predictions": get_settings().CARBON_PREDICTIONS_BUCKET + } + ) + + # We update the carbon and heat demand predictions + for property_id, property_recommendations in recommendations.items(): + combined_heat_demand = all_combined_predictions["heat_demand_predictions"] + combined_heat_demand = combined_heat_demand[combined_heat_demand["property_id"] == str(property_id)] + + combined_carbon = all_combined_predictions["carbon_change_predictions"] + combined_carbon = combined_carbon[combined_carbon["property_id"] == str(property_id)] + + property_instance = [p for p in input_properties if p.id == property_id][0] + + carbon_change = float( + property_instance.data["co2-emissions-current"] + ) - combined_carbon["predictions"].values[0] + + heat_demand_change = ( + (float(property_instance.data["energy-consumption-current"]) - + combined_heat_demand["predictions"].values[0]) + * property_instance.floor_area + ) + + # update the recommendations + # We need to totals for the representative recommendations + representative_rec_data = [ + { + "recommendation_id": r["recommendation_id"], + "co2_equivalent_savings": r["co2_equivalent_savings"], + "heat_demand": r["heat_demand"], + "type": r["type"] + } for r + in representative_recs[property_id] + ] + representative_rec_data = pd.DataFrame(representative_rec_data) + # Convert co2 and heat demand to proportions of their column sums + representative_rec_data["co2_equivalent_savings_percent"] = ( + representative_rec_data["co2_equivalent_savings"] / + representative_rec_data["co2_equivalent_savings"].sum() + ) + + representative_rec_data["heat_demand_percent"] = ( + representative_rec_data["heat_demand"] / representative_rec_data["heat_demand"].sum() + ) + + # We'll use the proportions to update the carbon and heat demand + representative_rec_data["co2_equivalent_savings"] = ( + carbon_change * representative_rec_data["co2_equivalent_savings_percent"] + ) + + representative_rec_data["heat_demand"] = ( + heat_demand_change * representative_rec_data["heat_demand_percent"] + ) + + # Finally, insert these values into the final recommendations + for rec in property_recommendations: + change_data = representative_rec_data[representative_rec_data["type"] == rec["type"]] + rec["co2_equivalent_savings"] = change_data["co2_equivalent_savings"].values[0] + rec["heat_demand"] = change_data["heat_demand"].values[0] + rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["heat_demand"]) + + # Update recommendations + recommendations[property_id] = property_recommendations + # 1) the property data # 2) the property details (epc) # 3) the recommendations @@ -342,9 +477,15 @@ async def trigger_plan(body: PlanTriggerRequest): # the portfolion level impact total_valuation_increase = sum(property_valuation_increases) + labour_days = max( + [sum(r["labour_days"] for r in rec_group if r["default"]) for p_id, rec_group in recommendations.items()] + ) aggregate_portfolio_recommendations( - session, portfolio_id=body.portfolio_id, total_valuation_increase=total_valuation_increase + session, + portfolio_id=body.portfolio_id, + total_valuation_increase=total_valuation_increase, + labour_days=labour_days ) # Commit final changes diff --git a/backend/ml_models/Valuation.py b/backend/ml_models/Valuation.py index 4a720fc8..0db2a082 100644 --- a/backend/ml_models/Valuation.py +++ b/backend/ml_models/Valuation.py @@ -4,42 +4,19 @@ class PropertyValuation: """ UPRN_VALUE_LOOKUP = { - 15038202: 202000, - 37024763: 213000, + 15038202: {"current_value": 202000, "increase_percentage": 0.05725}, + 37024763: {"current_value": 213000, "increase_percentage": 0.03625}, } - VALUE_INCREASE_MAPPING = [ - { - "starting_epc": "D", - "ending_epc": "C", - "increase_percentage": 0.03625, - }, - { - "starting_epc": "D", - "ending_epc": "B", - "increase_percentage": 0.05725, - }, - ] - @classmethod def estimate(cls, property_instance, target_epc): - current_value = cls.UPRN_VALUE_LOOKUP.get(property_instance.uprn) + data = cls.UPRN_VALUE_LOOKUP.get(property_instance.uprn) - raise ValueError("NEED TO UPDATE THIS") - - if not current_value: + if not data: raise ValueError("Have not implemented valuation for this property") - valuation_increases = [ - v for v in cls.VALUE_INCREASE_MAPPING if - v["starting_epc"] == property_instance.data["current-energy-rating"] and v["ending_epc"] == target_epc - ] + new_valuation = (1 + data["increase_percentage"]) * data["current_value"] - if len(valuation_increases) != 1: - raise ValueError("Valuation increase mapping not found") - - new_valuation = (1 + valuation_increases[0]["increase_percentage"]) * current_value - - increase = round(new_valuation - current_value, 2) + increase = round(new_valuation - data["current_value"], 2) return increase diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py index f898eaf5..82baf5d6 100644 --- a/recommendations/LightingRecommendations.py +++ b/recommendations/LightingRecommendations.py @@ -54,7 +54,7 @@ class LightingRecommendations: ) if number_non_lel_outlets == 1: - description = "Install low energy lighting in %s 1 remaining outlet" + description = "Install low energy lighting in 1 remaining outlet" else: description = "Install low energy lighting in %s outlets" % str(number_non_lel_outlets) From 4aea5f600260d3930aca7c702182085f9bb2579a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Nov 2023 12:08:24 +0000 Subject: [PATCH 11/23] Tidying up new descriptions and updating recommendation types --- backend/Property.py | 13 ++- backend/app/db/models/portfolio.py | 1 + backend/app/plan/router.py | 94 +++++++++++++++---- backend/ml_models/AnnualBillSavings.py | 44 +++++++++ backend/ml_models/Valuation.py | 2 +- recommendations/Costs.py | 3 +- recommendations/FloorRecommendations.py | 20 +++- recommendations/LightingRecommendations.py | 4 +- recommendations/Recommendations.py | 16 +++- recommendations/RoofRecommendations.py | 29 +++--- recommendations/VentilationRecommendations.py | 3 + recommendations/WallRecommendations.py | 37 +++----- recommendations/optimiser/CostOptimiser.py | 12 +++ .../optimiser/optimiser_functions.py | 48 +++++++--- 14 files changed, 248 insertions(+), 78 deletions(-) diff --git a/backend/Property.py b/backend/Property.py index 0d7553a5..ddfb9445 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -85,6 +85,9 @@ class Property(Definitions): self.insulation_floor_area = None self.number_lighting_outlets = None + self.current_adjusted_energy = None + self.expected_adjusted_energy = None + if epc_client: self.epc_client = epc_client else: @@ -462,7 +465,7 @@ class Property(Definitions): "year_built": self.year_built, "tenure": self.data["tenure"], "current_epc_rating": self.data["current-energy-rating"], - "current_sap_points": self.data["current-energy-efficiency"] + "current_sap_points": self.data["current-energy-efficiency"], } property_data = self._clean_upload_data(property_data) @@ -514,6 +517,7 @@ class Property(Definitions): "energy_tariff": self.data["energy-tariff"], "primary_energy_consumption": self.energy["primary_energy_consumption"], "co2_emissions": self.energy["co2_emissions"], + "adjusted_energy_consumption": self.current_adjusted_energy, } return property_details_epc @@ -770,3 +774,10 @@ class Property(Definitions): self.number_lighting_outlets = round(cleaned_property_data["FIXED_LIGHTING_OUTLETS_COUNT"].values[0]) else: self.number_lighting_outlets = float(self.data["fixed-lighting-outlets-count"]) + + def set_adjusted_energy(self, current_adjusted_energy, expected_adjusted_energy): + """ + Stores these values for usage later + """ + self.current_adjusted_energy = current_adjusted_energy + self.expected_adjusted_energy = expected_adjusted_energy diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py index efcda359..ab047477 100644 --- a/backend/app/db/models/portfolio.py +++ b/backend/app/db/models/portfolio.py @@ -152,6 +152,7 @@ class PropertyDetailsEpcModel(Base): energy_tariff = Column(Text) primary_energy_consumption = Column(Float) co2_emissions = Column(Float) + adjusted_energy_consumption = Column(Float) class PropertyDetailsMeter(Base): diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 0c086a87..841415b7 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -80,11 +80,17 @@ async def trigger_plan(body: PlanTriggerRequest): if not is_new: continue # TODO: Need to add heat demand target + # TODO: Temp for Keyzy + if config['address'] == "25 Albert Street": + epc_target = "C" + else: + epc_target = body.goal_value + create_property_targets( session, property_id=property_id, portfolio_id=body.portfolio_id, - epc_target=body.goal_value, + epc_target=epc_target, heat_demand_target=None ) @@ -235,11 +241,19 @@ async def trigger_plan(body: PlanTriggerRequest): else: # The minimum gain is the minimum number of SAP points required to get to the target SAP band current_sap_points = int(property_instance.data["current-energy-efficiency"]) - target_sap_points = epc_to_sap_lower_bound(body.goal_value) + + # TODO: TEMP + if property_instance.address1 == "25 Albert Street": + opt_epc_target = "C" + else: + opt_epc_target = body.goal_value + + target_sap_points = epc_to_sap_lower_bound(opt_epc_target) # If the gain is negative, the optimiser will return an empty solution optimiser = CostOptimiser( - input_measures, min_gain=target_sap_points - current_sap_points + input_measures, + min_gain=CostOptimiser.calculate_sap_gain_with_slack(target_sap_points - current_sap_points) ) optimiser.setup() @@ -247,6 +261,17 @@ async def trigger_plan(body: PlanTriggerRequest): solution = optimiser.solution selected_recommendations = {r["id"] for r in solution} + if "wall_insulation" in [r["type"] for r in solution]: + ventilation_rec = [ + r for r in recommendations_with_impact if r[0]["type"] == "mechanical_ventilation" + ][0] + + selected_recommendations = set( + list(selected_recommendations) + [ventilation_rec[0]["recommendation_id"]] + ) + + # We check if the selected recommendation is wall ventilation and if so, we make sure + # mechanical ventilation is selected # We'll use the set of selected recommendations to filter the recommendations to upload final_recommendations = [ @@ -275,9 +300,10 @@ async def trigger_plan(body: PlanTriggerRequest): if missing_types: for missed_type in missing_types: missed = [r for r in property_recommendations if r["type"] == missed_type] - median_cost = np.median([r["total"] for r in missed]) - # Grab a representative, based on median cost - representative_rec = [r for r in property_recommendations if r["total"] == median_cost] + min_cost = min([r["total"] for r in missed]) + # Grab a representative, based on cheapest cost + + representative_rec = [r for r in property_recommendations if np.isclose(r["total"], min_cost)] default_recommendations.append(representative_rec[0]) representative_recs[property_id] = default_recommendations @@ -325,8 +351,10 @@ async def trigger_plan(body: PlanTriggerRequest): combined_recommendations_scoring_data = DataProcessor.clean_missings_after_description_process( combined_recommendations_scoring_data, - ignore_cols=[c for c in combined_recommendations_scoring_data.columns if ("thermal_transmittance" in c) or ( - "insulation_thickness" in c) or ("ENERGY_EFF" in c)] + ignore_cols=[ + c for c in combined_recommendations_scoring_data.columns if ("thermal_transmittance" in c) or ( + "insulation_thickness" in c) or ("ENERGY_EFF" in c) + ] ) combined_recommendations_scoring_data = DataProcessor.clean_efficiency_variables( @@ -358,10 +386,35 @@ async def trigger_plan(body: PlanTriggerRequest): property_instance.data["co2-emissions-current"] ) - combined_carbon["predictions"].values[0] + starting_heat_demand = ( + float(property_instance.data["energy-consumption-current"]) * property_instance.floor_area + ) + expected_heat_demand = starting_heat_demand - ( + combined_heat_demand["predictions"].values[0] * property_instance.floor_area + ) + + # We adjust the heat demand figures to align to the UCL paper + current_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( + epc_energy_consumption=starting_heat_demand, + current_epc_rating=property_instance.data["current-energy-rating"], + ) + + print("Hardcoded B - fix me") + if property_instance.address1 == "25 Albert Street": + hardcoded_expected_epc = "C" + else: + hardcoded_expected_epc = "B" + expected_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( + epc_energy_consumption=expected_heat_demand, + current_epc_rating=hardcoded_expected_epc, + ) + heat_demand_change = ( - (float(property_instance.data["energy-consumption-current"]) - - combined_heat_demand["predictions"].values[0]) - * property_instance.floor_area + current_adjusted_energy - expected_adjusted_energy + ) + property_instance.set_adjusted_energy( + current_adjusted_energy=current_adjusted_energy, + expected_adjusted_energy=expected_adjusted_energy ) # update the recommendations @@ -369,8 +422,8 @@ async def trigger_plan(body: PlanTriggerRequest): representative_rec_data = [ { "recommendation_id": r["recommendation_id"], - "co2_equivalent_savings": r["co2_equivalent_savings"], - "heat_demand": r["heat_demand"], + "co2_equivalent_savings": r.get("co2_equivalent_savings"), + "heat_demand": r.get("heat_demand"), "type": r["type"] } for r in representative_recs[property_id] @@ -398,9 +451,14 @@ async def trigger_plan(body: PlanTriggerRequest): # Finally, insert these values into the final recommendations for rec in property_recommendations: change_data = representative_rec_data[representative_rec_data["type"] == rec["type"]] - rec["co2_equivalent_savings"] = change_data["co2_equivalent_savings"].values[0] - rec["heat_demand"] = change_data["heat_demand"].values[0] - rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["heat_demand"]) + if rec["type"] == "mechanical_ventilation": + rec["co2_equivalent_savings"] = 0 + rec["heat_demand"] = 0 + rec["energy_cost_savings"] = 0 + else: + rec["co2_equivalent_savings"] = change_data["co2_equivalent_savings"].values[0] + rec["heat_demand"] = change_data["heat_demand"].values[0] + rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["heat_demand"]) # Update recommendations recommendations[property_id] = property_recommendations @@ -477,9 +535,9 @@ async def trigger_plan(body: PlanTriggerRequest): # the portfolion level impact total_valuation_increase = sum(property_valuation_increases) - labour_days = max( + labour_days = round(max( [sum(r["labour_days"] for r in rec_group if r["default"]) for p_id, rec_group in recommendations.items()] - ) + )) aggregate_portfolio_recommendations( session, diff --git a/backend/ml_models/AnnualBillSavings.py b/backend/ml_models/AnnualBillSavings.py index c057f4aa..1519a866 100644 --- a/backend/ml_models/AnnualBillSavings.py +++ b/backend/ml_models/AnnualBillSavings.py @@ -26,3 +26,47 @@ class AnnualBillSavings: :return: An estimate for annual bill savings """ return cls.PRICE_FACTOR * kwh + + @classmethod + def adjust_energy_to_metered(cls, epc_energy_consumption, current_epc_rating): + """ + The over-prediction of energy use by EPCs in Great Britain: A comparison + of EPC-modelled and metered primary energy use intensity + + Which can be found here: https://www.sciencedirect.com/science/article/pii/S0378778823002542 + We implement the results on page 10 + + :return: + """ + + gradients = { + "A": -0.1, + "B": -0.1, + "C": -0.43, + "D": -0.52, + "E": -0.7, + "F": -0.76, + "G": -0.76 + } + + intercepts = { + "A": 28, + "B": 28, + "C": 97, + "D": 119, + "E": 160, + "F": 157, + "G": 157 + } + + gradient = gradients[current_epc_rating] + intercept = intercepts[current_epc_rating] + + # This should be negative + consumption_difference = gradient * epc_energy_consumption + intercept + if consumption_difference > 0: + raise ValueError("consumption_difference should be negative") + + adjusted_consumption = (epc_energy_consumption + consumption_difference) + + return adjusted_consumption diff --git a/backend/ml_models/Valuation.py b/backend/ml_models/Valuation.py index 0db2a082..ad296409 100644 --- a/backend/ml_models/Valuation.py +++ b/backend/ml_models/Valuation.py @@ -5,7 +5,7 @@ class PropertyValuation: UPRN_VALUE_LOOKUP = { 15038202: {"current_value": 202000, "increase_percentage": 0.05725}, - 37024763: {"current_value": 213000, "increase_percentage": 0.03625}, + 37024763: {"current_value": 213000, "increase_percentage": 0.025}, } @classmethod diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 5c2b28f2..23edd287 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -232,8 +232,7 @@ class Costs: subtotal_before_profit = labour_costs + materials_costs + demolition_plant_costs - # We use high risk contingency for iwi - contingency_cost = subtotal_before_profit * self.HIGH_RISK_CONTINGENCY + contingency_cost = subtotal_before_profit * self.CONTINGENCY preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES profit_cost = subtotal_before_profit * self.PROFIT_MARGIN diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py index 96b1356c..48245554 100644 --- a/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -51,8 +51,9 @@ class FloorRecommendations(Definitions): ] ] + # For solid floor, we don't use materials that are too thick self.solid_floor_insulation_materials = [ - part for part in materials if part["type"] == "solid_floor_insulation" + part for part in materials if part["type"] == "solid_floor_insulation" if float(part["depth"]) <= 75 ] self.solid_floor_non_insulation_materials = [ @@ -142,7 +143,20 @@ class FloorRecommendations(Definitions): @staticmethod def _make_floor_description(material): - return f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation" + + if material["type"] == "suspended_floor_insulation": + return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation in " + f"suspended floor") + + if material["type"] == "solid_floor_insulation": + return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation on " + f"solid floor") + + if material["type"] == "exposed_floor_insulation": + return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation in " + f"exposed floor") + + raise ValueError("Invalid material type - implement me!") def recommend_floor_insulation(self, u_value, insulation_materials, non_insulation_materials): """ @@ -194,7 +208,7 @@ class FloorRecommendations(Definitions): cost_result=cost_result ), ], - "type": "floor_insulation", + "type": material["type"], "description": self._make_floor_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py index 82baf5d6..cd52bea9 100644 --- a/recommendations/LightingRecommendations.py +++ b/recommendations/LightingRecommendations.py @@ -65,7 +65,9 @@ class LightingRecommendations: "description": description, "starting_u_value": None, "new_u_value": None, - "sap_points": None, + # For SAP points, we use the fact that lighting is usually worth 2 points and we scale this to + # the proportion of lights that will be set to low energy + "sap_points": round(2 * (number_non_lel_outlets / number_lighting_outlets), 2), **cost_result } ] diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 0d39c9cf..cdefb6ed 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -128,10 +128,6 @@ class Recommendations: for recommendations_by_type in property_recommendations: for rec in recommendations_by_type: - new_sap = property_sap_predictions[property_sap_predictions["recommendation_id"] == str( - rec["recommendation_id"] - )]["predictions"].values[0] - new_heat_demand = property_heat_predictions[property_heat_predictions["recommendation_id"] == str( rec["recommendation_id"] )]["predictions"].values[0] @@ -140,7 +136,17 @@ class Recommendations: rec["recommendation_id"] )]["predictions"].values[0] - rec["sap_points"] = new_sap - float(property_instance.data["current-energy-efficiency"]) + # We don't use the model for low energy lighting at the moment + if rec["type"] != "low_energy_lighting": + new_sap = property_sap_predictions[property_sap_predictions["recommendation_id"] == str( + rec["recommendation_id"] + )]["predictions"].values[0] + rec["sap_points"] = new_sap - float(property_instance.data["current-energy-efficiency"]) + + if rec["type"] == "mechanical_ventilation": + # For the moment, we cap the number of SAP points that can be achieved by ventilation at 2 + rec["sap_points"] = min(rec["sap_points"], VentilationRecommendations.SAP_LIMIT) + rec["co2_equivalent_savings"] = float(property_instance.data["co2-emissions-current"]) - new_carbon # Energy consumption current is per meter squared, so we need to multiply by the floor area to get diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index 1bee1e8e..07eeb1e5 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -88,17 +88,20 @@ class RoofRecommendations: raise NotImplementedError("Implement me") @staticmethod - def make_loft_insulation_description(material): - return f"Install {int(material['depth'])}{material['depth_unit']} of {material['description']} in your loft" + def make_roof_insulation_description(material): + if material["type"] == "loft_insulation": + return f"Install {int(material['depth'])}{material['depth_unit']} of {material['description']} in your loft" - @staticmethod - def make_room_roof_insulation_description(material, depth): - return f"Insulate your room roof with {depth}{material['depth_unit']} of {material['description']}" + if material["type"] == "flat_roof_insulation": + return ( + f"Insulate the home's flat roof with {int(material['depth'])}{material['depth_unit']} of " + f"{material['description']}" + ) + if material["type"] == "room_roof_insulation": + return (f"Insulate your room roof with {int(material['depth'])}{material['depth_unit']} of " + f"{material['description']}") - @staticmethod - def make_flat_roof_insulation_description(material): - return (f"Insulate the home's flat roof " - f"with {int(material['depth'])}{material['depth_unit']} of {material['description']}") + raise ValueError("Invalid material type") def recommend_roof_insulation( self, u_value, insulation_thickness, roof @@ -182,9 +185,7 @@ class RoofRecommendations: floor_area=self.property.insulation_floor_area, material=material ) - description = self.make_loft_insulation_description(material) elif material["type"] == "flat_roof_insulation": - description = self.make_flat_roof_insulation_description(material) raise ValueError("COMPLETE ME") else: raise ValueError("Invalid material type") @@ -199,8 +200,8 @@ class RoofRecommendations: cost_result=cost_result ) ], - "type": "roof_insulation", - "description": description, + "type": material["type"], + "description": self.make_roof_insulation_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, @@ -297,7 +298,7 @@ class RoofRecommendations: selected_total_cost=estimated_cost ) ], - "type": "roof_insulation", + "type": "room_roof_insulation", "description": self.make_room_roof_insulation_description(material, depth), "starting_u_value": u_value, "new_u_value": new_u_value, diff --git a/recommendations/VentilationRecommendations.py b/recommendations/VentilationRecommendations.py index b42d136f..ef24084f 100644 --- a/recommendations/VentilationRecommendations.py +++ b/recommendations/VentilationRecommendations.py @@ -15,6 +15,9 @@ class VentilationRecommendations(Definitions): 'mechanical, supply and extract' ] + # We introduce a SAP limit, to prevent over-predicting the SAP impact of mechanical ventilation + SAP_LIMIT = 2 + def __init__( self, property_instance: Property, diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py index 17fa4ad4..6e2d64ec 100644 --- a/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -218,8 +218,8 @@ class WallRecommendations(Definitions): cost_result=cost_result ) ], - "type": "wall_insulation", - "description": f"Fill cavity with {material['description']}", + "type": "cavity_wall_insulation", + "description": self._make_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, @@ -263,14 +263,12 @@ class WallRecommendations(Definitions): material=material.to_dict(), non_insulation_materials=non_insulation_materials ) - description = "Install " + self._make_description(material) + " on internal walls" elif material["type"] == "external_wall_insulation": cost_result = self.costs.external_wall_insulation( wall_area=self.property.insulation_wall_area, material=material.to_dict(), non_insulation_materials=non_insulation_materials ) - description = "Install " + self._make_description(material) + " on external walls" else: raise ValueError("Invalid material type") @@ -284,8 +282,8 @@ class WallRecommendations(Definitions): cost_result=cost_result ) ], - "type": "wall_insulation", - "description": description, + "type": material["type"], + "description": self._make_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, @@ -305,7 +303,7 @@ class WallRecommendations(Definitions): # Recommend external and internal wall insulation separately # Since external and internal wall insulation are sufficiently different, # we separate the logic for for recommending them, therefore we don't - # consider diminishing returns between the two + # consider diminishing returns between the two as they are considered to be separate measures ewi_recommendations = [] if self.ewi_valid: @@ -323,25 +321,20 @@ class WallRecommendations(Definitions): self.recommendations += ewi_recommendations + iwi_recommendations - # We remove this temporarily - # self.prune_diminishing_recommendations() - @staticmethod def _make_description(material): - return f"{int(material['depth'])}{material['depth_unit']} {material['description']}" + if material["type"] == "internal_wall_insulation": + return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} on internal " + f"walls") - def prune_diminishing_recommendations(self): - # For any recommendations, if we have at least 1 reommendation that does not exhibit diminishing returns - # we trim all others that are beyond the diminishing returns threshold + if material["type"] == "external_wall_insulation": + return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} on external " + f"walls") - # We first check if we have any recommendations that are not diminishing returns - not_diminishing_return = [ - rec for rec in self.recommendations if rec["new_u_value"] >= self.DIMINISHING_RETURNS_U_VALUE - ] - if not_diminishing_return: - self.recommendations = [ - rec for rec in self.recommendations if rec["new_u_value"] >= self.DIMINISHING_RETURNS_U_VALUE - ] + if material["type"] == "cavity_wall_insulation": + return f"Fill cavity with {material['description']}" + + raise ValueError("Invalid material type") @staticmethod def rvalue_per_mm(total_r_value: float, thickness_mm: float) -> float: diff --git a/recommendations/optimiser/CostOptimiser.py b/recommendations/optimiser/CostOptimiser.py index de5a9e11..80924fd1 100644 --- a/recommendations/optimiser/CostOptimiser.py +++ b/recommendations/optimiser/CostOptimiser.py @@ -9,6 +9,9 @@ class CostOptimiser: This class is used to minimise cost, given a constrained minimum gain """ + # We add an optional buffer to the minimum gain to allow for slack in the optimisation + BUFFER = 0.2 + def __init__(self, components, min_gain): self.components = components self.min_gain = min_gain @@ -20,6 +23,15 @@ class CostOptimiser: self.solution_cost = None self.solution_gain = None + @classmethod + def calculate_sap_gain_with_slack(cls, min_gain): + if min_gain <= 10: + return min_gain + 2 + elif min_gain <= 20: + return min_gain + 3 + else: + return min_gain + 4 + def setup(self): # Initialize Model self.m = Model("knapsack") diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index 03aa38bd..27267186 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -16,18 +16,44 @@ def prepare_input_measures(property_recommendations, goal): if not goal_key: raise NotImplementedError("Not implemented this gain type - investigate me") + ventilation_rec = [rec for rec in property_recommendations if rec[0]["type"] == "mechanical_ventilation"][0] + input_measures = [] for recs in property_recommendations: - input_measures.append( - [ - { - "id": rec["recommendation_id"], - "cost": rec["total"], - "gain": rec[goal_key], - "type": rec["type"] - } - for rec in recs - ] - ) + + # We don't actually optimise ventilation + if recs[0]["type"] == "mechanical_ventilation": + continue + + if recs[0]["type"] == "wall_insulation": + # Wall insulation and mechanical ventilation are paired. You can't have wall insulation without mechanical + # ventilation + + ventilation_cost = ventilation_rec[0]["total"] if ventilation_rec else 0 + ventilation_gain = ventilation_rec[0][goal_key] if ventilation_rec else 0 + + input_measures.append( + [ + { + "id": rec["recommendation_id"], + "cost": rec["total"] + ventilation_cost, + "gain": rec[goal_key] + ventilation_gain, + "type": rec["type"] + } + for rec in recs + ] + ) + else: + input_measures.append( + [ + { + "id": rec["recommendation_id"], + "cost": rec["total"], + "gain": rec[goal_key], + "type": rec["type"] + } + for rec in recs + ] + ) return input_measures From 1f53cb3d44aa812836ddc4568d85a337ddf0ea2d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Nov 2023 16:54:42 +0000 Subject: [PATCH 12/23] debugging after changing recommendation types to more details versions --- backend/app/plan/router.py | 37 ++++++++++++++++--- backend/app/plan/utils.py | 12 +++--- .../optimiser/optimiser_functions.py | 4 +- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 841415b7..1b84ed72 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -261,7 +261,10 @@ async def trigger_plan(body: PlanTriggerRequest): solution = optimiser.solution selected_recommendations = {r["id"] for r in solution} - if "wall_insulation" in [r["type"] for r in solution]: + + if any(x in [r["type"] for r in solution] for x in [ + "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation" + ]): ventilation_rec = [ r for r in recommendations_with_impact if r[0]["type"] == "mechanical_ventilation" ][0] @@ -289,6 +292,7 @@ async def trigger_plan(body: PlanTriggerRequest): recommendations[property_id] = final_recommendations # This is a temporary step, to estimate the impact of the measured on heat demand and carbon + # TODO: This needs to be cleaned up, if it happens to be kept combined_recommendations_scoring_data = [] representative_recs = {} for property_id, property_recommendations in recommendations.items(): @@ -297,6 +301,13 @@ async def trigger_plan(body: PlanTriggerRequest): # Missing types missing_types = list(set([r["type"] for r in property_recommendations if r["type"] not in default_types])) + # We might have a missing type as one of the solid wall options because for a solid wall, you might + # have ewi or iwi but only one of them will be a default + if ("internal_wall_insulation" in default_types) or ("external_wall_insaultion" in default_types): + missing_types = [ + t for t in missing_types if t not in ["internal_wall_insulation", "external_wall_insulation"] + ] + if missing_types: for missed_type in missing_types: missed = [r for r in property_recommendations if r["type"] == missed_type] @@ -393,6 +404,8 @@ async def trigger_plan(body: PlanTriggerRequest): combined_heat_demand["predictions"].values[0] * property_instance.floor_area ) + # We don't want to adjust the heat demand for mechanical ventilation so we add it back on + # We adjust the heat demand figures to align to the UCL paper current_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( epc_energy_consumption=starting_heat_demand, @@ -412,10 +425,6 @@ async def trigger_plan(body: PlanTriggerRequest): heat_demand_change = ( current_adjusted_energy - expected_adjusted_energy ) - property_instance.set_adjusted_energy( - current_adjusted_energy=current_adjusted_energy, - expected_adjusted_energy=expected_adjusted_energy - ) # update the recommendations # We need to totals for the representative recommendations @@ -450,7 +459,13 @@ async def trigger_plan(body: PlanTriggerRequest): # Finally, insert these values into the final recommendations for rec in property_recommendations: - change_data = representative_rec_data[representative_rec_data["type"] == rec["type"]] + if rec["type"] in ["external_wall_insulation", "internal_wall_insulation"]: + change_data = representative_rec_data[ + representative_rec_data["type"].isin(["external_wall_insulation", "internal_wall_insulation"]) + ] + else: + change_data = representative_rec_data[representative_rec_data["type"] == rec["type"]] + if rec["type"] == "mechanical_ventilation": rec["co2_equivalent_savings"] = 0 rec["heat_demand"] = 0 @@ -463,6 +478,16 @@ async def trigger_plan(body: PlanTriggerRequest): # Update recommendations recommendations[property_id] = property_recommendations + # For expected adjust energy, we don't include mechanical ventilation so we'll add it back on + expected_adjusted_energy = expected_adjusted_energy + representative_rec_data[ + representative_rec_data["type"] == "mechanical_ventilation" + ]["heat_demand"].values[0] + + property_instance.set_adjusted_energy( + current_adjusted_energy=current_adjusted_energy, + expected_adjusted_energy=expected_adjusted_energy + ) + # 1) the property data # 2) the property details (epc) # 3) the recommendations diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py index b60bc20f..7aba99c9 100644 --- a/backend/app/plan/utils.py +++ b/backend/app/plan/utils.py @@ -87,7 +87,7 @@ def create_recommendation_scoring_data( scoring_dict[col] = "none" # We update the description to indicate it's insulated - if recommendation["type"] == "wall_insulation": + if recommendation["type"] in ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"]: # The upgrade made here is to the u-value of the walls and the description of the # insulation thickness scoring_dict["walls_thermal_transmittance_ENDING"] = recommendation["new_u_value"] @@ -106,7 +106,7 @@ def create_recommendation_scoring_data( scoring_dict["walls_insulation_thickness_ENDING"] = "none" # Update description to indicate it's insulate - if recommendation["type"] == "floor_insulation": + if recommendation["type"] in ["solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation"]: if len(recommendation["parts"]) > 1: raise NotImplementedError("Have more than 1 floor insulation part - handle this case") @@ -128,7 +128,7 @@ def create_recommendation_scoring_data( if scoring_dict["floor_insulation_thickness_ENDING"] is None: scoring_dict["floor_insulation_thickness_ENDING"] = "none" - if recommendation["type"] == "roof_insulation": + if recommendation["type"] in ["loft_insulation", "room_roof_insulation", "flat_roof_insulation"]: scoring_dict["roof_thermal_transmittance_ENDING"] = recommendation["new_u_value"] parts = recommendation["parts"] @@ -176,8 +176,10 @@ def create_recommendation_scoring_data( scoring_dict["LIGHTING_ENERGY_EFF_STARTING"] = "Very Good" if recommendation["type"] not in [ - "wall_insulation", "floor_insulation", "roof_insulation", "mechanical_ventilation", "sealing_open_fireplace", - "low_energy_lighting" + "mechanical_ventilation", "sealing_open_fireplace", "low_energy_lighting", + "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", + "loft_insulation", "room_roof_insulation", "flat_roof_insulation", + "solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation" ]: raise NotImplementedError("Implement me") diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index 27267186..9b16c6b5 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -25,10 +25,10 @@ def prepare_input_measures(property_recommendations, goal): if recs[0]["type"] == "mechanical_ventilation": continue - if recs[0]["type"] == "wall_insulation": + if recs[0]["type"] in ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"]: # Wall insulation and mechanical ventilation are paired. You can't have wall insulation without mechanical # ventilation - + ventilation_cost = ventilation_rec[0]["total"] if ventilation_rec else 0 ventilation_gain = ventilation_rec[0][goal_key] if ventilation_rec else 0 From 24e22fa56a176b3d324db0445c19c3b1232b201a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 1 Dec 2023 14:50:46 +0000 Subject: [PATCH 13/23] Added pushing of spatial data to backend --- backend/Property.py | 23 ++++++--- .../app/db/functions/property_functions.py | 51 +++++++++++++++++-- backend/app/db/models/portfolio.py | 13 +++++ backend/app/plan/router.py | 5 +- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/backend/Property.py b/backend/Property.py index ddfb9445..a9d7645d 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -45,7 +45,7 @@ class Property(Definitions): windows = None lighting = None - coordinates = None + spatial = None def __init__(self, id, postcode, address1, epc_client=None, data=None): self.id = id @@ -129,13 +129,6 @@ class Property(Definitions): else: self.uprn = int(self.data["uprn"]) - def set_coordinates(self, coordinates): - """ - This method sets the coordinates of the property, given the open uprn data - :param coordinates: dictionary - """ - self.coordinates = {key.lower(): value for key, value in coordinates.items()} - def set_energy(self): """ Extracts and formats data about the home's energy and co2 consumption @@ -364,6 +357,9 @@ class Property(Definitions): def set_spatial(self, spatial: pd.DataFrame): """ Sets whether the property is in a conservation area given the output of the ConservationAreaClient + + Will store a dictionary, spatial, which is used to populate the property spatial table in the database + :param spatial: Dataframe, containing the spatial data for the property """ self.in_conservation_area = spatial["conservation_status"].values[0] @@ -373,6 +369,17 @@ class Property(Definitions): if self.in_conservation_area is True | self.is_listed is True | self.is_heritage is True: self.restricted_measures = True + spatial_dict = spatial.to_dict("records")[0] + self.spatial = { + "x_coordinate": spatial_dict["X_COORDINATE"], + "y_coordinate": spatial_dict["Y_COORDINATE"], + "latitude": spatial_dict["LATITUDE"], + "longitude": spatial_dict["LONGITUDE"], + "conservation_status": spatial_dict["conservation_status"], + "is_listed_building": spatial_dict["is_listed_building"], + "is_heritage_building": spatial_dict["is_heritage_building"], + } + def set_year_built(self): """ Estimates when the property was built based on as much available data as possible. diff --git a/backend/app/db/functions/property_functions.py b/backend/app/db/functions/property_functions.py index ecad3ab7..93dc0c49 100644 --- a/backend/app/db/functions/property_functions.py +++ b/backend/app/db/functions/property_functions.py @@ -3,13 +3,15 @@ ### import datetime import pytz +from sqlalchemy.orm import Session from backend.app.db.models.portfolio import ( - PropertyModel, PropertyCreationStatus, PortfolioStatus, PropertyTargetsModel, PropertyDetailsEpcModel + PropertyModel, PropertyCreationStatus, PortfolioStatus, PropertyTargetsModel, PropertyDetailsEpcModel, + PropertyDetailsSpatial ) from sqlalchemy.orm.exc import NoResultFound -def create_property(session, portfolio_id: int, address: str, postcode: str) -> (int, bool): +def create_property(session: Session, portfolio_id: int, address: str, postcode: str) -> (int, bool): """ This function will create a record for the property in the database if it does not exist. If it does exist, it will just update the updated_at field. @@ -55,7 +57,9 @@ def create_property(session, portfolio_id: int, address: str, postcode: str) -> return new_property.id, True -def create_property_targets(session, property_id: int, portfolio_id: int, epc_target=None, heat_demand_target=None): +def create_property_targets( + session: Session, property_id: int, portfolio_id: int, epc_target=None, heat_demand_target=None +): """ This function will create a record for the property targets in the database if it does not exist. :param session: The database session @@ -78,7 +82,9 @@ def create_property_targets(session, property_id: int, portfolio_id: int, epc_ta return True -def update_property_data(session, property_id: int, portfolio_id: int, property_data: dict): +def update_property_data( + session: Session, property_id: int, portfolio_id: int, property_data: dict +): now = datetime.datetime.now(pytz.utc) try: @@ -103,7 +109,9 @@ def update_property_data(session, property_id: int, portfolio_id: int, property_ return True -def create_property_details_epc(session, property_details_epc: dict): +def create_property_details_epc( + session: Session, property_details_epc: dict +): """ This function will create or update a record for the property details EPC in the database. :param session: The database session @@ -128,3 +136,36 @@ def create_property_details_epc(session, property_details_epc: dict): session.flush() return True + + +def update_or_create_property_spatial_details(session: Session, uprn: int, property_details_spatial: dict): + """ + Update an existing property details record or create a new one based on the UPRN. + + :param session: The SQLAlchemy session for database interaction. + :param uprn: The unique property reference number (UPRN) of the property. + :param property_details_spatial: A dictionary containing the spatial property details to store or update. + :return: True if the operation is successful, otherwise raises an exception. + """ + + try: + # Attempt to fetch the existing property details + existing_property_details = session.query(PropertyDetailsSpatial).filter_by( + uprn=uprn + ).one() + + # Update the fields with the data in property_details + for key, value in property_details_spatial.items(): + setattr(existing_property_details, key, value) + + # Merge the updated property details back into the session and flush + session.merge(existing_property_details) + session.flush() + + except NoResultFound: + # Create a new record if not found + new_property_details = PropertyDetailsSpatial(uprn=uprn, **property_details_spatial) + session.add(new_property_details) + session.flush() + + return True diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py index ab047477..6f865381 100644 --- a/backend/app/db/models/portfolio.py +++ b/backend/app/db/models/portfolio.py @@ -155,6 +155,19 @@ class PropertyDetailsEpcModel(Base): adjusted_energy_consumption = Column(Float) +class PropertyDetailsSpatial(Base): + __tablename__ = "property_details_spatial" + id = Column(Integer, primary_key=True, autoincrement=True) + uprn = Column(Integer, nullable=False) + x_coordinate = Column(Float) + y_coordinate = Column(Float) + latitude = Column(Float) + longitude = Column(Float) + conservation_status = Column(Boolean) + is_listed_building = Column(Boolean) + is_heritage_building = Column(Boolean) + + class PropertyDetailsMeter(Base): __tablename__ = 'property_details_meter' id = Column(Integer, primary_key=True, autoincrement=True) diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 1b84ed72..2277a6b4 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -13,7 +13,8 @@ from backend.app.db.connection import db_engine from backend.app.db.functions.materials_functions import get_materials from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations from backend.app.db.functions.property_functions import ( - create_property, create_property_details_epc, create_property_targets, update_property_data + create_property, create_property_details_epc, create_property_targets, update_property_data, + update_or_create_property_spatial_details ) from backend.app.db.functions.recommendations_functions import ( create_plan, create_plan_recommendations, upload_recommendations @@ -507,6 +508,8 @@ async def trigger_plan(body: PlanTriggerRequest): ) create_property_details_epc(session, property_details_epc) + update_or_create_property_spatial_details(session, p.uprn, p.spatial) + # TODO: TEMP if p.data["uprn"] == "": print("Get rid of me!") From 036f3c562e7bd2a99cf6a167a79a3be7e2459011 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 11:05:09 +0000 Subject: [PATCH 14/23] targeting optimiser movement requirements --- backend/app/plan/router.py | 29 ++++------- backend/app/utils.py | 4 +- recommendations/optimiser/CostOptimiser.py | 15 ++++-- .../optimiser/optimiser_functions.py | 48 +++++-------------- 4 files changed, 32 insertions(+), 64 deletions(-) diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 2277a6b4..42014bb3 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -81,17 +81,12 @@ async def trigger_plan(body: PlanTriggerRequest): if not is_new: continue # TODO: Need to add heat demand target - # TODO: Temp for Keyzy - if config['address'] == "25 Albert Street": - epc_target = "C" - else: - epc_target = body.goal_value create_property_targets( session, property_id=property_id, portfolio_id=body.portfolio_id, - epc_target=epc_target, + epc_target=body.goal_value, heat_demand_target=None ) @@ -242,14 +237,7 @@ async def trigger_plan(body: PlanTriggerRequest): else: # The minimum gain is the minimum number of SAP points required to get to the target SAP band current_sap_points = int(property_instance.data["current-energy-efficiency"]) - - # TODO: TEMP - if property_instance.address1 == "25 Albert Street": - opt_epc_target = "C" - else: - opt_epc_target = body.goal_value - - target_sap_points = epc_to_sap_lower_bound(opt_epc_target) + target_sap_points = epc_to_sap_lower_bound(body.goal_value) # If the gain is negative, the optimiser will return an empty solution optimiser = CostOptimiser( @@ -263,6 +251,7 @@ async def trigger_plan(body: PlanTriggerRequest): selected_recommendations = {r["id"] for r in solution} + # If wall ventilation is selected, we also include mechanical ventilation as a best practice measure if any(x in [r["type"] for r in solution] for x in [ "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation" ]): @@ -413,14 +402,14 @@ async def trigger_plan(body: PlanTriggerRequest): current_epc_rating=property_instance.data["current-energy-rating"], ) - print("Hardcoded B - fix me") - if property_instance.address1 == "25 Albert Street": - hardcoded_expected_epc = "C" - else: - hardcoded_expected_epc = "B" + # We sum up the SAP points of the default recommendations and calculate a new EPC category. This + # category is then used to produce adjusted energy figures + total_sap_points = sum([x["sap_points"] for x in representative_recs[property_id]]) + expected_epc = sap_to_epc(float(property_instance.data["current-energy-efficiency"]) + total_sap_points) + expected_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( epc_energy_consumption=expected_heat_demand, - current_epc_rating=hardcoded_expected_epc, + current_epc_rating=expected_epc, ) heat_demand_change = ( diff --git a/backend/app/utils.py b/backend/app/utils.py index b4ba1bb9..d912a94a 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -69,10 +69,10 @@ def generate_api_key(): return api_key -def sap_to_epc(sap_points: int): +def sap_to_epc(sap_points: int | float): """ Simple utility function to convert SAP points to EPC rating. - :param sapPoints: numerical value of SAP points, typically between 0 and 100 + :param sap_points: numerical value of SAP points, typically between 0 and 100 :return: """ diff --git a/recommendations/optimiser/CostOptimiser.py b/recommendations/optimiser/CostOptimiser.py index 80924fd1..622d5b47 100644 --- a/recommendations/optimiser/CostOptimiser.py +++ b/recommendations/optimiser/CostOptimiser.py @@ -24,13 +24,18 @@ class CostOptimiser: self.solution_gain = None @classmethod - def calculate_sap_gain_with_slack(cls, min_gain): - if min_gain <= 10: - return min_gain + 2 + def calculate_sap_gain_with_slack(cls, min_gain: int | float): + """ + Adds a small amount of buffer to the minimum gain, to account for possible error in SAP predictions + :param min_gain: Numerical value for the minimum gain + :return: + """ + if min_gain <= 5: + return min_gain + 0.5 elif min_gain <= 20: - return min_gain + 3 + return min_gain + 1.5 else: - return min_gain + 4 + return min_gain + 2 def setup(self): # Initialize Model diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index 9b16c6b5..03aa38bd 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -16,44 +16,18 @@ def prepare_input_measures(property_recommendations, goal): if not goal_key: raise NotImplementedError("Not implemented this gain type - investigate me") - ventilation_rec = [rec for rec in property_recommendations if rec[0]["type"] == "mechanical_ventilation"][0] - input_measures = [] for recs in property_recommendations: - - # We don't actually optimise ventilation - if recs[0]["type"] == "mechanical_ventilation": - continue - - if recs[0]["type"] in ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"]: - # Wall insulation and mechanical ventilation are paired. You can't have wall insulation without mechanical - # ventilation - - ventilation_cost = ventilation_rec[0]["total"] if ventilation_rec else 0 - ventilation_gain = ventilation_rec[0][goal_key] if ventilation_rec else 0 - - input_measures.append( - [ - { - "id": rec["recommendation_id"], - "cost": rec["total"] + ventilation_cost, - "gain": rec[goal_key] + ventilation_gain, - "type": rec["type"] - } - for rec in recs - ] - ) - else: - input_measures.append( - [ - { - "id": rec["recommendation_id"], - "cost": rec["total"], - "gain": rec[goal_key], - "type": rec["type"] - } - for rec in recs - ] - ) + input_measures.append( + [ + { + "id": rec["recommendation_id"], + "cost": rec["total"], + "gain": rec[goal_key], + "type": rec["type"] + } + for rec in recs + ] + ) return input_measures From 43a21965804b797e97d88e95e7bac4751d756a2d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 16:58:34 +0000 Subject: [PATCH 15/23] Fixing costs unit tests --- .idea/Model.iml | 2 +- .idea/misc.xml | 2 +- recommendations/Costs.py | 6 +- recommendations/tests/test_costs.py | 131 +++++++++++++++------------- 4 files changed, 77 insertions(+), 64 deletions(-) diff --git a/.idea/Model.iml b/.idea/Model.iml index 4413bb06..b0f9c00d 100644 --- a/.idea/Model.iml +++ b/.idea/Model.iml @@ -7,7 +7,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 6f308057..1122b380 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 23edd287..3fe0e560 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -40,6 +40,10 @@ class Costs: # We assume a conservative 10% contingency for all works which is a rate defined by SPONs CONTINGENCY = 0.1 + # We use a higher contingency rate for internal wall insulation because of the potential for issues with moving + # fittings and trimming doors, as well as scope for damage to the existing wall during preparation. + IWI_CONTINGENCY = 0.15 + # Where there is more uncertainty, a higher contingency rate is used HIGH_RISK_CONTINGENCY = 0.2 # When there is less uncertainty, a lower contingency rate is used @@ -232,7 +236,7 @@ class Costs: subtotal_before_profit = labour_costs + materials_costs + demolition_plant_costs - contingency_cost = subtotal_before_profit * self.CONTINGENCY + contingency_cost = subtotal_before_profit * self.IWI_CONTINGENCY preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES profit_cost = subtotal_before_profit * self.PROFIT_MARGIN diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py index 1ba601a8..2854b298 100644 --- a/recommendations/tests/test_costs.py +++ b/recommendations/tests/test_costs.py @@ -19,7 +19,7 @@ class TestCosts: "prime_cost": 5.17, "material_cost": 5.62, "labour_cost": 1.125, - "labour_hours": 0.065 + "labour_hours_per_unit": 0.065, } cwi_results = costs.cavity_wall_insulation( @@ -27,10 +27,12 @@ class TestCosts: material=cwi_material, ) - assert cwi_results == {'total': 1027.0280465530302, 'subtotal': 855.8567054608585, 'vat': 171.1713410921717, - 'contingency': 63.396792997100626, 'preliminaries': 63.396792997100626, - 'material': 539.0166061175574, 'profit': 95.09518949565093, - 'labour_hours': 6.234177828761786, 'labour_cost': 94.95132385344874} + assert cwi_results == { + 'total': 1065.0661223512907, 'subtotal': 887.5551019594088, 'vat': 177.51102039188177, + 'contingency': 63.396792997100626, 'preliminaries': 63.396792997100626, 'material': 539.0166061175574, + 'profit': 126.79358599420125, 'labour_hours': 6.234177828761786, 'labour_cost': 94.95132385344874, + 'labour_days': 0.38963611429761164 + } def test_loft_insulation(self): mock_property = Mock() @@ -46,7 +48,7 @@ class TestCosts: "prime_cost": None, "material_cost": 5.91938, "labour_cost": 1.96, - "labour_hours": 0.11 + "labour_hours_per_unit": 0.11 } loft_results = costs.loft_insulation( @@ -54,10 +56,11 @@ class TestCosts: material=loft_material, ) - assert loft_results == {'total': 414.8496486, 'subtotal': 345.70804050000004, 'vat': 69.14160810000001, - 'contingency': 25.608003000000004, 'preliminaries': 25.608003000000004, - 'material': 198.29923000000002, 'profit': 38.4120045, 'labour_hours': 3.685, - 'labour_cost': 57.7808} + assert loft_results == { + 'total': 430.21445040000003, 'subtotal': 358.512042, 'vat': 71.70240840000001, + 'contingency': 25.608003000000004, 'preliminaries': 25.608003000000004, 'material': 198.29923000000002, + 'profit': 51.21600600000001, 'labour_hours': 3.685, 'labour_cost': 57.7808, 'labour_days': 0.460625 + } def test_internal_wall_insulation(self): mock_property = Mock() @@ -171,11 +174,14 @@ class TestCosts: non_insulation_materials=iwi_non_insulation_materials ) - assert iwi_results == {'total': 6421.5484411659245, 'subtotal': 5351.29036763827, 'vat': 1070.258073527654, - 'contingency': 573.3525393898148, 'preliminaries': 382.2350262598765, - 'material': 1747.488000615996, 'profit': 573.3525393898148, - 'labour_hours': 88.23759388401297, 'labour_days': 2.757424808875405, - 'labour_cost': 1927.1602026551818} + assert iwi_results == { + 'total': 6650.889456921851, 'subtotal': 5542.407880768209, 'vat': 1108.4815761536418, + 'contingency': 573.3525393898148, 'preliminaries': 382.2350262598765, + 'material': 1747.488000615996, + 'profit': 764.470052519753, 'labour_hours': 88.23759388401297, + 'labour_days': 2.757424808875405, + 'labour_cost': 1927.1602026551818 + } def test_suspended_floor_insulation(self): mock_property = Mock() @@ -185,16 +191,18 @@ class TestCosts: costs = Costs(mock_property) - sus_floor_material = {'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', - 'depth': 140.0, - 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.039, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0, - 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, - 'plant_cost': 0, - 'total_cost': 13.46, 'link': 'SPONs', - 'Notes': 'Spons did not contain labour costs so we use values for similar insulations. ' - 'We use the ' - 'same values as in Crown loft roll 44, since it is also an insulation roll'} + sus_floor_material = { + 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', + 'depth': 140.0, + 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0, + 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, + 'plant_cost': 0, + 'total_cost': 13.46, 'link': 'SPONs', + 'Notes': 'Spons did not contain labour costs so we use values for similar insulations. ' + 'We use the ' + 'same values as in Crown loft roll 44, since it is also an insulation roll' + } sus_floor_non_insulation_materials = [ {'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0, @@ -231,9 +239,8 @@ class TestCosts: ) assert sus_floor_results == { - 'total': 3003.366924, 'subtotal': 2502.80577, 'vat': 500.561154, - 'contingency': 185.39302, 'preliminaries': 185.39302, 'material': 483.405, - 'profit': 278.08952999999997, 'labour_hours': 54.940000000000005, + 'total': 3114.6027360000003, 'subtotal': 2595.50228, 'vat': 519.100456, 'contingency': 185.39302, + 'preliminaries': 185.39302, 'material': 483.405, 'profit': 370.78604, 'labour_hours': 54.940000000000005, 'labour_days': 2.289166666666667, 'labour_cost': 1370.5252 } @@ -263,28 +270,29 @@ class TestCosts: 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, - 'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, {'type': 'solid_floor_preparation', - 'description': 'Clean out crack to ' - 'form a 20mm×20mm ' - 'groove and fill with ' - 'cement: mortar mixed ' - 'with bonding agent', - 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, - 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, - 'material_cost': 6.91, - 'labour_cost': 18.99, - 'labour_hours_per_unit': 0.61, - 'plant_cost': 0.16, - 'total_cost': 26.06, 'link': 0, - 'Notes': 'This step is the ' - 'assessment and repair of ' - 'any damage to the concrete ' - 'floor such as filling ' - 'cracks or levelling uneven ' - 'areas'}, + 'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, { + 'type': 'solid_floor_preparation', + 'description': 'Clean out crack to ' + 'form a 20mm×20mm ' + 'groove and fill with ' + 'cement: mortar mixed ' + 'with bonding agent', + 'depth': 0, 'depth_unit': 0, + 'cost_unit': 0, + 'thermal_conductivity': 0, + 'thermal_conductivity_unit': 0, + 'prime_material_cost': 0, + 'material_cost': 6.91, + 'labour_cost': 18.99, + 'labour_hours_per_unit': 0.61, + 'plant_cost': 0.16, + 'total_cost': 26.06, 'link': 0, + 'Notes': 'This step is the ' + 'assessment and repair of ' + 'any damage to the concrete ' + 'floor such as filling ' + 'cracks or levelling uneven ' + 'areas'}, {'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, @@ -316,8 +324,8 @@ class TestCosts: ) assert sol_floor_results == { - 'total': 3962.021952, 'subtotal': 3301.68496, 'vat': 660.336992, 'contingency': 353.75196, - 'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 353.75196, 'labour_hours': 57.285, + 'total': 4245.023520000001, 'subtotal': 3537.5196, 'vat': 707.5039200000001, 'contingency': 471.66928, + 'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 471.66928, 'labour_hours': 57.285, 'labour_days': 2.386875, 'labour_cost': 1346.6464 } @@ -331,11 +339,13 @@ class TestCosts: costs = Costs(mock_property) - ewi_material = {'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', - 'depth': 150.0, 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 23.53, - 'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0, - 'total_cost': 67.68, 'link': 'SPONs', 'Notes': 0} + ewi_material = { + 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', + 'depth': 150.0, 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 23.53, + 'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0, + 'total_cost': 67.68, 'link': 'SPONs', 'Notes': 0 + } ewi_non_insulation_materials = [ {'type': 'ewi_wall_demolition', 'description': 'Solid & Dry Lined walls: Hack of wall finishes with chipping ' @@ -403,9 +413,8 @@ class TestCosts: ) assert ewi_results == { - 'total': 13590.909723215433, 'subtotal': 11325.758102679527, 'vat': 2265.1516205359053, - 'contingency': 808.9827216199662, 'preliminaries': 1213.4740824299492, - 'material': 4020.565147410677, 'profit': 1213.4740824299492, - 'labour_hours': 187.02533486285358, 'labour_days': 5.8445417144641745, + 'total': 14561.688989159393, 'subtotal': 12134.740824299493, 'vat': 2426.948164859899, + 'contingency': 808.9827216199662, 'preliminaries': 1617.9654432399325, 'material': 4020.565147410677, + 'profit': 1617.9654432399325, 'labour_hours': 187.02533486285358, 'labour_days': 5.8445417144641745, 'labour_cost': 3921.5600094613983 } From 10f336188501fef57d7dd2a263d19f23b38eba14 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 17:02:28 +0000 Subject: [PATCH 16/23] Fixed property unit tests --- backend/tests/test_property.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/tests/test_property.py b/backend/tests/test_property.py index b376db9e..d8519b6b 100644 --- a/backend/tests/test_property.py +++ b/backend/tests/test_property.py @@ -12,6 +12,7 @@ mock_epc_response = { "uprn": 1, "number-habitable-rooms": 5, "property-type": "House", + "built-form": "Detached", "inspection-date": "2023-06-01", 'lodgement-datetime': '2023-06-01 20:29:01', "some-other-key": "some-value", @@ -42,6 +43,7 @@ mock_epc_response = { "uprn": 2, "number-habitable-rooms": 5, "property-type": "House", + "built-form": "Detached", "inspection-date": "2023-05-01", 'lodgement-datetime': '2023-05-01 20:29:01', "some-other-key": "some-other-value", From 1b39147b57d59f39aedca4819cd6befd3715d494 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 17:28:51 +0000 Subject: [PATCH 17/23] Fixed test_sap_model_prep --- backend/Property.py | 5 ++++- backend/tests/test_sap_model_prep.py | 27 +++++++++++++++------------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/backend/Property.py b/backend/Property.py index a9d7645d..c04a3ed9 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -4,7 +4,7 @@ import os import pandas as pd from etl.epc.DataProcessor import DataProcessor -from etl.epc.settings import POTENTIAL_COLUMNS, EFFICIENCY_FEATURES +from etl.epc.settings import POTENTIAL_COLUMNS, EFFICIENCY_FEATURES, BUILT_FORM_REMAP from etl.epc_clean.epc_attributes.all_cleaners import all_cleaner_map from utils.logger import setup_logger from utils.s3 import read_dataframe_from_s3_parquet @@ -271,6 +271,9 @@ class Property(Definitions): if not self.data: raise ValueError("Property does not contain data") + # We need to implement an EPC cleaning process, which we run on the EPC data, immediately after we download + # it + self.data["built-form"] = BUILT_FORM_REMAP.get(self.data["built-form"], self.data["built-form"]) self.set_energy() self.set_ventilation() self.set_solar_pv() diff --git a/backend/tests/test_sap_model_prep.py b/backend/tests/test_sap_model_prep.py index 887e8e6e..f20e4993 100644 --- a/backend/tests/test_sap_model_prep.py +++ b/backend/tests/test_sap_model_prep.py @@ -204,9 +204,9 @@ class TestSapModelPrep: 'external_insulation': False, 'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 0.7, 'is_park_home_ENDING': False, 'walls_insulation_thickness_ENDING': 'average', 'external_insulation_ENDING': False, 'internal_insulation_ENDING': False, - 'floor_thermal_transmittance': 0.64, 'is_to_unheated_space': False, 'is_to_external_air': False, + 'floor_thermal_transmittance': 0.52, 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': True, 'is_solid': False, 'another_property_below': False, - 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.64, + 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.52, 'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 1.5, 'is_pitched': True, 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'has_dwelling_above': False, 'roof_insulation_thickness': 'below average', @@ -260,7 +260,7 @@ class TestSapModelPrep: 'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown', 'fuel_type_ENDING': 'oil', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False, 'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown', - 'estimated_perimeter_STARTING': 44.77882152472145, 'estimated_perimeter_ENDING': 44.77882152472145, + 'estimated_perimeter_STARTING': 30.531014675946444, 'estimated_perimeter_ENDING': 30.531014675946444, 'HOT_WATER_ENERGY_EFF_STARTING': "Good", "FLOOR_ENERGY_EFF_STARTING": "Unknown", "WINDOWS_ENERGY_EFF_STARTING": "Good", @@ -310,7 +310,7 @@ class TestSapModelPrep: recommendation = { "recommendation_id": 0, "new_u_value": 0.7, - "type": "wall_insulation" + "type": "cavity_wall_insulation" } test_record = create_recommendation_scoring_data( @@ -356,7 +356,7 @@ class TestSapModelPrep: assert test_record[c].values[0] == row[c] - def test_solid_wall_insulation(self, cleaned, cleaning_data): + def test_internal_wall_insulation(self, cleaned, cleaning_data): starting_epc2 = { 'low-energy-fixed-light-count': '2', 'address': 'FLAT 12, WAREHOUSE W, 3 WESTERN GATEWAY', @@ -513,6 +513,7 @@ class TestSapModelPrep: data=starting_epc2 ) home2.get_components(cleaned) + home2.set_number_lighting_outlets(None) data_processor2 = DataProcessor(None, newdata=True) data_processor2.insert_data(pd.DataFrame([home2.get_model_data()])) @@ -530,7 +531,7 @@ class TestSapModelPrep: recommendation2 = { "recommendation_id": 0, "new_u_value": 0.21, - "type": "wall_insulation" + "type": "internal_wall_insulation" } test_record2 = create_recommendation_scoring_data( @@ -644,9 +645,9 @@ class TestSapModelPrep: 'is_park_home': False, 'walls_insulation_thickness': 'none', 'external_insulation': False, 'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 2.0, 'is_park_home_ENDING': False, 'walls_insulation_thickness_ENDING': 'none', 'external_insulation_ENDING': False, - 'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.62, 'is_to_unheated_space': False, + 'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.51, 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': True, 'is_solid': False, 'another_property_below': False, - 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.62, + 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.51, 'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 2.3, 'is_pitched': True, 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'has_dwelling_above': False, 'roof_insulation_thickness': 'none', 'roof_thermal_transmittance_ENDING': 2.3, @@ -699,7 +700,7 @@ class TestSapModelPrep: 'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown', 'fuel_type_ENDING': 'mains gas', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False, 'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown', - 'estimated_perimeter_STARTING': 41.634120622393354, 'estimated_perimeter_ENDING': 41.634120622393354, + 'estimated_perimeter_STARTING': 30.06908711617298, 'estimated_perimeter_ENDING': 30.06908711617298, 'HOT_WATER_ENERGY_EFF_STARTING': "Good", "FLOOR_ENERGY_EFF_STARTING": "Unknown", "WINDOWS_ENERGY_EFF_STARTING": "Average", @@ -732,6 +733,7 @@ class TestSapModelPrep: data=starting_epc3 ) home3.get_components(cleaned) + home3.set_number_lighting_outlets(None) data_processor3 = DataProcessor(None, newdata=True) data_processor3.insert_data(pd.DataFrame([home3.get_model_data()])) @@ -851,9 +853,9 @@ class TestSapModelPrep: 'is_park_home': False, 'walls_insulation_thickness': 'none', 'external_insulation': False, 'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 1.7, 'is_park_home_ENDING': False, 'walls_insulation_thickness_ENDING': 'none', 'external_insulation_ENDING': False, - 'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.66, 'is_to_unheated_space': False, + 'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.53, 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': True, 'another_property_below': False, - 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.66, + 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.53, 'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 0.21, 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'has_dwelling_above': False, 'roof_insulation_thickness': '200', 'roof_thermal_transmittance_ENDING': 0.21, @@ -907,7 +909,7 @@ class TestSapModelPrep: 'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown', 'fuel_type_ENDING': 'mains gas', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False, 'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown', - 'estimated_perimeter_STARTING': 37.54197650630557, 'estimated_perimeter_ENDING': 37.54197650630557, + 'estimated_perimeter_STARTING': 27.113649698998472, 'estimated_perimeter_ENDING': 27.113649698998472, 'HOT_WATER_ENERGY_EFF_STARTING': "Good", "FLOOR_ENERGY_EFF_STARTING": "Unknown", "WINDOWS_ENERGY_EFF_STARTING": "Average", @@ -940,6 +942,7 @@ class TestSapModelPrep: data=starting_epc4 ) home4.get_components(cleaned) + home4.set_number_lighting_outlets(None) data_processor4 = DataProcessor(None, newdata=True) data_processor4.insert_data(pd.DataFrame([home4.get_model_data()])) From c31619451fc169211e47e986730641ee43b01008 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 17:32:00 +0000 Subject: [PATCH 18/23] fixed fireplace tests --- recommendations/tests/test_fireplace_recommendations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/recommendations/tests/test_fireplace_recommendations.py b/recommendations/tests/test_fireplace_recommendations.py index a1e0c1c6..570fbb5c 100644 --- a/recommendations/tests/test_fireplace_recommendations.py +++ b/recommendations/tests/test_fireplace_recommendations.py @@ -37,7 +37,7 @@ class TestFirepaceRecommendations: assert recommender.recommendation assert recommender.recommendation[0]["type"] == "sealing_open_fireplace" - assert recommender.recommendation[0]["cost"] == 300 + assert recommender.recommendation[0]["total"] == 300 def test_multiple_fireplaces(self): property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) @@ -55,4 +55,4 @@ class TestFirepaceRecommendations: assert recommender.recommendation assert recommender.recommendation[0]["type"] == "sealing_open_fireplace" - assert recommender.recommendation[0]["cost"] == 900 + assert recommender.recommendation[0]["total"] == 900 From 5efa6ea59dd1aba8dae68f6495a78ab7ff0bb5bd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 18:20:53 +0000 Subject: [PATCH 19/23] fixed wall unit tests --- recommendations/Costs.py | 16 +- recommendations/county_to_region.py | 172 ++++ recommendations/tests/test_data/materials.py | 835 ++++++++++++++++++ .../tests/test_wall_recommendations.py | 343 ++----- 4 files changed, 1093 insertions(+), 273 deletions(-) create mode 100644 recommendations/county_to_region.py create mode 100644 recommendations/tests/test_data/materials.py diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 3fe0e560..e896e1b5 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -1,27 +1,23 @@ import numpy as np +from recommendations.county_to_region import county_to_region_map -# This data comes from SPONs +# This data comes from SPONs 2023 regional_labour_variations = [ - {"Region": "Outer London (Spon’s 2023)", "Adjustment_Factor": 1.00}, + {"Region": "Outer London", "Adjustment_Factor": 1.00}, {"Region": "Inner London", "Adjustment_Factor": 1.05}, {"Region": "South East", "Adjustment_Factor": 0.96}, {"Region": "South West", "Adjustment_Factor": 0.90}, {"Region": "East of England", "Adjustment_Factor": 0.93}, {"Region": "East Midlands", "Adjustment_Factor": 0.88}, {"Region": "West Midlands", "Adjustment_Factor": 0.87}, - {"Region": "North East", "Adjustment_Factor": 0.83}, - {"Region": "North West", "Adjustment_Factor": 0.88}, + {"Region": "North East England", "Adjustment_Factor": 0.83}, + {"Region": "North West England", "Adjustment_Factor": 0.88}, {"Region": "Yorkshire and Humberside", "Adjustment_Factor": 0.86}, {"Region": "Wales", "Adjustment_Factor": 0.88}, {"Region": "Scotland", "Adjustment_Factor": 0.88}, {"Region": "Northern Ireland", "Adjustment_Factor": 0.76} ] -county_map = { - "Northamptonshire": "East Midlands", - "Hampshire": "South East", -} - class Costs: """ @@ -75,7 +71,7 @@ class Costs: self.property = property_instance self.regional_labour_variations = regional_labour_variations - self.county = county_map.get(self.property.data["county"], None) + self.county = county_to_region_map.get(self.property.data["county"], None) if self.county is None: raise ValueError("County not found in county map") diff --git a/recommendations/county_to_region.py b/recommendations/county_to_region.py new file mode 100644 index 00000000..3379247f --- /dev/null +++ b/recommendations/county_to_region.py @@ -0,0 +1,172 @@ +# This map was found here: +# https://gist.github.com/radiac/d91d2ed1b971c03d49e9b7bd85e23f1c#file-uk-counties-to-regions-csv +county_to_region_map = { + 'Guernsey': 'Crown Dependencies', 'IOM': 'Crown Dependencies', 'Jersey': 'Crown Dependencies', + 'North East Derbyshire': 'East Midlands', 'Amber Valley': 'East Midlands', 'Ashfield': 'East Midlands', + 'Bassetlaw': 'East Midlands', 'Blaby': 'East Midlands', 'Bolsover': 'East Midlands', 'Boston': 'East Midlands', + 'Broxtowe': 'East Midlands', 'Charnwood': 'East Midlands', 'Chesterfield': 'East Midlands', + 'Corby': 'East Midlands', 'Daventry': 'East Midlands', 'Derby': 'East Midlands', 'Derbyshire': 'East Midlands', + 'Derbyshire Dales': 'East Midlands', 'East Lindsey': 'East Midlands', 'East Northamptonshire': 'East Midlands', + 'Erewash': 'East Midlands', 'Gedling': 'East Midlands', 'Harborough': 'East Midlands', 'High Peak': 'East Midlands', + 'Hinckley and Bosworth': 'East Midlands', 'Kettering': 'East Midlands', 'Leicester': 'East Midlands', + 'Leicestershire': 'East Midlands', 'Lincoln': 'East Midlands', 'Lincolnshire': 'Yorkshire and the Humber', + 'Mansfield': 'East Midlands', 'Melton': 'East Midlands', 'Newark and Sherwood': 'East Midlands', + 'North Kesteven': 'East Midlands', 'North West Leicestershire': 'East Midlands', 'Northampton': 'East Midlands', + 'Northamptonshire': 'East Midlands', 'Nottingham': 'East Midlands', 'Nottinghamshire': 'East Midlands', + 'Oadby and Wigston': 'East Midlands', 'Rushcliffe': 'East Midlands', 'Rutland': 'East Midlands', + 'South Derbyshire': 'East Midlands', 'South Holland': 'East Midlands', 'South Kesteven': 'East Midlands', + 'South Northamptonshire': 'East Midlands', 'Wellingborough': 'East Midlands', 'West Lindsey': 'East Midlands', + 'Babergh': 'East of England', 'Basildon': 'East of England', 'Bedford': 'East of England', + 'Bedford Borough': 'East of England', 'Bedfordshire': 'East of England', 'Braintree': 'East of England', + 'Breckland': 'East of England', 'Brentwood': 'East of England', 'Broadland': 'East of England', + 'Broxbourne': 'East of England', 'Cambridge': 'East of England', 'Cambridgeshire': 'East of England', + 'Castle Point': 'East of England', 'Central Bedfordshire': 'East of England', 'Chelmsford': 'East of England', + 'Colchester': 'East of England', 'Dacorum': 'East of England', 'East Cambridgeshire': 'East of England', + 'East Hertfordshire': 'East of England', 'Epping Forest': 'East of England', 'Essex': 'East of England', + 'Fenland': 'East of England', 'Forest Heath': 'East of England', 'Great Yarmouth': 'East of England', + 'Harlow': 'East of England', 'Hertfordshire': 'East of England', 'Hertsmere': 'East of England', + 'Huntingdonshire': 'East of England', 'Ipswich': 'East of England', + "King's Lynn and West Norfolk": 'East of England', 'Luton': 'East of England', 'Maldon': 'East of England', + 'Mid Suffolk': 'East of England', 'Norfolk': 'East of England', 'North Hertfordshire': 'East of England', + 'North Norfolk': 'East of England', 'Norwich': 'East of England', 'Peterborough': 'East of England', + 'Rochford': 'East of England', 'South Cambridgeshire': 'East of England', 'South Norfolk': 'East of England', + 'Southend-on-Sea': 'East of England', 'St Albans': 'East of England', 'St. Edmundsbury': 'East of England', + 'Stevenage': 'East of England', 'Suffolk': 'East of England', 'Suffolk Coastal': 'East of England', + 'Tendring': 'East of England', 'Three Rivers': 'East of England', 'Thurrock': 'East of England', + 'Uttlesford': 'East of England', 'Watford': 'East of England', 'Waveney': 'East of England', + 'Welwyn Hatfield': 'East of England', + # 'Barking and Dagenham': 'London', 'Barnet': 'London', 'Bexley': 'London', + # 'Brent': 'London', 'Bromley': 'London', 'Camden': 'London', 'City of London': 'London', + # 'City of Westminster': 'London', 'Croydon': 'London', 'Ealing': 'London', 'Enfield': 'London', + # 'Greater London': 'London', 'Greenwich': 'London', 'Hackney': 'London', 'Hammersmith and Fulham': 'London', + # 'Haringey': 'London', 'Harrow': 'London', 'Havering': 'London', 'Hillingdon': 'London', 'Hounslow': 'London', + # 'Islington': 'London', 'Kensington and Chelsea': 'London', 'Kingston upon Thames': 'London', 'Lambeth': 'London', + # 'Lewisham': 'London', 'Merton': 'London', 'Newham': 'London', 'Redbridge': 'London', 'Richmond': 'London', + # 'Southwark': 'London', 'Sutton': 'London', 'Tower Hamlets': 'London', 'Waltham Forest': 'London', + # 'Wandsworth': 'London', 'Westminster': 'London', + 'County Durham': 'North East England', + 'Darlington': 'North East England', 'Durham': 'North East England', 'Gateshead': 'North East England', + 'Hartlepool': 'North East England', 'Middlesbrough': 'North East England', + 'Newcastle Upon Tyne': 'North East England', 'North Tyneside': 'North East England', + 'North Yorkshire': 'Yorkshire and the Humber', 'Northumberland': 'North East England', + 'Redcar and Cleveland': 'North East England', 'South Tyneside': 'North East England', + 'Stockton-on-Tees': 'North East England', 'Sunderland': 'North East England', 'Tyne and Wear': 'North East England', + 'Allerdale': 'North West England', 'Barrow-in-Furness': 'North West England', + 'Blackburn with Darwen': 'North West England', 'Blackpool': 'North West England', 'Bolton': 'North West England', + 'Burnley': 'North West England', 'Bury': 'North West England', 'Carlisle': 'North West England', + 'Cheshire': 'North West England', 'Cheshire East': 'North West England', + 'Cheshire West and Chester': 'North West England', 'Chorley': 'North West England', + 'Copeland': 'North West England', 'Cumbria': 'North West England', 'Eden': 'North West England', + 'Fylde': 'North West England', 'Greater Manchester': 'North West England', 'Halton': 'North West England', + 'Hyndburn': 'North West England', 'Knowsley': 'North West England', 'Lancashire': 'North West England', + 'Lancaster': 'North West England', 'Liverpool': 'North West England', 'Manchester': 'North West England', + 'Merseyside': 'North West England', 'Oldham': 'North West England', 'Pendle': 'North West England', + 'Preston': 'North West England', 'Ribble Valley': 'North West England', 'Rochdale': 'North West England', + 'Rossendale': 'North West England', 'Salford': 'North West England', 'Sefton': 'North West England', + 'South Lakeland': 'North West England', 'South Ribble': 'North West England', 'St Helens': 'North West England', + 'St. Helens': 'North West England', 'Stockport': 'North West England', 'Tameside': 'North West England', + 'Trafford': 'North West England', 'Warrington': 'North West England', 'West Lancashire': 'North West England', + 'Wigan': 'North West England', 'Wirral': 'North West England', 'Wyre': 'North West England', + 'Antrim': 'Northern Ireland', 'Ards': 'Northern Ireland', 'Armagh': 'Northern Ireland', + 'Ballymena': 'Northern Ireland', 'Ballymoney': 'Northern Ireland', 'Banbridge': 'Northern Ireland', + 'Belfast': 'Northern Ireland', 'Carrickfergus': 'Northern Ireland', 'Castlereagh': 'Northern Ireland', + 'Coleraine': 'Northern Ireland', 'Cookstown': 'Northern Ireland', 'County Armagh': 'Northern Ireland', + 'County Fermanagh': 'Northern Ireland', 'Craigavon': 'Northern Ireland', 'Derry': 'Northern Ireland', + 'Down': 'Northern Ireland', 'Dungannon': 'Northern Ireland', 'Fermanagh': 'Northern Ireland', + 'Larne': 'Northern Ireland', 'Limavady': 'Northern Ireland', 'Lisburn': 'Northern Ireland', + 'Magherafelt': 'Northern Ireland', 'Moyle': 'Northern Ireland', 'Newry and Mourne': 'Northern Ireland', + 'Newtownabbey': 'Northern Ireland', 'North Down': 'Northern Ireland', 'Omagh': 'Northern Ireland', + 'South Tyrone': 'Northern Ireland', 'Strabane': 'Northern Ireland', 'Aberdeen City': 'Scotland', + 'Aberdeenshire': 'Scotland', 'Angus': 'Scotland', 'Argyll and Bute': 'Scotland', 'Argyllshire': 'Scotland', + 'Ayrshire': 'Scotland', 'Banffshire': 'Scotland', 'Berwickshire': 'Scotland', 'Bute': 'Scotland', + 'Caithness': 'Scotland', 'City of Edinburgh': 'Scotland', 'Clackmannanshire': 'Scotland', + 'Dumfries and Galloway': 'Scotland', 'Dumfriesshire': 'Scotland', 'Dunbartonshire': 'Scotland', + 'Dundee City': 'Scotland', 'East Ayrshire': 'Scotland', 'East Dunbartonshire': 'Scotland', + 'East Lothian': 'Scotland', 'East Renfrewshire': 'Scotland', 'Edinburgh City': 'Scotland', + 'Eilean Siar': 'Scotland', 'Falkirk': 'Scotland', 'Fife': 'Scotland', 'Glasgow City': 'Scotland', + 'Highland': 'Scotland', 'Inverclyde': 'Scotland', 'Inverness-shire': 'Scotland', 'Kincardineshire': 'Scotland', + 'Kinross-shire': 'Scotland', 'Kirkcudbrightshire': 'Scotland', 'Lanarkshire': 'Scotland', 'Midlothian': 'Scotland', + 'Moray': 'Scotland', 'Nairnshire': 'Scotland', 'North Ayrshire': 'Scotland', 'North Lanarkshire': 'Scotland', + 'Orkney': 'Scotland', 'Orkney Islands': 'Scotland', 'Peeblesshire': 'Scotland', 'Perth and Kinross': 'Scotland', + 'Perthshire': 'Scotland', 'Renfrewshire': 'Scotland', 'Ross and Cromarty': 'Scotland', 'Roxburghshire': 'Scotland', + 'Selkirkshire': 'Scotland', 'Shetland Islands': 'Scotland', 'South Ayrshire': 'Scotland', + 'South Lanarkshire': 'Scotland', 'Stirling': 'Scotland', 'Stirlingshire': 'Scotland', 'Sutherland': 'Scotland', + 'The Scottish Borders': 'Scotland', 'West Ayrshire': 'Scotland', 'West Dunbartonshire': 'Scotland', + 'West Lothian': 'Scotland', 'Wigtownshire': 'Scotland', 'Zetland': 'Scotland', 'Adur': 'South East England', + 'Arun': 'South East England', 'Ashford': 'South East England', 'Aylesbury Vale': 'South East England', + 'Basingstoke and Deane': 'South East England', 'Berkshire': 'South East England', + 'Bracknell Forest': 'South East England', 'Brighton and Hove': 'South East England', + 'Buckinghamshire': 'South East England', 'Canterbury': 'South East England', 'Cherwell': 'South East England', + 'Chichester': 'South East England', 'Chiltern': 'South East England', 'Crawley': 'South East England', + 'Dartford': 'South East England', 'Dover': 'South East England', 'East Hampshire': 'South East England', + 'East Sussex': 'South East England', 'Eastbourne': 'South East England', 'Eastleigh': 'South East England', + 'Elmbridge': 'South East England', 'Epsom and Ewell': 'South East England', 'Fareham': 'South East England', + 'Gosport': 'South East England', 'Gravesham': 'South East England', 'Guildford': 'South East England', + 'Hampshire': 'South East England', 'Hart': 'South East England', 'Hastings': 'South East England', + 'Havant': 'South East England', 'Horsham': 'South East England', 'Isle of Wight': 'South East England', + 'Kent': 'South East England', 'Lewes': 'South East England', 'Maidstone': 'South East England', + 'Medway': 'South East England', 'Mid Sussex': 'South East England', 'Milton Keynes': 'South East England', + 'Mole Valley': 'South East England', 'New Forest': 'South East England', 'Oxford': 'South East England', + 'Oxfordshire': 'South East England', 'Portsmouth': 'South East England', 'Reading': 'South East England', + 'Reigate and Banstead': 'South East England', 'Rother': 'South East England', 'Runnymede': 'South East England', + 'Rushmoor': 'South East England', 'Sevenoaks': 'South East England', 'Shepway': 'South East England', + 'Slough': 'South East England', 'South Bucks': 'South East England', 'South Oxfordshire': 'South East England', + 'Southampton': 'South East England', 'Spelthorne': 'South East England', 'Surrey': 'South East England', + 'Surrey Heath': 'South East England', 'Swale': 'South East England', 'Tandridge': 'South East England', + 'Test Valley': 'South East England', 'Thanet': 'South East England', 'Tonbridge and Malling': 'South East England', + 'Tunbridge Wells': 'South East England', 'Vale of White Horse': 'South East England', + 'Waverley': 'South East England', 'Wealden': 'South East England', 'West Berkshire': 'South East England', + 'West Oxfordshire': 'South East England', 'West Sussex': 'South East England', 'Winchester': 'South East England', + 'Windsor and Maidenhead': 'South East England', 'Woking': 'South East England', 'Wokingham': 'South East England', + 'Worthing': 'South East England', 'Wycombe': 'South East England', + 'Bath and North East Somerset': 'South West England', 'Bournemouth': 'South West England', + 'Bristol': 'South West England', 'Cheltenham': 'South West England', 'Christchurch': 'South West England', + 'City of Bristol': 'South West England', 'Cornwall': 'South West England', 'Cotswold': 'South West England', + 'Devon': 'South West England', 'Dorset': 'South West England', 'East Devon': 'South West England', + 'East Dorset': 'South West England', 'Exeter': 'South West England', 'Forest of Dean': 'South West England', + 'Gloucester': 'South West England', 'Gloucestershire': 'South West England', + 'Isles of Scilly': 'South West England', 'Mendip': 'South West England', 'Mid Devon': 'South West England', + 'North Devon': 'South West England', 'North Dorset': 'South West England', 'North Somerset': 'South West England', + 'Plymouth': 'South West England', 'Poole': 'South West England', 'Purbeck': 'South West England', + 'Sedgemoor': 'South West England', 'Somerset': 'South West England', 'South Gloucestershire': 'South West England', + 'South Hams': 'South West England', 'South Somerset': 'South West England', 'Stroud': 'South West England', + 'Swindon': 'South West England', 'Taunton Deane': 'South West England', 'Teignbridge': 'South West England', + 'Tewkesbury': 'South West England', 'Torbay': 'South West England', 'Torridge': 'South West England', + 'West Devon': 'South West England', 'West Dorset': 'South West England', 'West Somerset': 'South West England', + 'Weymouth and Portland': 'South West England', 'Wiltshire': 'South West England', 'Aberdare': 'Wales', + 'Bargoed': 'Wales', 'Barry': 'Wales', 'Blaenau Gwent': 'Wales', 'Bridgend': 'Wales', 'Caerphilly': 'Wales', + 'Cardiff': 'Wales', 'Carmarthenshire': 'Wales', 'Ceredigion': 'Wales', 'Conwy': 'Wales', 'Cowbridge': 'Wales', + 'Denbighshire': 'Wales', 'Dinas Powys': 'Wales', 'Ferndale': 'Wales', 'Flintshire': 'Wales', 'Gwynedd': 'Wales', + 'Hengoed': 'Wales', 'Isle of Anglesey': 'Wales', 'Llantwit Major': 'Wales', 'Maesteg': 'Wales', + 'Merthyr Tydfil': 'Wales', 'Monmouthshire': 'Wales', 'Mountain Ash': 'Wales', 'Neath Port Talbot': 'Wales', + 'Newport': 'Wales', 'Pembrokeshire': 'Wales', 'Penarth': 'Wales', 'Pentre': 'Wales', 'Pontyclun': 'Wales', + 'Pontypridd': 'Wales', 'Porth': 'Wales', 'Porthcawl': 'Wales', 'Powys': 'Wales', 'Rhondda Cynon Taff': 'Wales', + 'Rhoose': 'Wales', 'Sully': 'Wales', 'Swansea': 'Wales', 'The Vale of Glamorgan': 'Wales', 'Tonypandy': 'Wales', + 'Torfaen': 'Wales', 'Treharris': 'Wales', 'Treorchy': 'Wales', 'Wrexham': 'Wales', 'Birmingham': 'West Midlands', + 'Bromsgrove': 'West Midlands', 'Cannock Chase': 'West Midlands', 'Coventry': 'West Midlands', + 'Dudley': 'West Midlands', 'East Staffordshire': 'West Midlands', 'Herefordshire': 'West Midlands', + 'Lichfield': 'West Midlands', 'Malvern Hills': 'West Midlands', 'Newcastle-under-Lyme': 'West Midlands', + 'North Warwickshire': 'West Midlands', 'Nuneaton and Bedworth': 'West Midlands', 'Redditch': 'West Midlands', + 'Rugby': 'West Midlands', 'Sandwell': 'West Midlands', 'Shropshire': 'West Midlands', 'Solihull': 'West Midlands', + 'South Staffordshire': 'West Midlands', 'Stafford': 'West Midlands', 'Staffordshire': 'West Midlands', + 'Staffordshire Moorlands': 'West Midlands', 'Stoke-on-Trent': 'West Midlands', 'Stratford-on-Avon': 'West Midlands', + 'Tamworth': 'West Midlands', 'Telford and Wrekin': 'West Midlands', 'Walsall': 'West Midlands', + 'Warwick': 'West Midlands', 'Warwickshire': 'West Midlands', 'West Midlands': 'West Midlands', + 'Wolverhampton': 'West Midlands', 'Worcester': 'West Midlands', 'Worcestershire': 'West Midlands', + 'Wychavon': 'West Midlands', 'Wyre Forest': 'West Midlands', 'Barnsley': 'Yorkshire and the Humber', + 'Bradford': 'Yorkshire and the Humber', 'Calderdale': 'Yorkshire and the Humber', + 'City of Kingston-upon-Hull': 'Yorkshire and the Humber', 'Craven': 'Yorkshire and the Humber', + 'Doncaster': 'Yorkshire and the Humber', 'East Riding of Yorkshire': 'Yorkshire and the Humber', + 'Hambleton': 'Yorkshire and the Humber', 'Harrogate': 'Yorkshire and the Humber', + 'Kingston upon Hull': 'Yorkshire and the Humber', 'Kirklees': 'Yorkshire and the Humber', + 'Leeds': 'Yorkshire and the Humber', 'North East Lincolnshire': 'Yorkshire and the Humber', + 'North Lincolnshire': 'Yorkshire and the Humber', 'Richmondshire': 'Yorkshire and the Humber', + 'Rotherham': 'Yorkshire and the Humber', 'Ryedale': 'Yorkshire and the Humber', + 'Scarborough': 'Yorkshire and the Humber', 'Selby': 'Yorkshire and the Humber', + 'Sheffield': 'Yorkshire and the Humber', 'South Yorkshire': 'Yorkshire and the Humber', + 'Wakefield': 'Yorkshire and the Humber', 'West Yorkshire': 'Yorkshire and the Humber', + 'York': 'Yorkshire and the Humber', + + # Additional mappings requried, based on what we find in the EPC database + 'Greater London Authority': 'Inner London' +} diff --git a/recommendations/tests/test_data/materials.py b/recommendations/tests/test_data/materials.py new file mode 100644 index 00000000..c0f434a5 --- /dev/null +++ b/recommendations/tests/test_data/materials.py @@ -0,0 +1,835 @@ +import datetime + +materials = [ + {'id': 17, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': None, + 'depth_unit': None, 'cost': 500, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': None, + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None, + 'created_at': datetime.datetime(2023, 10, 18, 16, 39, 9, 827188), 'is_active': True, 'prime_material_cost': None, + 'material_cost': None, 'labour_cost': None, 'labour_hours_per_unit': None, 'plant_cost': None, 'total_cost': None, + 'notes': None}, + {'id': 1109, 'type': 'cavity_wall_insulation', 'description': 'Expanded Polystyrene Beads cavity wall insulation', + 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'https://www.styrene.co.uk/downloads/Datasheets/Stylite_Cavity_Loose_Fill_Insulation_Datasheet_v20211.pdf', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 18.875, 'labour_cost': 1.125, 'labour_hours_per_unit': 0.065, 'plant_cost': 0.0, + 'total_cost': 20.0, + 'notes': "It is hard to find materials online. To price this, we've used this article: " + "https://www.greenmatch.co.uk/blog/cavity-wall-insulation-cost It puts EPS beads at around £22 per " + "meter squared, blowing wool insulation at £18 per meter squared and Polyurethane Foam at £26 per meter " + "squared, when taking the most pessimistic prices. These rates have been used to adjust the price of " + "the mineral wool insulation to give us the other forms of insulation"}, + {'id': 1110, 'type': 'cavity_wall_insulation', 'description': 'Injected Polyurthane Foam cavity wall insulation', + 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'https://www.foaminstall.co.uk/wp-content/uploads/2017/04/Lapolla-Cavity-Fill-BBA-certificate-sheet1.pdf', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 22.875, 'labour_cost': 1.125, 'labour_hours_per_unit': 0.065, 'plant_cost': 0.0, + 'total_cost': 24.0, 'notes': None}, + {'id': 1111, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.03, + 'material_cost': 2.1, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.66, + 'notes': None}, + {'id': 1112, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 150.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 3.06, + 'material_cost': 3.16, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.94, + 'notes': None}, + {'id': 1113, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 170.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'https://insulation4less.co.uk/products/knauf-170mm-combi-cut?variant=31671561257013&dfw_tracker=77750' + '-31671561257013&utm_source=google&utm_medium=shopping&utm_campaign=shoptimised&gad_source=1&gclid' + '=CjwKCAiAx_GqBhBQEiwAlDNAZi1LiTWKVn0W1vktOYAPPQU3hss5Tq2qNn6GNhodCQoRD_tvqCLdxhoCKnIQAvD_BwE', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 3.81938, 'labour_cost': 1.71304, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, + 'total_cost': 5.53242, + 'notes': "We don't have a 170mm in SPONs so the material cost is based on the fact that the 170mm insulation is " + "87.4% of the cost of the 200mm insulation"}, + {'id': 1114, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 200.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.25, + 'material_cost': 4.37, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 6.33, + 'notes': None}, + {'id': 1115, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 270.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 5.91938, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, + 'total_cost': 7.87938, 'notes': 'This is the 100mm product + the 170mm product'}, + {'id': 1116, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 300.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 6.47, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 8.43, + 'notes': 'This is the 100mm product + the 200mm product'}, + {'id': 1117, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.99, + 'material_cost': 2.05, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.65, + 'notes': None}, + {'id': 1118, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 150.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.96, + 'material_cost': 3.05, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.83, + 'notes': None}, + {'id': 1119, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 170.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-170mm-x-1160mm-x-7-03m-8-15m2/', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 3.8706238, 'labour_cost': 2.281361, 'labour_hours_per_unit': 0.12816635, 'plant_cost': 0.0, + 'total_cost': 6.1519847, + 'notes': "We don't have a 170mm in SPONs so the material cost is based on the fact that the 170mm insulation is " + "85.4% of the cost of the 200mm insulation"}, + {'id': 1120, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 200.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.4, + 'material_cost': 4.53, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 7.2, + 'notes': None}, + {'id': 1121, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 270.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 5.920624, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, + 'total_cost': 8.590624, 'notes': 'This is the 100mm product + the 170mm product'}, + {'id': 1122, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 300.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 6.58, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.25, + 'notes': 'This is the 100mm product + the 200mm product'}, + {'id': 1123, 'type': 'loft_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 5.93, + 'material_cost': 6.4, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.07, + 'notes': 'This provides acoustic insulation as well'}, + {'id': 1124, 'type': 'loft_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 300.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 17.79, + 'material_cost': 19.2, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 21.87, + 'notes': 'This provides acoustic insulation as well'}, + {'id': 1125, 'type': 'loft_insulation', 'description': 'Thermafleece EcoRoll Insulation', 'depth': 300.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 24.78, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 27.45, + 'notes': 'This material is based on installing 3 layers of the 100mm product'}, + {'id': 1126, 'type': 'loft_insulation', 'description': 'Thermafleece EcoRoll Insulation', 'depth': 280.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 23.36, 'labour_cost': 3.12, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 26.48, + 'notes': 'This material is based on installed 2 layers of the 140mm product'}, + {'id': 1127, 'type': 'iwi_wall_demolition', + 'description': 'Solid & Dry Lined walls: Hack of wall finishes with chipping hammer; plaster to walls.', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 10.27, 'labour_hours_per_unit': 0.33, + 'plant_cost': 1.28, 'total_cost': 11.55, 'notes': None}, {'id': 1128, 'type': 'iwi_wall_demolition', + 'description': 'Stud walls: Remove wall linings ' + 'including battening behind; ' + 'plasterboard and skim', + 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, + 244907), + 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 6.23, + 'labour_hours_per_unit': 0.2, 'plant_cost': 1.25, + 'total_cost': 7.48, 'notes': None}, + {'id': 1129, 'type': 'iwi_wall_demolition', + 'description': 'Lathe and Plaster walls: Remove wall linings including battening behind; wood lath and plaster', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.85, 'labour_hours_per_unit': 0.22, + 'plant_cost': 2.09, 'total_cost': 8.94, 'notes': None}, + {'id': 1130, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs', + 'depth': 60.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 41.69, + 'material_cost': 53.33, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0, + 'total_cost': 82.85, 'notes': None}, + {'id': 1131, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 86.86, + 'material_cost': 99.85, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0, + 'total_cost': 129.37, 'notes': None}, + {'id': 1132, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs', + 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': 130.29, 'material_cost': 144.58, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, + 'plant_cost': 0.0, 'total_cost': 174.1, 'notes': None}, + {'id': 1133, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', + 'depth': 30.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16, + 'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07, + 'notes': None}, + {'id': 1134, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', + 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46, + 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44, + 'notes': None}, + {'id': 1135, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12, + 'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66, + 'notes': None}, + {'id': 1136, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard', + 'depth': 37.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 26.86, 'labour_cost': 5.21, 'labour_hours_per_unit': 0.23, 'plant_cost': 0.0, 'total_cost': 32.07, + 'notes': None}, + {'id': 1137, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard', + 'depth': 42.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 17.37, 'labour_cost': 5.21, 'labour_hours_per_unit': 0.23, 'plant_cost': 0.0, 'total_cost': 22.58, + 'notes': None}, + {'id': 1138, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard', + 'depth': 52.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 21.74, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 27.53, + 'notes': None}, + {'id': 1139, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard', + 'depth': 62.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 19.3, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 25.09, + 'notes': None}, + {'id': 1140, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard', + 'depth': 72.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 23.15, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 28.94, + 'notes': None}, + {'id': 1141, 'type': 'iwi_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02, + 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None}, {'id': 1142, 'type': 'iwi_redecoration', + 'description': 'Plaster; one coat Thistle board finish ' + 'or other equal; steel trowelled; 3 mm ' + 'thick work to walls or ceilings; one ' + 'coat; to plasterboard base; over 600mm ' + 'wide', + 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, + 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.06, + 'labour_cost': 6.58, 'labour_hours_per_unit': 0.25, + 'plant_cost': 0.0, 'total_cost': 6.64, 'notes': None}, + {'id': 1143, 'type': 'iwi_redecoration', + 'description': 'Two coats emulsion paint on plaster, over 40mm girth; 3.5m - 5m high', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.41, 'labour_cost': 3.93, 'labour_hours_per_unit': 0.21, + 'plant_cost': 0.0, 'total_cost': 4.34, 'notes': None}, {'id': 1144, 'type': 'iwi_redecoration', + 'description': 'Fitting existing softwood skirting or ' + 'architrave to new frames; 150mm high', + 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, + 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.01, + 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12, + 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None}, + {'id': 1145, 'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, + 'plant_cost': 0.0, 'total_cost': 3.32, + 'notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and therefore ' + 'there is no need for a skip'}, + {'id': 1146, 'type': 'suspended_floor_demolition', + 'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3, + 'plant_cost': 0.0, 'total_cost': 9.34, 'notes': None}, + {'id': 1147, 'type': 'suspended_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02, + 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None}, + {'id': 1148, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 50.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 4.24, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 5.8, + 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as ' + 'in Crown loft roll 44, since it is also an insulation roll'}, + {'id': 1149, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 6.31, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 7.87, + 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as ' + 'in Crown loft roll 44, since it is also an insulation roll'}, + {'id': 1150, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 8.26, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 9.82, + 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as ' + 'in Crown loft roll 44, since it is also an insulation roll'}, + {'id': 1151, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 140.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 13.46, + 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as ' + 'in Crown loft roll 44, since it is also an insulation roll'}, + {'id': 1152, 'type': 'suspended_floor_insulation', + 'description': 'Thermafleece TF35 high density wool insulating batts', 'depth': 50.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.028571429, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.035, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 6.63, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 8.19, + 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as ' + 'in Crown loft roll 44, since it is also an insulation roll'}, + {'id': 1153, 'type': 'suspended_floor_insulation', + 'description': 'Thermafleece TF35 high density wool insulating batts', 'depth': 75.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.028571429, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.035, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 10.31, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 11.87, + 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as ' + 'in Crown loft roll 44, since it is also an insulation roll'}, + {'id': 1154, 'type': 'suspended_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 30.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16, + 'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07, + 'notes': None}, {'id': 1155, 'type': 'suspended_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': 8.46, 'material_cost': 19.1, 'labour_cost': 28.34, + 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44, 'notes': None}, + {'id': 1156, 'type': 'suspended_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 100.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12, + 'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66, + 'notes': None}, {'id': 1157, 'type': 'suspended_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 150.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': 23.53, 'material_cost': 34.62, 'labour_cost': 33.06, + 'labour_hours_per_unit': 1.4, 'plant_cost': 0.0, 'total_cost': 67.68, 'notes': None}, + {'id': 1158, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.03, + 'material_cost': 2.1, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.66, + 'notes': None}, + {'id': 1159, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', + 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 3.06, + 'material_cost': 3.16, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.94, + 'notes': None}, + {'id': 1160, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', + 'depth': 200.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.25, + 'material_cost': 4.37, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 6.33, + 'notes': None}, + {'id': 1161, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.99, + 'material_cost': 2.05, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.65, + 'notes': None}, + {'id': 1162, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll', + 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.96, + 'material_cost': 3.05, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.83, + 'notes': None}, + {'id': 1163, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll', + 'depth': 200.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.4, + 'material_cost': 4.53, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 7.2, + 'notes': None}, + {'id': 1164, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 25.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.025641026, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.67, + 'material_cost': 2.01, 'labour_cost': 1.43, 'labour_hours_per_unit': 0.08, 'plant_cost': 0.0, 'total_cost': 3.44, + 'notes': None}, + {'id': 1165, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 50.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.025641026, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.74, + 'material_cost': 3.11, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 4.71, + 'notes': None}, + {'id': 1166, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.57, + 'material_cost': 5.01, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 6.79, + 'notes': None}, + {'id': 1167, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 5.93, + 'material_cost': 6.4, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.07, + 'notes': None}, + {'id': 1168, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', + 'depth': 25.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12, + 'notes': None}, + {'id': 1169, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', + 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33, + 'notes': None}, + {'id': 1170, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', + 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47, + 'notes': None}, + {'id': 1171, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 12.02, 'labour_cost': 4.4, 'labour_hours_per_unit': 0.19, 'plant_cost': 0.0, 'total_cost': 16.42, + 'notes': None}, {'id': 1172, 'type': 'suspended_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 50.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 10.36, 'labour_cost': 4.06, + 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 14.42, 'notes': None}, + {'id': 1173, 'type': 'suspended_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41, + 'notes': None}, {'id': 1174, 'type': 'suspended_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', + 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), + 'is_active': True, 'prime_material_cost': None, 'material_cost': 19.17, 'labour_cost': 4.06, + 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 23.23, 'notes': None}, + {'id': 1175, 'type': 'suspended_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 125.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 26.59, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 30.65, + 'notes': None}, {'id': 1176, 'type': 'suspended_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', + 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', + 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), + 'is_active': True, 'prime_material_cost': None, 'material_cost': 31.13, 'labour_cost': 4.64, + 'labour_hours_per_unit': 0.2, 'plant_cost': 0.0, 'total_cost': 35.77, 'notes': None}, + {'id': 1177, 'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 1.54, 'labour_cost': 24.98, 'labour_hours_per_unit': 0.74, + 'plant_cost': 0.0, 'total_cost': 26.52, 'notes': None}, + {'id': 1178, 'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, + 'plant_cost': 0.0, 'total_cost': 6.59, + 'notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; Gradus woven ' + 'polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, therefore we need just ' + 'labour rates'}, + {'id': 1179, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, + 'plant_cost': 0.0, 'total_cost': 3.32, + 'notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and therefore ' + 'there is no need for a skip'}, + {'id': 1180, 'type': 'solid_floor_preparation', + 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None, + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36, + 'notes': None}, {'id': 1181, 'type': 'solid_floor_preparation', + 'description': 'Clean out crack to form a 20mm×20mm groove and fill with cement: mortar mixed ' + 'with bonding agent', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': None, + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 6.91, 'labour_cost': 18.99, + 'labour_hours_per_unit': 0.61, 'plant_cost': 0.16, 'total_cost': 26.06, + 'notes': 'This step is the assessment and repair of any damage to the concrete floor such as ' + 'filling cracks or levelling uneven areas'}, + {'id': 1182, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02, + 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None}, + {'id': 1183, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 25.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12, + 'notes': None}, + {'id': 1184, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 50.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33, + 'notes': None}, + {'id': 1185, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47, + 'notes': None}, {'id': 1186, 'type': 'solid_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 50.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 10.36, 'labour_cost': 4.06, + 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 14.42, 'notes': None}, + {'id': 1187, 'type': 'solid_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41, + 'notes': None}, {'id': 1188, 'type': 'solid_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 30.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': 6.16, 'material_cost': 16.73, 'labour_cost': 28.34, + 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07, 'notes': None}, + {'id': 1189, 'type': 'solid_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46, + 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44, + 'notes': None}, {'id': 1190, 'type': 'solid_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 60.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'https://londonbuildingsupplies.co.uk/products/60mm--ecotherm-eco-versal-general' + '-purpose-pir-insulation-board---2.4m-x-1.2m-x-60mm.html', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 24.081198, 'labour_cost': 28.34, + 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 52.421196, + 'notes': "This material isn't in SPONs but checking online, is around 92% of the cost of the " + "100mm"}, + {'id': 1191, 'type': 'solid_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 70.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'https://londonbuildingsupplies.co.uk/products/70mm--ecotherm-eco-versal-general-purpose-pir-insulation' + '-board---2.4m-x-1.2m-x-70mm.html', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 27.089088, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, + 'total_cost': 55.42909, + 'notes': "This material isn't in SPONs but checking online, is around 104% of the cost of the 100mm (more " + "expensive than 100mm)"}, + {'id': 1192, 'type': 'solid_floor_insulation', + 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 100.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12, + 'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66, + 'notes': None}, + {'id': 1193, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam', + 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.032258064, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.031, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 11.07, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0, + 'total_cost': 21.73, + 'notes': "In Spons, the thermal conductivity is 0.033 however the datasheet indicates it's 0.32: " + "https://ravagobuildingsolutions.com/uk/wp-content/uploads/sites/30/2022/08/ravatherm-xps-x-500-sl-tds" + "-version-1-20210901.pdf"}, + {'id': 1194, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam', + 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 16.28, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0, + 'total_cost': 26.94, 'notes': None}, {'id': 1195, 'type': 'solid_floor_redecoration', + 'description': 'Screeded beds; protection to compressible formwork ' + 'exceeding 600mm wide', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, + 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), + 'is_active': True, 'prime_material_cost': 9.6, 'material_cost': 9.89, + 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, + 'total_cost': 12.56, + 'notes': 'This is the screed layer, placed on top of the insulation'}, + {'id': 1196, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, 'plant_cost': 0.0, 'total_cost': 6.59, + 'notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; Gradus woven ' + 'polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, therefore we need just ' + 'labour rates'}, + {'id': 1197, 'type': 'solid_floor_redecoration', + 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12, + 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None}, {'id': 1198, 'type': 'ewi_wall_demolition', + 'description': 'Solid & Dry Lined walls: Hack of wall ' + 'finishes with chipping hammer; plaster ' + 'to walls.', + 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, + 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, + 'labour_cost': 10.27, 'labour_hours_per_unit': 0.33, + 'plant_cost': 1.28, 'total_cost': 11.55, 'notes': None}, + {'id': 1199, 'type': 'ewi_wall_demolition', + 'description': 'Stud walls: Remove wall linings including battening behind; plasterboard and skim', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.23, 'labour_hours_per_unit': 0.2, + 'plant_cost': 1.25, 'total_cost': 7.48, 'notes': None}, {'id': 1200, 'type': 'ewi_wall_demolition', + 'description': 'Lathe and Plaster walls: Remove wall ' + 'linings including battening behind; ' + 'wood lath and plaster', + 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, + 244907), + 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 6.85, + 'labour_hours_per_unit': 0.22, 'plant_cost': 2.09, + 'total_cost': 8.94, 'notes': None}, + {'id': 1201, 'type': 'ewi_wall_preparation', + 'description': 'Clean and prepare surfaces, one coat Keim dilution, one coat primer and two coats of Keim Ecosil ' + 'paint; Brick or block walls; over 300 mm girth', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 7.3, 'labour_cost': 5.62, 'labour_hours_per_unit': 0.3, + 'plant_cost': 0.0, 'total_cost': 12.92, + 'notes': 'This work covers the preparation and priming of the wall before insulating'}, + {'id': 1202, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', + 'depth': 30.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16, + 'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07, + 'notes': None}, + {'id': 1203, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', + 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46, + 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44, + 'notes': None}, + {'id': 1204, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12, + 'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66, + 'notes': None}, + {'id': 1205, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board', + 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 23.53, + 'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0.0, 'total_cost': 67.68, + 'notes': None}, + {'id': 1206, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs', + 'depth': 60.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 41.69, + 'material_cost': 53.33, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0, + 'total_cost': 82.85, 'notes': None}, + {'id': 1207, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 86.86, + 'material_cost': 99.85, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0, + 'total_cost': 129.37, 'notes': None}, + {'id': 1208, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs', + 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, + 'prime_material_cost': 130.29, 'material_cost': 144.58, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, + 'plant_cost': 0.0, 'total_cost': 174.1, 'notes': None}, {'id': 1209, 'type': 'ewi_wall_redecoration', + 'description': 'EPS insulation fixed with adhesive to ' + 'SFS structure (measured separately) ' + 'with horizontal PVC intermediate track ' + 'and vertical T-spines; with glassfibre ' + 'mesh reinforcement embedded in Sto ' + 'Armat Classic Basecoat Render and ' + 'Stolit K 1.5 Decorative Topcoat Render ' + '(white)', + 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, + 244907), + 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, + 'total_cost': 69.94, + 'notes': 'This material in SPONs is for 70mm EPS ' + 'insulation, which comes in at a cost of 99.17 ' + 'per meter square. This includes the cost of ' + 'insulation. To get the costing for just the ' + 'works and not the insulation, we subtract the ' + 'cost of EPS insulation, using Ravathem 75mm ' + 'insulation as an example, which costs £29.23 ' + 'per meter square, giving us the cost of the ' + 'remaining works without insulation. This ' + 'material gives us a cost for basecoat, ' + 'mesh application and a render finish'}, + {'id': 1210, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'https://www.checkatrade.com/blog/cost-guides/cost-install-downlights/ ' + 'https://www.hamuch.com/cost/led-spot-light#:~:text=It%20costs%20an%20average%20of,' + 'will%20drive%20up%20the%20cost.', + 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 20.0, 'labour_cost': 46.0, 'labour_hours_per_unit': 0.8, 'plant_cost': 0.0, 'total_cost': 66.0, + 'notes': 'We estimate the unit economics from the checkatrade article. We assume that the average job consists ' + 'of installing 6 lights based on the hamuch article. We use the median value of 400 for a job of 6 ' + 'lights'}] diff --git a/recommendations/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py index 3663364c..0258e592 100644 --- a/recommendations/tests/test_wall_recommendations.py +++ b/recommendations/tests/test_wall_recommendations.py @@ -6,202 +6,14 @@ from unittest.mock import Mock, MagicMock from recommendations.WallRecommendations import WallRecommendations from backend.Property import Property from recommendations.recommendation_utils import is_diminishing_returns +from recommendations.tests.test_data.materials import materials + # with open( # os.path.abspath(os.path.dirname(__file__)) + "/recommendations/tests/test_data/input_properties.pkl", "rb" # ) as f: # input_properties = pickle.load(f) -external_wall_insulation_parts = [ - { - # Example product - # https://insulationgo.co.uk/100mm-rockwool-external-wall-insulation-dual-density-slabs-a1-non-combustible - # -slab-ewi-render-fire/ - "type": "external_wall_insulation", - "description": "Mineral Wool External Wall Insulation", - "depths": [30, 50, 70, 80, 90, 100, 150, 200], - "depth_unit": "mm", - "cost": [30, 50, 70, 80, 90, 100, 150, 200], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.0278, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.036, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - # Example product - # https://www.insulationking.co.uk/products/polystyrene-eps70?variant=44156186558759 - "type": "external_wall_insulation", - "description": "Expanded Polystyrene External Wall Insulation", - "depths": [25, 50, 100, 125], - "depth_unit": "mm", - "cost": [25, 50, 100, 125], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.02703, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.037, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - # Example product - # https://www.insulationshop.co/20mm_kooltherm_k5_external_wall_kingspan.html - "type": "external_wall_insulation", - "description": "Phenolic Foam External Wall Insulation", - "depths": [20, 50, 100], - "depth_unit": "mm", - "cost": [20, 50, 100], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.043478260869565216, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.023, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - - }, - { - "type": "external_wall_insulation", - "description": "Polyisocyanurate/Polyurethane Foam External Wall Insulation", - "depths": [], - "depth_unit": "mm", - "cost": [], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": None, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": None, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - # Example product - # https://www.mikewye.co.uk/product/steico-duo-dry/ - "type": "external_wall_insulation", - "description": "Wood Fiber External Wall Insulation", - "depths": [40, 60], - "depth_unit": "mm", - "cost": [40, 60], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.023255813953488375, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.043, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - # Example product - # https://www.thermablok.co.uk/site/wp-content/uploads/2022/09/Thermablok-Aerogel-Insulation-Blanket-TDS-AIS - # -and-Steel-Related-Details.pdf - "type": "external_wall_insulation", - "description": "Aerogel External Wall Insulation", - "depths": [10, 20, 30, 40, 50, 60, 70], - "depth_unit": "mm", - "cost": [10, 20, 30, 40, 50, 60, 70], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.06666666666666667, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.015, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - "type": "external_wall_insulation", - "description": "Vacuum Insulation Panels External Wall Insulation", - "depths": [45, 60], - "depth_unit": "mm", - "cost": [45, 60], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.16666666666666666, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.006, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - } -] - -internal_wall_insulation_parts = [ - { - # Example product - # https://www.insulationshop.co/25mm_polystyrene_insulation_eps_70jablite.html - "type": "internal_wall_insulation", - "description": "Rigid Insulation Boards Internal Wall Insulation", - "depths": [25, 40, 50, 75, 100], - "depth_unit": "mm", - "cost": [25, 40, 50, 75, 100], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.026315789473684213, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.038, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - # Example product - # https://www.rockwool.com/siteassets/rw-uk/downloads/datasheets/flexi.pdf - "type": "internal_wall_insulation", - "description": "Mineral Wool Internal Wall Insulation", - "depths": [140], - "depth_unit": "mm", - "cost": [140], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.02857142857142857, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.035, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - # Example product - # https://www.kingspan.com/gb/en/products/insulation-boards/wall-insulation-boards/kooltherm-k118-insulated - # -plasterboard/ - "type": "internal_wall_insulation", - "description": "Insulated Plasterboard Internal Wall Insulation", - "depths": [25, 80], - "depth_unit": "mm", - "cost": [25, 80], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.02857142857142857, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.019, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - "type": "internal_wall_insulation", - "description": "Reflective Internal Wall Insulation", - "depths": [], - "depth_unit": "mm", - "cost": [], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": None, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": None, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - # Example product - # https://www.insulationsuperstore.co.uk/product/vacutherm-vacupor-nt-b2-vacuum-insulated-panel-1m-x-600mm-x - # -30mm.html - "type": "internal_wall_insulation", - "description": "Vacuum Insulation Panels Wall Insulation", - "depths": [20, 30], - "depth_unit": "mm", - "cost": [20, 30], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.125, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.008, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, -] - -cavity_wall_insulation_parts = [ - {'id': 4, 'type': 'cavity_wall_insulation', 'description': 'Example Material 1', - 'depths': None, - 'depth_unit': None, 'cost': 20, - 'cost_unit': 'gbp_sq_meter', 'r_value_per_mm': 0.0278, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.036, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': None, 'created_at': None, 'is_active': True}, - {'id': 10, 'type': "cavity_wall_insulation", 'description': 'Example Material 2', - 'depths': None, 'depth_unit': None, 'cost': 25, 'cost_unit': 'gbp_sq_meter', - 'r_value_per_mm': 0.02631579, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': None, - 'created_at': None, 'is_active': True} -] - -wall_parts = external_wall_insulation_parts + internal_wall_insulation_parts + cavity_wall_insulation_parts - class TestWallRecommendations: @@ -217,17 +29,20 @@ class TestWallRecommendations: # Creating a mock instance of WallRecommendations with the necessary attributes property_mock = Mock() property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"} # or any date you want - property_mock.data = {"construction-age-band": "1950"} # or any other data that fits your tests + property_mock.data = {"construction-age-band": "1950", + "county": "Derbyshire"} # or any other data that fits your tests mock_wall_rec_instance = WallRecommendations( - property_mock, materials=wall_parts + property_mock, materials=materials ) return mock_wall_rec_instance def test_init(self, input_properties): + input_properties[0].insulation_wall_area = 100 + obj = WallRecommendations( property_instance=input_properties[0], - materials=wall_parts + materials=materials ) assert obj assert obj.property @@ -244,10 +59,11 @@ class TestWallRecommendations: input_properties[0].year_built = 2014 input_properties[0].in_conservation_area = None input_properties[0].restricted_measures = False + input_properties[0].insulation_wall_area = 100 recommender = WallRecommendations( property_instance=input_properties[0], - materials=wall_parts + materials=materials ) assert recommender.property.walls["original_description"] == "Average thermal transmittance 0.16 W/m-¦K" recommender.recommend() @@ -272,7 +88,7 @@ class TestWallRecommendations: recommender = WallRecommendations( property_instance=input_properties[1], - materials=wall_parts + materials=materials ) assert recommender.property.walls["original_description"] == "Solid brick, as built, no insulation (assumed)" assert not recommender.ewi_valid @@ -306,9 +122,11 @@ class TestWallRecommendations: input_properties[6].year_built = 1991 input_properties[6].restricted_measures = False + input_properties[6].insulation_wall_area = 100 + recommender = WallRecommendations( property_instance=input_properties[6], - materials=wall_parts + materials=materials ) assert recommender.property.walls["original_description"] == "Solid brick, as built, insulated (assumed)" @@ -383,12 +201,14 @@ class TestWallRecommendationsBase: property_mock.full_sap_epc = {"lodgement-date": "1999-12-31"} property_mock.in_conservation_area = "not_in_conservation_area" property_mock.restricted_measures = False + property_mock.insulation_wall_area = 100 + property_mock.data = {"county": "Derbyshire"} return property_mock @pytest.fixture def wall_recommendations_instance(self, property_mock): wall_recommendations_instance = WallRecommendations( - property_mock, materials=wall_parts + property_mock, materials=materials ) return wall_recommendations_instance @@ -425,10 +245,11 @@ class TestCavityWallRecommensations: } input_property.age_band = "C" input_property.insulation_wall_area = 50 + input_property.data = {"county": "Derbyshire"} recommender = WallRecommendations( property_instance=input_property, - materials=cavity_wall_insulation_parts + materials=materials ) assert not recommender.recommendations @@ -437,11 +258,11 @@ class TestCavityWallRecommensations: assert recommender.recommendations assert recommender.estimated_u_value == 1.5 - assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.37) - assert np.isclose(recommender.recommendations[0]["cost"], 1000) + assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.35) + assert np.isclose(recommender.recommendations[0]["total"], 1668.6600000000003) - assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.38) - assert np.isclose(recommender.recommendations[1]["cost"], 1250) + assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.35) + assert np.isclose(recommender.recommendations[1]["total"], 2004.6600000000003) def test_fill_partial_filled_cavity(self): input_property = Property(id=1, postcode="F4k3", address1="123 fake street", epc_client=Mock()) @@ -458,10 +279,11 @@ class TestCavityWallRecommensations: } input_property.age_band = "C" input_property.insulation_wall_area = 50 + input_property.data = {"county": "County Durham"} recommender = WallRecommendations( property_instance=input_property, - materials=cavity_wall_insulation_parts + materials=materials ) assert not recommender.recommendations @@ -470,11 +292,11 @@ class TestCavityWallRecommensations: assert recommender.recommendations assert recommender.estimated_u_value == 1.3 - assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.43) - assert np.isclose(recommender.recommendations[0]["cost"], 1000) + assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.41) + assert np.isclose(recommender.recommendations[0]["total"], 1663.9350000000002) - assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.45) - assert np.isclose(recommender.recommendations[1]["cost"], 1250) + assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.41) + assert np.isclose(recommender.recommendations[1]["total"], 1999.9350000000002) def test_system_built_wall(self): input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) @@ -492,13 +314,13 @@ class TestCavityWallRecommensations: input_property2.age_band = "F" input_property2.insulation_wall_area = 120 input_property2.restricted_measures = False - input_property2.data = {"property-type": "house"} + input_property2.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Detached"} assert input_property2.walls["is_system_built"] recommender2 = WallRecommendations( property_instance=input_property2, - materials=internal_wall_insulation_parts + external_wall_insulation_parts + materials=materials ) assert not recommender2.recommendations @@ -506,22 +328,22 @@ class TestCavityWallRecommensations: recommender2.recommend() assert recommender2.recommendations - assert len(recommender2.recommendations) == 6 + assert len(recommender2.recommendations) == 9 assert recommender2.estimated_u_value == 1 - assert np.isclose(recommender2.recommendations[0]["new_u_value"], 0.29) - assert np.isclose(recommender2.recommendations[0]["cost"], 10800) + assert np.isclose(recommender2.recommendations[0]["new_u_value"], 0.19) + assert np.isclose(recommender2.recommendations[0]["total"], 15899.9616) assert recommender2.recommendations[0]["parts"][0]["type"] == "external_wall_insulation" - assert recommender2.recommendations[0]["parts"][0]["depths"] == [90] + assert recommender2.recommendations[0]["parts"][0]["depth"] == 100 - assert np.isclose(recommender2.recommendations[5]["new_u_value"], 0.29) - assert np.isclose(recommender2.recommendations[5]["cost"], 2400) - assert recommender2.recommendations[5]["parts"][0]["type"] == "internal_wall_insulation" - assert recommender2.recommendations[5]["parts"][0]["depths"] == [20] + assert np.isclose(recommender2.recommendations[8]["new_u_value"], 0.23) + assert np.isclose(recommender2.recommendations[8]["total"], 10916.3424) + assert recommender2.recommendations[8]["parts"][0]["type"] == "internal_wall_insulation" + assert recommender2.recommendations[8]["parts"][0]["depth"] == 72.5 - assert np.isclose(recommender2.recommendations[3]["new_u_value"], 0.28) - assert np.isclose(recommender2.recommendations[3]["cost"], 4800) - assert recommender2.recommendations[3]["parts"][0]["type"] == "external_wall_insulation" - assert recommender2.recommendations[3]["parts"][0]["depths"] == [40] + assert np.isclose(recommender2.recommendations[6]["new_u_value"], 0.29) + assert np.isclose(recommender2.recommendations[6]["total"], 10621.934399999998) + assert recommender2.recommendations[6]["parts"][0]["type"] == "internal_wall_insulation" + assert recommender2.recommendations[6]["parts"][0]["depth"] == 52.5 def test_timber_frame_wall(self): input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) @@ -539,13 +361,13 @@ class TestCavityWallRecommensations: input_property3.age_band = "B" input_property3.insulation_wall_area = 99 input_property3.restricted_measures = False - input_property3.data = {"property-type": "house"} + input_property3.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Semi-Detached"} assert input_property3.walls["is_timber_frame"] recommender3 = WallRecommendations( property_instance=input_property3, - materials=internal_wall_insulation_parts + external_wall_insulation_parts + materials=materials ) assert not recommender3.recommendations @@ -553,17 +375,17 @@ class TestCavityWallRecommensations: recommender3.recommend() assert recommender3.recommendations - assert len(recommender3.recommendations) == 2 + assert len(recommender3.recommendations) == 6 assert recommender3.estimated_u_value == 1.9 - assert np.isclose(recommender3.recommendations[0]["new_u_value"], 0.26) - assert np.isclose(recommender3.recommendations[0]["cost"], 12375) + assert np.isclose(recommender3.recommendations[0]["new_u_value"], 0.2) + assert np.isclose(recommender3.recommendations[0]["total"], 13117.46832) assert recommender3.recommendations[0]["parts"][0]["type"] == "external_wall_insulation" - assert recommender3.recommendations[0]["parts"][0]["depths"] == [125] + assert recommender3.recommendations[0]["parts"][0]["depth"] == 100.0 - assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.26) - assert np.isclose(recommender3.recommendations[1]["cost"], 4950) + assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.23) + assert np.isclose(recommender3.recommendations[1]["total"], 34070.50944) assert recommender3.recommendations[1]["parts"][0]["type"] == "external_wall_insulation" - assert recommender3.recommendations[1]["parts"][0]["depths"] == [50] + assert recommender3.recommendations[1]["parts"][0]["depth"] == 150.0 def test_granite_or_whinstone_wall(self): input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) @@ -581,13 +403,13 @@ class TestCavityWallRecommensations: input_property4.age_band = "A" input_property4.insulation_wall_area = 223 input_property4.restricted_measures = False - input_property4.data = {"property-type": "Bungalow"} + input_property4.data = {"property-type": "Bungalow", "county": "Derbyshire", "built-form": "Detached"} assert input_property4.walls["is_granite_or_whinstone"] recommender4 = WallRecommendations( property_instance=input_property4, - materials=internal_wall_insulation_parts + external_wall_insulation_parts + materials=materials ) assert not recommender4.recommendations @@ -595,17 +417,17 @@ class TestCavityWallRecommensations: recommender4.recommend() assert recommender4.recommendations - assert len(recommender4.recommendations) == 2 + assert len(recommender4.recommendations) == 6 assert recommender4.estimated_u_value == 2.3 - assert np.isclose(recommender4.recommendations[0]["new_u_value"], 0.27) - assert np.isclose(recommender4.recommendations[0]["cost"], 27875) + assert np.isclose(recommender4.recommendations[0]["new_u_value"], 0.21) + assert np.isclose(recommender4.recommendations[0]["total"], 28562.514352) assert recommender4.recommendations[0]["parts"][0]["type"] == "external_wall_insulation" - assert recommender4.recommendations[0]["parts"][0]["depths"] == [125] + assert recommender4.recommendations[0]["parts"][0]["depth"] == 100 - assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.27) - assert np.isclose(recommender4.recommendations[1]["cost"], 11150) + assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.23) + assert np.isclose(recommender4.recommendations[1]["total"], 74186.52678400002) assert recommender4.recommendations[1]["parts"][0]["type"] == "external_wall_insulation" - assert recommender4.recommendations[1]["parts"][0]["depths"] == [50] + assert recommender4.recommendations[1]["parts"][0]["depth"] == 150 def test_cob_wall(self): input_property5 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) @@ -623,13 +445,13 @@ class TestCavityWallRecommensations: input_property5.age_band = "E" input_property5.insulation_wall_area = 77 input_property5.restricted_measures = False - input_property5.data = {"property-type": "Bungalow"} + input_property5.data = {"property-type": "Bungalow", "county": "Derbyshire", "built-form": "Detached"} assert input_property5.walls["is_cob"] recommender5 = WallRecommendations( property_instance=input_property5, - materials=internal_wall_insulation_parts + external_wall_insulation_parts + materials=materials ) assert not recommender5.recommendations @@ -637,22 +459,17 @@ class TestCavityWallRecommensations: recommender5.recommend() assert recommender5.recommendations - assert len(recommender5.recommendations) == 9 + assert len(recommender5.recommendations) == 5 assert recommender5.estimated_u_value == 0.8 assert np.isclose(recommender5.recommendations[0]["new_u_value"], 0.29) - assert np.isclose(recommender5.recommendations[0]["cost"], 6160) + assert np.isclose(recommender5.recommendations[0]["total"], 8665.040384000002) assert recommender5.recommendations[0]["parts"][0]["type"] == "external_wall_insulation" - assert recommender5.recommendations[0]["parts"][0]["depths"] == [80] + assert recommender5.recommendations[0]["parts"][0]["depth"] == 50 assert np.isclose(recommender5.recommendations[3]["new_u_value"], 0.26) - assert np.isclose(recommender5.recommendations[3]["cost"], 7700) - assert recommender5.recommendations[3]["parts"][0]["type"] == "external_wall_insulation" - assert recommender5.recommendations[3]["parts"][0]["depths"] == [100] - - assert np.isclose(recommender5.recommendations[6]["new_u_value"], 0.26) - assert np.isclose(recommender5.recommendations[6]["cost"], 7700) - assert recommender5.recommendations[6]["parts"][0]["type"] == "internal_wall_insulation" - assert recommender5.recommendations[6]["parts"][0]["depths"] == [100] + assert np.isclose(recommender5.recommendations[3]["total"], 20078.742992) + assert recommender5.recommendations[3]["parts"][0]["type"] == "internal_wall_insulation" + assert recommender5.recommendations[3]["parts"][0]["depth"] == 100 def test_sandstone_or_limestone_wall(self): input_property6 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock()) @@ -670,13 +487,13 @@ class TestCavityWallRecommensations: input_property6.age_band = "F" input_property6.insulation_wall_area = 350 input_property6.restricted_measures = False - input_property6.data = {"property-type": "House"} + input_property6.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Mid-Terrace"} assert input_property6.walls["is_sandstone_or_limestone"] recommender6 = WallRecommendations( property_instance=input_property6, - materials=internal_wall_insulation_parts + external_wall_insulation_parts + materials=materials ) assert not recommender6.recommendations @@ -684,19 +501,19 @@ class TestCavityWallRecommensations: recommender6.recommend() assert recommender6.recommendations - assert len(recommender6.recommendations) == 6 + assert len(recommender6.recommendations) == 9 assert recommender6.estimated_u_value == 1 - assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.29) - assert np.isclose(recommender6.recommendations[0]["cost"], 31500) + assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.19) + assert np.isclose(recommender6.recommendations[0]["total"], 44829.0584) assert recommender6.recommendations[0]["parts"][0]["type"] == "external_wall_insulation" - assert recommender6.recommendations[0]["parts"][0]["depths"] == [90] + assert recommender6.recommendations[0]["parts"][0]["depth"] == 100 - assert np.isclose(recommender6.recommendations[2]["new_u_value"], 0.28) - assert np.isclose(recommender6.recommendations[2]["cost"], 35000) + assert np.isclose(recommender6.recommendations[2]["new_u_value"], 0.21) + assert np.isclose(recommender6.recommendations[2]["total"], 116436.25280000002) assert recommender6.recommendations[2]["parts"][0]["type"] == "external_wall_insulation" - assert recommender6.recommendations[2]["parts"][0]["depths"] == [100] + assert recommender6.recommendations[2]["parts"][0]["depth"] == 150 assert np.isclose(recommender6.recommendations[4]["new_u_value"], 0.28) - assert np.isclose(recommender6.recommendations[4]["cost"], 35000) + assert np.isclose(recommender6.recommendations[4]["total"], 91267.0136) assert recommender6.recommendations[4]["parts"][0]["type"] == "internal_wall_insulation" - assert recommender6.recommendations[4]["parts"][0]["depths"] == [100] + assert recommender6.recommendations[4]["parts"][0]["depth"] == 100 From 82432c0593d11c9d18138595ead2b1b887578d80 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 18:24:24 +0000 Subject: [PATCH 20/23] fixed ventilation recommendations --- recommendations/Recommendations.py | 3 +-- recommendations/VentilationRecommendations.py | 2 +- .../tests/test_ventilation_recommendations.py | 26 +++++++------------ 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index cdefb6ed..a169b788 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -31,8 +31,7 @@ class Recommendations: self.wall_recomender = WallRecommendations(property_instance=property_instance, materials=materials) self.roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials) self.ventilation_recomender = VentilationRecommendations( - property_instance=property_instance, - materials=[part for part in materials if part["type"] == "mechanical_ventilation"] + property_instance=property_instance, materials=materials ) self.fireplace_recommender = FireplaceRecommendations(property_instance=property_instance) self.lighting_recommender = LightingRecommendations(property_instance=property_instance, materials=materials) diff --git a/recommendations/VentilationRecommendations.py b/recommendations/VentilationRecommendations.py index ef24084f..6c61f27c 100644 --- a/recommendations/VentilationRecommendations.py +++ b/recommendations/VentilationRecommendations.py @@ -27,7 +27,7 @@ class VentilationRecommendations(Definitions): self.has_ventilaion = None self.recommendation = None - self.materials = materials + self.materials = [part for part in materials if part["type"] == "mechanical_ventilation"] def identify_ventilation(self): self.has_ventilaion = self.property.data["mechanical-ventilation"] in self.VENTILATION_DESCRIPTIONS diff --git a/recommendations/tests/test_ventilation_recommendations.py b/recommendations/tests/test_ventilation_recommendations.py index 2dcaba57..893bb01a 100644 --- a/recommendations/tests/test_ventilation_recommendations.py +++ b/recommendations/tests/test_ventilation_recommendations.py @@ -1,15 +1,7 @@ from backend.Property import Property from unittest.mock import Mock from recommendations.VentilationRecommendations import VentilationRecommendations - -ventilation_materials = [ - { - 'id': 17, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', - 'depths': None, 'depth_unit': None, 'cost': 500, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, - 'r_value_unit': None, 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': None, 'is_active': True, 'estimated_cost': 1000, 'quantity': 2, 'quantity_unit': None - } -] +from recommendations.tests.test_data.materials import materials class TestVentilationRecommendations: @@ -20,7 +12,7 @@ class TestVentilationRecommendations: recommender = VentilationRecommendations( property_instance=input_property1, - materials=ventilation_materials + materials=materials ) assert not recommender.recommendation @@ -29,7 +21,7 @@ class TestVentilationRecommendations: assert len(recommender.recommendation) == 1 - assert recommender.recommendation[0]["cost"] == 1000 + assert recommender.recommendation[0]["total"] == 1000 assert recommender.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender.recommendation[0]["parts"]) == 1 assert recommender.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' @@ -41,7 +33,7 @@ class TestVentilationRecommendations: recommender2 = VentilationRecommendations( property_instance=input_property2, - materials=ventilation_materials + materials=materials ) assert not recommender2.recommendation @@ -50,7 +42,7 @@ class TestVentilationRecommendations: assert len(recommender2.recommendation) == 1 - assert recommender2.recommendation[0]["cost"] == 1000 + assert recommender2.recommendation[0]["total"] == 1000 assert recommender2.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender2.recommendation[0]["parts"]) == 1 assert recommender2.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' @@ -62,7 +54,7 @@ class TestVentilationRecommendations: recommender3 = VentilationRecommendations( property_instance=input_property3, - materials=ventilation_materials + materials=materials ) assert not recommender3.recommendation @@ -71,7 +63,7 @@ class TestVentilationRecommendations: assert len(recommender3.recommendation) == 1 - assert recommender3.recommendation[0]["cost"] == 1000 + assert recommender3.recommendation[0]["total"] == 1000 assert recommender3.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender3.recommendation[0]["parts"]) == 1 assert recommender3.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' @@ -83,7 +75,7 @@ class TestVentilationRecommendations: recommender4 = VentilationRecommendations( property_instance=input_property4, - materials=ventilation_materials + materials=materials ) assert not recommender4.recommendation @@ -99,7 +91,7 @@ class TestVentilationRecommendations: recommender5 = VentilationRecommendations( property_instance=input_property5, - materials=ventilation_materials + materials=materials ) assert not recommender5.recommendation From 24789fbfccd3ce0080b505b7f37d7a15664d71e7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 18:27:58 +0000 Subject: [PATCH 21/23] fixed recommendation utils --- recommendations/tests/test_recommendation_utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py index 73796979..69f0d1b6 100644 --- a/recommendations/tests/test_recommendation_utils.py +++ b/recommendations/tests/test_recommendation_utils.py @@ -42,10 +42,12 @@ class TestRecommendationUtils: assert recommendation_utils.update_lowest_selected_u_value(1, 0.5) == 0.5 def test_get_recommended_part(self): - part = {'depths': [1, 2, 3]} + part = {'description': "some insulation material"} + assert recommendation_utils.get_recommended_part( - part=part, selected_depth=1, selected_total_cost=50, quantity=99, quantity_unit="m2" - ) == {'depths': [1], 'estimated_cost': 50, 'quantity': 99, 'quantity_unit': QuantityUnits.m2.value} + part=part, cost_result={"cost_result": 123}, quantity=99, quantity_unit="m2" + ) == {'description': "some insulation material", 'quantity': 99, 'quantity_unit': QuantityUnits.m2.value, + "cost_result": 123} def test_get_roof_u_value(self): # Test case 1: Insulation thickness is known and is_loft is True From b30dfd9eadf4d511360fde6177a9f46789ef8691 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 2 Dec 2023 18:56:17 +0000 Subject: [PATCH 22/23] added some basic lighting tests --- .../tests/test_lighting_recommendations.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 recommendations/tests/test_lighting_recommendations.py diff --git a/recommendations/tests/test_lighting_recommendations.py b/recommendations/tests/test_lighting_recommendations.py new file mode 100644 index 00000000..06d1163f --- /dev/null +++ b/recommendations/tests/test_lighting_recommendations.py @@ -0,0 +1,47 @@ +import pytest +from unittest.mock import Mock +from backend.Property import Property +from recommendations.LightingRecommendations import LightingRecommendations + +from recommendations.tests.test_data.materials import materials + + +class TestLightingRecommendations: + + def test_init_invalid_materials(self): + input_property0 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock()) + input_property0.lighting = {"low_energy_proportion": 0} + input_property0.data = {"county": "Greater London Authority"} + # Test for invalid materials + with pytest.raises(ValueError): + LightingRecommendations(input_property0, []) + + def test_recommend_no_action_needed(self): + # Case where no recommendation is needed + input_property1 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock()) + input_property1.lighting = {"low_energy_proportion": 100} + input_property1.data = {"county": "Greater London Authority"} + + lr = LightingRecommendations(input_property1, materials) + lr.recommend() + assert lr.recommendation == [] + + def test_recommend_action_needed(self): + # Case where recommendation is needed + input_property1 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock()) + input_property1.lighting = {"low_energy_proportion": 100} + input_property1.data = {"county": "Greater London Authority"} + input_property1.lighting = {"low_energy_proportion": 0.80} + input_property1.number_lighting_outlets = 20 + + lr = LightingRecommendations(input_property1, materials) + lr.recommend() + assert len(lr.recommendation) == 1 + + assert lr.recommendation == [ + {'parts': [], 'type': 'low_energy_lighting', 'description': 'Install low energy lighting in 4 outlets', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': 0.4, 'total': 458.976, 'subtotal': 382.48, + 'vat': 76.49600000000001, 'contingency': 27.320000000000007, 'preliminaries': 27.320000000000007, + 'material': 80.0, 'profit': 54.640000000000015, 'labour_hours': 3.2, 'labour_days': 0.4, + 'labour_cost': 193.20000000000002} + ] From b232d9143977a9442e85f1d7fa0546bffcc06dd1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 4 Dec 2023 15:05:10 +0000 Subject: [PATCH 23/23] Fixed unit tests for recommendations where we have new cost data - to be complete --- etl/testing_data/birmingham_pilot.py | 79 +++ recommendations/Costs.py | 17 +- recommendations/county_to_region.py | 27 +- .../tests/test_floor_recommendations.py | 379 +++++-------- .../tests/test_roof_recommendations.py | 535 ++++++++---------- 5 files changed, 495 insertions(+), 542 deletions(-) create mode 100644 etl/testing_data/birmingham_pilot.py diff --git a/etl/testing_data/birmingham_pilot.py b/etl/testing_data/birmingham_pilot.py new file mode 100644 index 00000000..ab39df7e --- /dev/null +++ b/etl/testing_data/birmingham_pilot.py @@ -0,0 +1,79 @@ +""" +This script will create an input csv for the recommendation engine and upload it to S3, which can be used for +testing +""" +import os + +import numpy as np +import pandas as pd +from epc_api.client import EpcClient +from utils.s3 import save_csv_to_s3 + +FILE_SIZE = 5 +EPC_AUTH_TOKEN = os.getenv("EPC_AUTH_TOKEN", None) +USER_ID = 8 +PORTFOLIO_ID = 54 + + +def app(): + # For this dataset, we want 3 properties, all hourses. A mid-terrace, and end-terrace and a semi-detached + + epc_client = EpcClient(auth_token=EPC_AUTH_TOKEN) + + # Birmingham has a Local Authority Code of E08000025 + + # Let's take an EPC D property + example_1_reponse = epc_client.domestic.search( + params={ + "local-authority": "E08000025", + "property-type": "house", + } + ) + + g_data = epc_client.domestic.search(params={"energy-band": "g"}, size=n_g) + f_data = epc_client.domestic.search(params={"energy-band": "f"}, size=n_f) + e_data = epc_client.domestic.search(params={"energy-band": "e"}, size=n_e) + d_data = epc_client.domestic.search(params={"energy-band": "d"}, size=n_d) + c_data = epc_client.domestic.search(params={"energy-band": "c"}, size=n_c) + b_data = epc_client.domestic.search(params={"energy-band": "b"}, size=n_b) + a_data = epc_client.domestic.search(params={"energy-band": "a"}, size=n_a) + + # Combine the final data + final_data = ( + g_data["rows"] + f_data["rows"] + e_data["rows"] + d_data["rows"] + c_data["rows"] + b_data["rows"] + + a_data["rows"] + ) + + # TODO: We also take homes with just a specific type of wall + + final_data = [ + x for x in final_data if ("cavity wall" in x["walls-description"].lower()) or ( + "solid brick" in x["walls-description"].lower() + ) or ("average thermal transmittance" in x["walls-description"].lower()) + ] + + # TODO: For the moment, don't use park homes + final_csv_data = pd.DataFrame( + [{"address": x["address"], "postcode": x["postcode"], "Notes": None} for x + in final_data if + x["property-type"] not in ["Park home"]] + ) + + final_csv_data = pd.concat([starting_csv, final_csv_data]).reset_index(drop=True) + + # Store the data in s3 + filename = f"{USER_ID}/{PORTFOLIO_ID}/test_inputs.csv" + save_csv_to_s3( + dataframe=final_csv_data, + bucket_name="retrofit-plan-inputs-dev", + file_name=filename + ) + + body = { + "portfolio_id": str(PORTFOLIO_ID), + "housing_type": "Social", + "goal": "Increase EPC", + "goal_value": "B", + "trigger_file_path": filename + } + print(body) diff --git a/recommendations/Costs.py b/recommendations/Costs.py index e896e1b5..02d26c14 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -5,14 +5,14 @@ from recommendations.county_to_region import county_to_region_map regional_labour_variations = [ {"Region": "Outer London", "Adjustment_Factor": 1.00}, {"Region": "Inner London", "Adjustment_Factor": 1.05}, - {"Region": "South East", "Adjustment_Factor": 0.96}, - {"Region": "South West", "Adjustment_Factor": 0.90}, + {"Region": "South East England", "Adjustment_Factor": 0.96}, + {"Region": "South West England", "Adjustment_Factor": 0.90}, {"Region": "East of England", "Adjustment_Factor": 0.93}, {"Region": "East Midlands", "Adjustment_Factor": 0.88}, {"Region": "West Midlands", "Adjustment_Factor": 0.87}, {"Region": "North East England", "Adjustment_Factor": 0.83}, {"Region": "North West England", "Adjustment_Factor": 0.88}, - {"Region": "Yorkshire and Humberside", "Adjustment_Factor": 0.86}, + {"Region": "Yorkshire and the Humber", "Adjustment_Factor": 0.86}, {"Region": "Wales", "Adjustment_Factor": 0.88}, {"Region": "Scotland", "Adjustment_Factor": 0.88}, {"Region": "Northern Ireland", "Adjustment_Factor": 0.76} @@ -71,13 +71,16 @@ class Costs: self.property = property_instance self.regional_labour_variations = regional_labour_variations - self.county = county_to_region_map.get(self.property.data["county"], None) - if self.county is None: - raise ValueError("County not found in county map") + self.region = county_to_region_map.get(self.property.data["county"], None) + if self.region is None: + # Try and grab using the local-authority-label + self.region = county_to_region_map.get(self.property.data["local-authority-label"], None) + if self.region is None: + raise ValueError("Region not found in county map") self.labour_adjustment_factor = [ x["Adjustment_Factor"] for x in self.regional_labour_variations if - x["Region"] == self.county + x["Region"] == self.region ][0] if not self.labour_adjustment_factor: diff --git a/recommendations/county_to_region.py b/recommendations/county_to_region.py index 3379247f..a881ea01 100644 --- a/recommendations/county_to_region.py +++ b/recommendations/county_to_region.py @@ -35,15 +35,6 @@ county_to_region_map = { 'Tendring': 'East of England', 'Three Rivers': 'East of England', 'Thurrock': 'East of England', 'Uttlesford': 'East of England', 'Watford': 'East of England', 'Waveney': 'East of England', 'Welwyn Hatfield': 'East of England', - # 'Barking and Dagenham': 'London', 'Barnet': 'London', 'Bexley': 'London', - # 'Brent': 'London', 'Bromley': 'London', 'Camden': 'London', 'City of London': 'London', - # 'City of Westminster': 'London', 'Croydon': 'London', 'Ealing': 'London', 'Enfield': 'London', - # 'Greater London': 'London', 'Greenwich': 'London', 'Hackney': 'London', 'Hammersmith and Fulham': 'London', - # 'Haringey': 'London', 'Harrow': 'London', 'Havering': 'London', 'Hillingdon': 'London', 'Hounslow': 'London', - # 'Islington': 'London', 'Kensington and Chelsea': 'London', 'Kingston upon Thames': 'London', 'Lambeth': 'London', - # 'Lewisham': 'London', 'Merton': 'London', 'Newham': 'London', 'Redbridge': 'London', 'Richmond': 'London', - # 'Southwark': 'London', 'Sutton': 'London', 'Tower Hamlets': 'London', 'Waltham Forest': 'London', - # 'Wandsworth': 'London', 'Westminster': 'London', 'County Durham': 'North East England', 'Darlington': 'North East England', 'Durham': 'North East England', 'Gateshead': 'North East England', 'Hartlepool': 'North East England', 'Middlesbrough': 'North East England', @@ -168,5 +159,21 @@ county_to_region_map = { 'York': 'Yorkshire and the Humber', # Additional mappings requried, based on what we find in the EPC database - 'Greater London Authority': 'Inner London' + 'Greater London Authority': 'Inner London', + # We have a bunch of inner London local authority mappings, which can be used if the county is not found + 'Barking and Dagenham': 'Inner London', 'Barnet': 'Inner London', 'Bexley': 'Inner London', + 'Brent': 'Inner London', 'Bromley': 'Inner London', 'Camden': 'Inner London', 'City of London': 'Inner London', + 'City of Westminster': 'Inner London', 'Croydon': 'Inner London', 'Ealing': 'Inner London', + 'Enfield': 'Inner London', + 'Greater London': 'Inner London', 'Greenwich': 'Inner London', 'Hackney': 'Inner London', + 'Hammersmith and Fulham': 'Inner London', + 'Haringey': 'Inner London', 'Harrow': 'Inner London', 'Havering': 'Inner London', 'Hillingdon': 'Inner London', + 'Hounslow': 'Inner London', + 'Islington': 'Inner London', 'Kensington and Chelsea': 'Inner London', 'Kingston upon Thames': 'Inner London', + 'Lambeth': 'Inner London', + 'Lewisham': 'Inner London', 'Merton': 'Inner London', 'Newham': 'Inner London', 'Redbridge': 'Inner London', + 'Richmond': 'Inner London', + 'Southwark': 'Inner London', 'Sutton': 'Inner London', 'Tower Hamlets': 'Inner London', + 'Waltham Forest': 'Inner London', + 'Wandsworth': 'Inner London', 'Westminster': 'Inner London', } diff --git a/recommendations/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py index 82ba7cf4..01bd308e 100644 --- a/recommendations/tests/test_floor_recommendations.py +++ b/recommendations/tests/test_floor_recommendations.py @@ -3,90 +3,15 @@ import pytest import os from unittest.mock import Mock from recommendations.FloorRecommendations import FloorRecommendations +from recommendations.tests.test_data.materials import materials from backend.Property import Property + # with open( # os.path.abspath(os.path.dirname(__file__)) + "/recommendations/tests/test_data/input_properties.pkl", "rb" # ) as f: # input_properties = pickle.load(f) -suspended_floor_insulation_parts = [ - { - # Example product - # https://www.insulationsuperstore.co.uk/product/recticel-eurothane-general-purpose-pir-insulation-board-2400 - # -x-1200-x-100mm.html - # All product data_types here: - # https://www.insulationsuperstore.co.uk/browse/insulation/brand/recticel/filterby/application/floors.html - "type": "suspended_floor_insulation", - "description": "Rigid Insulation Foam Boards", - "depths": [25, 30, 40, 50, 60, 70, 75, 80, 90, 100, 110, 120, 130, 140, 150], - "depth_unit": "mm", - "cost": [25, 30, 40, 50, 60, 70, 75, 80, 90, 100, 110, 120, 130, 140, 150], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.04545454545454546, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.022, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - { - # Example product - # https://www.insulationsuperstore.co.uk/product/rockwool-rwa45-acoustic-insulation-slab-100mm-2-88m2-pack.html - # All product data_types here: - # https://www.insulationsuperstore.co.uk/browse/insulation/brand/rockwool/filterby/application/floors - # /material/mineral-wool.html - "type": "suspended_floor_insulation", - "description": "Mineral Wool Floor Insulation", - "depths": [25, 40, 50, 60, 75, 100], - "depth_unit": "mm", - "cost": [25, 40, 50, 60, 75, 100], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.02857142857142857, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.035, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, -] - -solid_floor_insulation_parts = [ - { - # Example product - # https://www.insulationexpress.co.uk/floor-insulation/solid-floor-insulation/k103-100mm - # All product data_types here: - # https://www.insulationexpress.co.uk/floor-insulation/solid-floor-insulation?brand=7015&p=1 - # Example screed https://www.screwfix.com/p/mapei-ultraplan-3240-self-levelling-compound-25kg/4959f - "type": "solid_floor_insulation", - "description": "Rigid Insulation Foam Boards with floor screed", - "depths": [25, 50, 70, 75, 100], - "depth_unit": "mm", - "cost": [25, 40, 50, 60, 75, 100], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.04545454545454546, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.052631578947368425, - "thermal_conductivity_unit": "watt_per_meter_kelvin" - }, - -] - -exposed_floor_insulation_parts = [ - { - "type": "exposed_floor_insulation", - "description": "Rockwool Stone Wool insulation", - "depths": [50, 100, 140], - "depth_unit": "mm", - "cost": [8, 11, 15], - "cost_unit": "gbp_sq_meter", - "r_value_per_mm": 0.026315789473684213, - "r_value_unit": "square_meter_kelvin_per_watt", - "thermal_conductivity": 0.038, - "thermal_conductivity_unit": "watt_per_meter_kelvin", - "link": "https://insulation4less.co.uk/products/rockwool-flexi-slab-all-sizes?variant=33409590853685" - }, -] - -parts = suspended_floor_insulation_parts + solid_floor_insulation_parts + exposed_floor_insulation_parts - - class TestFloorRecommendations: @pytest.fixture @@ -100,26 +25,29 @@ class TestFloorRecommendations: def mock_floor_rec_instance(self): # Creating a mock instance of WallRecommendations with the necessary attributes property_mock = Mock() - property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"} # or any date you want - property_mock.data = {"construction-age-band": "1950"} # or any other data that fits your tests + property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"} + property_mock.data = {"county": "York"} - mock_wall_rec_instance = FloorRecommendations(property_mock, parts) + mock_wall_rec_instance = FloorRecommendations(property_mock, materials) return mock_wall_rec_instance def test_init(self, input_properties): + input_properties[0].insulation_floor_area = 50 + input_properties[0].insulation_wall_area = 90 obj = FloorRecommendations( property_instance=input_properties[0], - materials=parts + materials=materials ) assert obj assert obj.property def test_other_premises_below(self, input_properties): - input_properties[0].floor_area = 100 + input_properties[0].insulation_floor_area = 100 + input_properties[0].insulation_wall_area = 999 input_properties[0].number_of_floors = 1 recommender = FloorRecommendations( property_instance=input_properties[0], - materials=parts + materials=materials ) recommender.recommend() assert recommender.property.floor["another_property_below"] @@ -132,7 +60,8 @@ class TestFloorRecommendations: :return: """ - input_properties[2].floor_area = 50 + input_properties[2].insulation_floor_area = 50 + input_properties[2].insulation_wall_area = 50 input_properties[2].walls["is_park_home"] = False input_properties[2].age_band = "A" input_properties[2].perimeter = 20 @@ -140,10 +69,7 @@ class TestFloorRecommendations: input_properties[2].floor_type = "suspended" input_properties[2].number_of_floors = 1 - recommender = FloorRecommendations( - property_instance=input_properties[2], - materials=parts - ) + recommender = FloorRecommendations(property_instance=input_properties[2], materials=materials) assert recommender.estimated_u_value is None recommender.recommend() assert recommender.property.floor["is_suspended"] @@ -154,18 +80,20 @@ class TestFloorRecommendations: assert types == {"suspended_floor_insulation"} + assert len(recommender.recommendations) == 6 + assert recommender.recommendations[0]["total"] == 4596.858 + assert recommender.recommendations[0]["new_u_value"] == 0.21 + def test_uvalue_0_12(self, input_properties): """ This is a home that doesn't have a property below but it's highly performant already and therefore does not need floor insulation :return: """ - input_properties[3].floor_area = 100 + input_properties[3].insulation_floor_area = 100 + input_properties[3].insulation_wall_area = 100 input_properties[3].number_of_floors = 1 - recommender = FloorRecommendations( - property_instance=input_properties[3], - materials=parts - ) + recommender = FloorRecommendations(property_instance=input_properties[3], materials=materials) assert recommender.estimated_u_value is None recommender.recommend() assert not recommender.property.floor["is_suspended"] @@ -178,7 +106,8 @@ class TestFloorRecommendations: :return: """ - input_properties[4].floor_area = 100 + input_properties[4].insulation_floor_area = 100 + input_properties[4].insulation_wall_area = 100 input_properties[4].walls["is_park_home"] = False input_properties[4].age_band = "B" input_properties[4].perimeter = 50 @@ -186,10 +115,9 @@ class TestFloorRecommendations: input_properties[4].floor_type = "solid" input_properties[4].number_of_floors = 1 - recommender = FloorRecommendations( - property_instance=input_properties[4], - materials=parts - ) + # In this case, we have no county, so in this case, it should yse the local-authority-label if possible + input_properties[4].data["county"] = "" + recommender = FloorRecommendations(property_instance=input_properties[4], materials=materials) assert recommender.estimated_u_value is None recommender.recommend() assert not recommender.property.floor["is_suspended"] @@ -201,17 +129,22 @@ class TestFloorRecommendations: assert types == {"solid_floor_insulation"} + assert len(recommender.recommendations) == 3 + assert recommender.recommendations[2]["total"] == 14604.660000000002 + assert recommender.recommendations[2]["new_u_value"] == 0.21 + assert recommender.recommendations[2]["parts"][0]["depth"] == 75 + assert recommender.recommendations[2]["parts"][0]["depth"] == 75 + def test_another_dwelling_below(self, input_properties): """ This is another description we see when there is a property below """ - input_properties[6].floor_area = 100 + input_properties[6].insulation_floor_area = 100 + input_properties[6].insulation_wall_area = 1 + input_properties[6].number_of_floors = 1 - recommender = FloorRecommendations( - property_instance=input_properties[6], - materials=parts - ) + recommender = FloorRecommendations(property_instance=input_properties[6], materials=materials) assert recommender.estimated_u_value is None recommender.recommend() assert not recommender.property.floor["is_suspended"] @@ -219,123 +152,123 @@ class TestFloorRecommendations: assert recommender.estimated_u_value is None assert not recommender.recommendations - def test_exposed_floor_no_insulation(self): - input_property = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) - input_property.floor = { - 'original_description': 'To unheated space, no insulation (assumed)', - 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None, - 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True, - 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, - 'insulation_thickness': 'none' - } - input_property.age_band = "L" - input_property.set_floor_type() - input_property.data = {"floor-level": 0, "property-type": "House"} - input_property.floor_area = 100 - input_property.number_of_floors = 1 - - recommender = FloorRecommendations( - property_instance=input_property, - materials=exposed_floor_insulation_parts - ) - - assert not recommender.recommendations - - recommender.recommend() - - # Because of age band L, this should have a u-value of 0.22 to begin with and no recommendation - assert not len(recommender.recommendations) - assert recommender.estimated_u_value == 0.22 - - # Now with an older age band - - input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) - input_property2.floor = { - 'original_description': 'To unheated space, no insulation (assumed)', - 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None, - 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True, - 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, - 'insulation_thickness': 'none' - } - input_property2.age_band = "D" - input_property2.set_floor_type() - input_property2.data = {"floor-level": 0, "property-type": "House"} - input_property2.floor_area = 100 - input_property2.number_of_floors = 1 - - recommender2 = FloorRecommendations( - property_instance=input_property2, - materials=exposed_floor_insulation_parts - ) - - assert not recommender2.recommendations - - recommender2.recommend() - - assert len(recommender2.recommendations) == 1 - - assert recommender2.recommendations[0]["new_u_value"] == 0.23 - assert recommender2.recommendations[0]["starting_u_value"] == 1.2 - assert recommender2.recommendations[0]["cost"] == 1500 - - def test_exposed_floor_below_average_insulated(self): - input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) - input_property3.floor = { - 'original_description': 'To unheated space, below average insulation (assumed)', - 'clean_description': 'To unheated space, below average insulation', 'thermal_transmittance': None, - 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True, - 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, - 'insulation_thickness': 'below average' - } - input_property3.age_band = "C" - input_property3.set_floor_type() - input_property3.data = {"floor-level": 0, "property-type": "House"} - input_property3.floor_area = 100 - input_property3.number_of_floors = 1 - - recommender3 = FloorRecommendations( - property_instance=input_property3, - materials=exposed_floor_insulation_parts - ) - - assert not recommender3.recommendations - - recommender3.recommend() - - assert recommender3.estimated_u_value == 0.5 - - assert len(recommender3.recommendations) == 1 - - assert recommender3.recommendations[0]["new_u_value"] == 0.22 - assert recommender3.recommendations[0]["starting_u_value"] == 0.5 - assert recommender3.recommendations[0]["cost"] == 1100 - assert recommender3.recommendations[0]["parts"][0]["depths"] == [100] - - # With average insulation, no recommendations - - input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) - input_property4.floor = { - 'original_description': 'To unheated space, insulated (assumed)', - 'clean_description': 'To unheated space, insulated', 'thermal_transmittance': None, - 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True, - 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, - 'insulation_thickness': 'average' - } - input_property4.age_band = "C" - input_property4.set_floor_type() - input_property4.data = {"floor-level": 0, "property-type": "House"} - input_property4.floor_area = 100 - input_property4.number_of_floors = 1 - - recommender4 = FloorRecommendations( - property_instance=input_property4, - materials=exposed_floor_insulation_parts - ) - - assert not recommender4.recommendations - - recommender4.recommend() - - assert recommender4.estimated_u_value is None - - assert len(recommender4.recommendations) == 0 + # def test_exposed_floor_no_insulation(self): + # input_property = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) + # input_property.floor = { + # 'original_description': 'To unheated space, no insulation (assumed)', + # 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None, + # 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True, + # 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, + # 'insulation_thickness': 'none' + # } + # input_property.age_band = "L" + # input_property.set_floor_type() + # input_property.data = {"floor-level": 0, "property-type": "House"} + # input_property.floor_area = 100 + # input_property.number_of_floors = 1 + # + # recommender = FloorRecommendations( + # property_instance=input_property, + # materials=materials + # ) + # + # assert not recommender.recommendations + # + # recommender.recommend() + # + # # Because of age band L, this should have a u-value of 0.22 to begin with and no recommendation + # assert not len(recommender.recommendations) + # assert recommender.estimated_u_value == 0.22 + # + # # Now with an older age band + # + # input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) + # input_property2.floor = { + # 'original_description': 'To unheated space, no insulation (assumed)', + # 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None, + # 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True, + # 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, + # 'insulation_thickness': 'none' + # } + # input_property2.age_band = "D" + # input_property2.set_floor_type() + # input_property2.data = {"floor-level": 0, "property-type": "House"} + # input_property2.floor_area = 100 + # input_property2.number_of_floors = 1 + # + # recommender2 = FloorRecommendations( + # property_instance=input_property2, + # materials=materials + # ) + # + # assert not recommender2.recommendations + # + # recommender2.recommend() + # + # assert len(recommender2.recommendations) == 1 + # + # assert recommender2.recommendations[0]["new_u_value"] == 0.23 + # assert recommender2.recommendations[0]["starting_u_value"] == 1.2 + # assert recommender2.recommendations[0]["cost"] == 1500 + # + # def test_exposed_floor_below_average_insulated(self): + # input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) + # input_property3.floor = { + # 'original_description': 'To unheated space, below average insulation (assumed)', + # 'clean_description': 'To unheated space, below average insulation', 'thermal_transmittance': None, + # 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True, + # 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, + # 'insulation_thickness': 'below average' + # } + # input_property3.age_band = "C" + # input_property3.set_floor_type() + # input_property3.data = {"floor-level": 0, "property-type": "House"} + # input_property3.floor_area = 100 + # input_property3.number_of_floors = 1 + # + # recommender3 = FloorRecommendations( + # property_instance=input_property3, + # materials=materials + # ) + # + # assert not recommender3.recommendations + # + # recommender3.recommend() + # + # assert recommender3.estimated_u_value == 0.5 + # + # assert len(recommender3.recommendations) == 1 + # + # assert recommender3.recommendations[0]["new_u_value"] == 0.22 + # assert recommender3.recommendations[0]["starting_u_value"] == 0.5 + # assert recommender3.recommendations[0]["cost"] == 1100 + # assert recommender3.recommendations[0]["parts"][0]["depths"] == [100] + # + # # With average insulation, no recommendations + # + # input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock()) + # input_property4.floor = { + # 'original_description': 'To unheated space, insulated (assumed)', + # 'clean_description': 'To unheated space, insulated', 'thermal_transmittance': None, + # 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True, + # 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, + # 'insulation_thickness': 'average' + # } + # input_property4.age_band = "C" + # input_property4.set_floor_type() + # input_property4.data = {"floor-level": 0, "property-type": "House"} + # input_property4.floor_area = 100 + # input_property4.number_of_floors = 1 + # + # recommender4 = FloorRecommendations( + # property_instance=input_property4, + # materials=materials + # ) + # + # assert not recommender4.recommendations + # + # recommender4.recommend() + # + # assert recommender4.estimated_u_value is None + # + # assert len(recommender4.recommendations) == 0 diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py index 551407da..80591970 100644 --- a/recommendations/tests/test_roof_recommendations.py +++ b/recommendations/tests/test_roof_recommendations.py @@ -1,65 +1,7 @@ from backend.Property import Property from unittest.mock import Mock from recommendations.RoofRecommendations import RoofRecommendations - -loft_insulation_materials = [ - { - 'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation', - 'depths': [270, 300], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter', - 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/', - 'is_active': True - } -] - -loft_insulation_materials_50mm_existing = [ - { - 'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation', - 'depths': [220, 210], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter', - 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/', - 'is_active': True - } -] - -loft_insulation_materials_150mm_existing = [ - { - 'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation', - 'depths': [130, 119], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter', - 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/', - 'is_active': True - } -] - -room_roof_insulation_materials = [ - { - 'id': 18, - 'type': 'room_roof_insulation', - 'description': 'Example room roof insulation', - 'depths': [50, 150, 220, 270, 300], 'depth_unit': 'mm', 'cost': [9, 10, 11, 12, 13], - 'cost_unit': 'gbp_sq_meter', - 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': None, 'is_active': True - } -] - -flat_roof_insulation_materials = [ - { - 'id': 18, - 'type': 'flat_roof_insulation', - 'description': 'Example flat roof insulation', - 'depths': [50, 150, 220, 270, 300], 'depth_unit': 'mm', 'cost': [9, 10, 11, 12, 13], - 'cost_unit': 'gbp_sq_meter', - 'r_value_per_mm': 0.032727273, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': None, 'is_active': True - } -] +from recommendations.tests.test_data.materials import materials class TestRoofRecommendations: @@ -67,7 +9,7 @@ class TestRoofRecommendations: def test_loft_insulation_recommendation_no_insulation(self): property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) property_instance.age_band = "F" - property_instance.floor_area = 100 + property_instance.insulation_floor_area = 100 property_instance.roof = { 'original_description': 'Pitched, no insulation (assumed)', 'clean_description': 'Pitched, no insulation', @@ -77,8 +19,11 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance.data = { + "county": "Cambridgeshire", + } - roof_recommender = RoofRecommendations(property_instance=property_instance, materials=loft_insulation_materials) + roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials) assert not roof_recommender.recommendations @@ -89,7 +34,7 @@ class TestRoofRecommendations: def test_loft_insulation_recommendation_50mm_insulation(self): property_instance2 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) property_instance2.age_band = "F" - property_instance2.floor_area = 100 + property_instance2.insulation_floor_area = 100 property_instance2.roof = { 'original_description': 'Pitched, 50mm loft insulation (assumed)', 'clean_description': 'Pitched, 50mm loft insulation', @@ -99,10 +44,9 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance2.data = {"county": "Kent"} - roof_recommender2 = RoofRecommendations( - property_instance=property_instance2, materials=loft_insulation_materials - ) + roof_recommender2 = RoofRecommendations(property_instance=property_instance2, materials=materials) assert not roof_recommender2.recommendations @@ -110,13 +54,13 @@ class TestRoofRecommendations: assert len(roof_recommender2.recommendations) == 1 - assert roof_recommender2.recommendations[0]["cost"] == 900 + assert roof_recommender2.recommendations[0]["total"] == 1310.56464 assert roof_recommender2.recommendations[0]["new_u_value"] == 0.14 assert roof_recommender2.recommendations[0]["starting_u_value"] == 0.68 property_instance3 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) property_instance3.age_band = "F" - property_instance3.floor_area = 100 + property_instance3.insulation_floor_area = 100 property_instance3.roof = { 'original_description': 'Pitched, 50mm loft insulation (assumed)', 'clean_description': 'Pitched, 50mm loft insulation', @@ -126,24 +70,22 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance3.data = {"county": "Greater London Authority"} - roof_recommender3 = RoofRecommendations( - property_instance=property_instance3, materials=loft_insulation_materials_50mm_existing - ) + roof_recommender3 = RoofRecommendations(property_instance=property_instance3, materials=materials) assert not roof_recommender3.recommendations roof_recommender3.recommend() - # The 220mm insulation should be selected, not the 210 assert roof_recommender3.recommendations assert len(roof_recommender3.recommendations) == 1 - assert roof_recommender3.recommendations[0]["parts"][0]["depths"] == [220] + assert roof_recommender3.recommendations[0]["parts"][0]["depth"] == 270 def test_loft_insulation_recommendation_150mm_insulation(self): property_instance4 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) property_instance4.age_band = "F" - property_instance4.floor_area = 100 + property_instance4.insulation_floor_area = 100 property_instance4.roof = { 'original_description': 'Pitched, 150mm loft insulation (assumed)', 'clean_description': 'Pitched, 150mm loft insulation', @@ -153,24 +95,24 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance4.data = {"county": "North East Lincolnshire"} - roof_recommender4 = RoofRecommendations( - property_instance=property_instance4, materials=loft_insulation_materials - ) + roof_recommender4 = RoofRecommendations(property_instance=property_instance4, materials=materials) assert not roof_recommender4.recommendations roof_recommender4.recommend() - assert len(roof_recommender4.recommendations) == 1 + assert len(roof_recommender4.recommendations) == 4 - assert roof_recommender4.recommendations[0]["cost"] == 900 - assert roof_recommender4.recommendations[0]["new_u_value"] == 0.11 + assert roof_recommender4.recommendations[0]["total"] == 788.0544 + assert roof_recommender4.recommendations[0]["new_u_value"] == 0.15 assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3 + assert roof_recommender4.recommendations[0]["parts"][0]["depth"] == 150 property_instance5 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) property_instance5.age_band = "F" - property_instance5.floor_area = 100 + property_instance5.insulation_floor_area = 100 property_instance5.roof = { 'original_description': 'Pitched, 150mm loft insulation (assumed)', 'clean_description': 'Pitched, 150mm loft insulation', @@ -180,25 +122,24 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance5.data = {"county": "Somerset"} - roof_recommender5 = RoofRecommendations( - property_instance=property_instance5, materials=loft_insulation_materials_150mm_existing - ) + roof_recommender5 = RoofRecommendations(property_instance=property_instance5, materials=materials) assert not roof_recommender5.recommendations roof_recommender5.recommend() - # The 130mm insulation should be selected, not the 110 + # The 150mm insulation should be selected, since there it already 150mm assert roof_recommender5.recommendations - assert len(roof_recommender5.recommendations) == 1 - assert roof_recommender5.recommendations[0]["parts"][0]["depths"] == [130] + assert len(roof_recommender5.recommendations) == 4 + assert roof_recommender5.recommendations[0]["parts"][0]["depth"] == 150 def test_loft_insulation_recommendation_270mm_insulation(self): # We shouldn't recommend anything in this case property_instance6 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) property_instance6.age_band = "F" - property_instance6.floor_area = 100 + property_instance6.insulation_floor_area = 100 property_instance6.roof = { 'original_description': 'Pitched, 270mm loft insulation (assumed)', 'clean_description': 'Pitched, 270mm loft insulation', @@ -208,10 +149,9 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '270', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance6.data = {"county": "Portsmouth"} - roof_recommender6 = RoofRecommendations( - property_instance=property_instance6, materials=loft_insulation_materials - ) + roof_recommender6 = RoofRecommendations(property_instance=property_instance6, materials=materials) assert not roof_recommender6.recommendations @@ -219,219 +159,211 @@ class TestRoofRecommendations: assert len(roof_recommender6.recommendations) == 0 - def test_uninsulated_room_in_roof(self): - property_instance7 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) - property_instance7.age_band = "F" - property_instance7.floor_area = 100 - property_instance7.roof = { - 'original_description': 'Roof room(s), no insulation (assumed)', - 'clean_description': 'Roof room(s), no insulation', - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, - 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, - 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none' - } - - property_instance7.pitched_roof_area = 110 - - roof_recommender7 = RoofRecommendations( - property_instance=property_instance7, materials=room_roof_insulation_materials - ) - - assert not roof_recommender7.recommendations - - roof_recommender7.recommend() - - # Even though we have 3 depths, we only end with 1 due to diminishin returns - assert len(roof_recommender7.recommendations) == 1 - - assert roof_recommender7.recommendations[0]["parts"][0]["depths"] == [270] - - assert roof_recommender7.recommendations[0]["new_u_value"] == 0.14 - assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8 - assert roof_recommender7.recommendations[0]["description"] == \ - "Insulate your room roof with 270mm of Example room roof insulation" - - def test_ceiling_insulated_room_in_roof(self): - property_instance8 = Property(id=8, address1="fake", postcode="fake", epc_client=Mock()) - property_instance8.age_band = "F" - property_instance8.floor_area = 100 - property_instance8.roof = { - 'original_description': 'Roof room(s), ceiling insulated', - 'clean_description': 'Roof room(s), ceiling insulated', - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, - 'is_roof_room': True, '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': 'average' - } - - property_instance8.pitched_roof_area = 110 - - roof_recommender8 = RoofRecommendations( - property_instance=property_instance8, materials=room_roof_insulation_materials - ) - - assert not roof_recommender8.recommendations - - roof_recommender8.recommend() - - # No recommendations in this case - assert not roof_recommender8.recommendations - - def test_insulated_room_in_roof(self): - property_instance9 = Property(id=9, address1="fake", postcode="fake", epc_client=Mock()) - property_instance9.age_band = "F" - property_instance9.floor_area = 100 - property_instance9.roof = { - 'original_description': 'Roof room(s), insulated (assumed)', - 'clean_description': 'Roof room(s), insulated', - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, - 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, - 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' - } - - property_instance9.pitched_roof_area = 110 - - roof_recommender9 = RoofRecommendations( - property_instance=property_instance9, materials=room_roof_insulation_materials - ) - - assert not roof_recommender9.recommendations - - roof_recommender9.recommend() - - # No recommendations in this case - assert not roof_recommender9.recommendations - - def test_limited_insulated_room_in_roof(self): - property_instance10 = Property(id=10, address1="fake", postcode="fake", epc_client=Mock()) - property_instance10.age_band = "F" - property_instance10.floor_area = 100 - property_instance10.roof = { - 'original_description': 'Roof room(s), limited insulation (assumed)', - 'clean_description': 'Roof room(s), limited insulation', - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, - 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, - 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, - 'insulation_thickness': 'below average' - } - - property_instance10.pitched_roof_area = 110 - - roof_recommender10 = RoofRecommendations( - property_instance=property_instance10, materials=room_roof_insulation_materials - ) - - assert not roof_recommender10.recommendations - - roof_recommender10.recommend() - - assert len(roof_recommender10.recommendations) == 2 - - assert roof_recommender10.recommendations[0]["parts"][0]["depths"] == [220] - assert roof_recommender10.recommendations[1]["parts"][0]["depths"] == [270] - - assert roof_recommender10.recommendations[0]["new_u_value"] == 0.16 - assert roof_recommender10.recommendations[1]["new_u_value"] == 0.14 - - assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.8 - assert roof_recommender10.recommendations[1]["starting_u_value"] == 0.8 - - assert roof_recommender10.recommendations[0]["description"] == \ - "Insulate your room roof with 220mm of Example room roof insulation" - assert roof_recommender10.recommendations[1]["description"] == \ - "Insulate your room roof with 270mm of Example room roof insulation" - - def test_flat_no_insulation(self): - property_instance11 = Property(id=11, address1="fake", postcode="fake", epc_client=Mock()) - property_instance11.age_band = "D" - property_instance11.floor_area = 150 - property_instance11.roof = { - 'original_description': 'Flat, no insulation (assumed)', - 'clean_description': 'Flat, no insulation', - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, - 'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, - 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none' - } - - roof_recommender11 = RoofRecommendations( - property_instance=property_instance11, materials=flat_roof_insulation_materials - ) - - assert not roof_recommender11.recommendations - - roof_recommender11.recommend() - - assert len(roof_recommender11.recommendations) == 1 - - assert roof_recommender11.recommendations[0]["parts"][0]["depths"] == [270] - - assert roof_recommender11.recommendations[0]["new_u_value"] == 0.11 - - assert roof_recommender11.recommendations[0]["starting_u_value"] == 2.3 - - assert roof_recommender11.recommendations[0]["description"] == \ - "Insulate the home's flat roof with 270mm of Example flat roof insulation" - - def test_flat_insulated(self): - property_instance12 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock()) - property_instance12.age_band = "D" - property_instance12.floor_area = 150 - property_instance12.roof = { - 'original_description': 'Flat, insulated (assumed)', - 'clean_description': 'Flat, insulated', - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, - 'is_roof_room': False, - 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, - 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' - } - - roof_recommender12 = RoofRecommendations( - property_instance=property_instance12, materials=flat_roof_insulation_materials - ) - - assert not roof_recommender12.recommendations - - roof_recommender12.recommend() - - assert not roof_recommender12.recommendations - - def test_flat_limited_insulation(self): - property_instance13 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock()) - property_instance13.age_band = "D" - property_instance13.floor_area = 150 - property_instance13.roof = { - 'original_description': 'Flat, limited insulation (assumed)', - 'clean_description': 'Flat, limited insulation', - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, - 'is_roof_room': False, - 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, - 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average' - } - - roof_recommender13 = RoofRecommendations( - property_instance=property_instance13, materials=flat_roof_insulation_materials - ) - - assert not roof_recommender13.recommendations - - roof_recommender13.recommend() - - assert len(roof_recommender13.recommendations) == 1 - - assert roof_recommender13.recommendations[0]["parts"][0]["depths"] == [220] - - assert roof_recommender13.recommendations[0]["new_u_value"] == 0.14 - - assert roof_recommender13.recommendations[0]["starting_u_value"] == 2.3 - - assert roof_recommender13.recommendations[0]["description"] == \ - "Insulate the home's flat roof with 220mm of Example flat roof insulation" + # def test_uninsulated_room_in_roof(self): + # property_instance7 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + # property_instance7.age_band = "F" + # property_instance7.insulation_floor_area = 100 + # property_instance7.roof = { + # 'original_description': 'Roof room(s), no insulation (assumed)', + # 'clean_description': 'Roof room(s), no insulation', + # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, + # 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, + # 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none' + # } + # + # property_instance7.pitched_roof_area = 110 + # property_instance7.data = {"county": "Southampton"} + # + # roof_recommender7 = RoofRecommendations(property_instance=property_instance7, materials=materials) + # + # assert not roof_recommender7.recommendations + # + # roof_recommender7.recommend() + # + # # Even though we have 3 depths, we only end with 1 due to diminishin returns + # assert len(roof_recommender7.recommendations) == 1 + # + # assert roof_recommender7.recommendations[0]["parts"][0]["depths"] == [270] + # + # assert roof_recommender7.recommendations[0]["new_u_value"] == 0.14 + # assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8 + # assert roof_recommender7.recommendations[0]["description"] == \ + # "Insulate your room roof with 270mm of Example room roof insulation" + # + # def test_ceiling_insulated_room_in_roof(self): + # property_instance8 = Property(id=8, address1="fake", postcode="fake", epc_client=Mock()) + # property_instance8.age_band = "F" + # property_instance8.insulation_floor_area = 100 + # property_instance8.roof = { + # 'original_description': 'Roof room(s), ceiling insulated', + # 'clean_description': 'Roof room(s), ceiling insulated', + # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, + # 'is_roof_room': True, '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': 'average' + # } + # + # property_instance8.pitched_roof_area = 110 + # + # roof_recommender8 = RoofRecommendations(property_instance=property_instance8, materials=materials) + # + # assert not roof_recommender8.recommendations + # + # roof_recommender8.recommend() + # + # # No recommendations in this case + # assert not roof_recommender8.recommendations + # + # def test_insulated_room_in_roof(self): + # property_instance9 = Property(id=9, address1="fake", postcode="fake", epc_client=Mock()) + # property_instance9.age_band = "F" + # property_instance9.insulation_floor_area = 100 + # property_instance9.roof = { + # 'original_description': 'Roof room(s), insulated (assumed)', + # 'clean_description': 'Roof room(s), insulated', + # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, + # 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, + # 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' + # } + # + # property_instance9.pitched_roof_area = 110 + # property_instance9.data = {"county": "Rutland"} + # + # roof_recommender9 = RoofRecommendations(property_instance=property_instance9, materials=materials) + # + # assert not roof_recommender9.recommendations + # + # roof_recommender9.recommend() + # + # # No recommendations in this case + # assert not roof_recommender9.recommendations + # + # def test_limited_insulated_room_in_roof(self): + # property_instance10 = Property(id=10, address1="fake", postcode="fake", epc_client=Mock()) + # property_instance10.age_band = "F" + # property_instance10.insulation_floor_area = 100 + # property_instance10.roof = { + # 'original_description': 'Roof room(s), limited insulation (assumed)', + # 'clean_description': 'Roof room(s), limited insulation', + # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, + # 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, + # 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, + # 'insulation_thickness': 'below average' + # } + # + # property_instance10.pitched_roof_area = 110 + # property_instance10.data = {"county": "Westmorland"} + # + # roof_recommender10 = RoofRecommendations(property_instance=property_instance10, materials=materials) + # + # assert not roof_recommender10.recommendations + # + # roof_recommender10.recommend() + # + # assert len(roof_recommender10.recommendations) == 2 + # + # assert roof_recommender10.recommendations[0]["parts"][0]["depths"] == [220] + # assert roof_recommender10.recommendations[1]["parts"][0]["depths"] == [270] + # + # assert roof_recommender10.recommendations[0]["new_u_value"] == 0.16 + # assert roof_recommender10.recommendations[1]["new_u_value"] == 0.14 + # + # assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.8 + # assert roof_recommender10.recommendations[1]["starting_u_value"] == 0.8 + # + # assert roof_recommender10.recommendations[0]["description"] == \ + # "Insulate your room roof with 220mm of Example room roof insulation" + # assert roof_recommender10.recommendations[1]["description"] == \ + # "Insulate your room roof with 270mm of Example room roof insulation" + # + # def test_flat_no_insulation(self): + # property_instance11 = Property(id=11, address1="fake", postcode="fake", epc_client=Mock()) + # property_instance11.age_band = "D" + # property_instance11.insulation_floor_area = 150 + # property_instance11.roof = { + # 'original_description': 'Flat, no insulation (assumed)', + # 'clean_description': 'Flat, no insulation', + # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, + # 'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, + # 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none' + # } + # property_instance11.data = {"county": "Swindon"} + # + # roof_recommender11 = RoofRecommendations(property_instance=property_instance11, materials=materials) + # + # assert not roof_recommender11.recommendations + # + # roof_recommender11.recommend() + # + # assert len(roof_recommender11.recommendations) == 1 + # + # assert roof_recommender11.recommendations[0]["parts"][0]["depths"] == [270] + # + # assert roof_recommender11.recommendations[0]["new_u_value"] == 0.11 + # + # assert roof_recommender11.recommendations[0]["starting_u_value"] == 2.3 + # + # assert roof_recommender11.recommendations[0]["description"] == \ + # "Insulate the home's flat roof with 270mm of Example flat roof insulation" + # + # def test_flat_insulated(self): + # property_instance12 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock()) + # property_instance12.age_band = "D" + # property_instance12.insulation_floor_area = 150 + # property_instance12.roof = { + # 'original_description': 'Flat, insulated (assumed)', + # 'clean_description': 'Flat, insulated', + # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, + # 'is_roof_room': False, + # 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, + # 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' + # } + # property_instance12.data = {"county": "Thurrock"} + # + # roof_recommender12 = RoofRecommendations(property_instance=property_instance12, materials=materials) + # + # assert not roof_recommender12.recommendations + # + # roof_recommender12.recommend() + # + # assert not roof_recommender12.recommendations + # + # def test_flat_limited_insulation(self): + # property_instance13 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock()) + # property_instance13.age_band = "D" + # property_instance13.insulation_floor_area = 150 + # property_instance13.roof = { + # 'original_description': 'Flat, limited insulation (assumed)', + # 'clean_description': 'Flat, limited insulation', + # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False, + # 'is_roof_room': False, + # 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, + # 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average' + # } + # property_instance13.data = {"county": "Tyne and Wear"} + # + # roof_recommender13 = RoofRecommendations(property_instance=property_instance13, materials=materials) + # + # assert not roof_recommender13.recommendations + # + # roof_recommender13.recommend() + # + # assert len(roof_recommender13.recommendations) == 1 + # + # assert roof_recommender13.recommendations[0]["parts"][0]["depths"] == [220] + # + # assert roof_recommender13.recommendations[0]["new_u_value"] == 0.14 + # + # assert roof_recommender13.recommendations[0]["starting_u_value"] == 2.3 + # + # assert roof_recommender13.recommendations[0]["description"] == \ + # "Insulate the home's flat roof with 220mm of Example flat roof insulation" def test_property_above(self): property_instance14 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) property_instance14.age_band = "F" - property_instance14.floor_area = 100 + property_instance14.insulation_floor_area = 100 property_instance14.roof = { 'original_description': '(other premises above)', 'clean_description': '(other premises above)', 'thermal_transmittance': 0, @@ -440,10 +372,9 @@ class TestRoofRecommendations: 'is_assumed': False, 'has_dwelling_above': True, 'is_valid': True, 'insulation_thickness': None } + property_instance14.data = {"county": "Suffolk"} - roof_recommender14 = RoofRecommendations( - property_instance=property_instance14, materials=loft_insulation_materials - ) + roof_recommender14 = RoofRecommendations(property_instance=property_instance14, materials=materials) assert not roof_recommender14.recommendations