diff --git a/.idea/Model.iml b/.idea/Model.iml
index b0f9c00d..4413bb06 100644
--- a/.idea/Model.iml
+++ b/.idea/Model.iml
@@ -7,7 +7,7 @@
-
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 1122b380..6f308057 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -3,7 +3,7 @@
-
+
diff --git a/backend/Property.py b/backend/Property.py
index 1094e7b2..a3328156 100644
--- a/backend/Property.py
+++ b/backend/Property.py
@@ -12,7 +12,7 @@ from epc_api.client import EpcClient
from BaseUtility import Definitions
from recommendations.rdsap_tables import england_wales_age_band_lookup
from recommendations.recommendation_utils import (
- estimate_floors, estimate_perimeter, get_wall_type, estimate_wall_area, esimtate_pitched_roof_area
+ estimate_perimeter, get_wall_type, estimate_external_wall_area, esimtate_pitched_roof_area
)
ENVIRONMENT = os.environ.get('ENVIRONMENT', 'dev')
@@ -82,6 +82,7 @@ class Property(Definitions):
self.insulation_wall_area = None
self.floor_area = None
self.pitched_roof_area = None
+ self.insulation_floor_area = None
if epc_client:
self.epc_client = epc_client
@@ -288,10 +289,16 @@ class Property(Definitions):
for description, attribute in cleaned.items():
if self.data[description] in self.DATA_ANOMALY_MATCHES:
+ template = cleaned[description][0]
+ fill_dict = dict(zip(template.keys(), [None] * len(template)))
+ fill_dict.update({
+ "original_description": self.data[description],
+ "clean_description": self.data[description],
+ })
setattr(
self,
self.ATTRIBUTE_MAP[description],
- {"original_description": self.data[description], "clean_description": self.data[description]}
+ fill_dict,
)
continue
@@ -328,8 +335,28 @@ class Property(Definitions):
raise ValueError("Property does not contain data")
self.construction_age_band = DataProcessor.clean_construction_age_band(self.data["construction-age-band"])
+ if self.construction_age_band in self.DATA_ANOMALY_MATCHES:
+ if self.old_data:
+ # Take the most recent
+ max_datetime = max(
+ [x["lodgement-datetime"] for x in self.old_data if
+ x["construction-age-band"] not in self.DATA_ANOMALY_MATCHES]
+ )
+ most_recent = [x for x in self.old_data if x["lodgement-datetime"] == max_datetime]
+
+ self.construction_age_band = DataProcessor.clean_construction_age_band(
+ most_recent[0]["construction-age-band"]
+ )
+
self.age_band = england_wales_age_band_lookup.get(self.construction_age_band)
+ if (self.data["transaction-type"] == "new dwelling") and (self.age_band is None):
+ self.age_band = "L"
+ self.construction_age_band = 'England and Wales: 2012 onwards'
+
+ if self.age_band is None:
+ raise ValueError("age_band is missing")
+
def set_spatial(self, spatial: pd.DataFrame):
"""
Sets whether the property is in a conservation area given the output of the ConservationAreaClient
@@ -569,7 +596,7 @@ class Property(Definitions):
self.number_of_rooms = float(self.data["number-habitable-rooms"])
if self.data["property-type"] == "House":
- self.number_of_floors = estimate_floors(self.floor_area, self.number_of_rooms)
+ self.number_of_floors = 2
elif self.data["property-type"] in ["Flat", "Bungalow"]:
self.number_of_floors = 1
elif self.data["property-type"] == "Maisonette":
@@ -586,12 +613,17 @@ class Property(Definitions):
self.floor_area / self.number_of_floors, self.number_of_rooms / self.number_of_floors
)
- self.insulation_wall_area = estimate_wall_area(
- num_floors=self.number_of_floors, floor_height=self.floor_height, perimeter=self.perimeter
+ self.insulation_wall_area = estimate_external_wall_area(
+ num_floors=self.number_of_floors,
+ floor_height=self.floor_height,
+ perimeter=self.perimeter,
+ built_form=self.data["built-form"],
)
+ self.insulation_floor_area = self.floor_area / self.number_of_floors
+
self.pitched_roof_area = esimtate_pitched_roof_area(
- floor_area=self.floor_area / self.number_of_floors, floor_height=self.floor_height
+ floor_area=self.insulation_floor_area, floor_height=self.floor_height
)
def set_wall_type(self):
@@ -722,6 +754,7 @@ class Property(Definitions):
"TOTAL_FLOOR_AREA": self.floor_area,
**epc_raw_data,
"BUILT_FORM": built_form,
+ "POSTCODE": self.data["postcode"],
}
return property_data
diff --git a/backend/app/db/functions/materials_functions.py b/backend/app/db/functions/materials_functions.py
index f3c2f316..4d492946 100644
--- a/backend/app/db/functions/materials_functions.py
+++ b/backend/app/db/functions/materials_functions.py
@@ -1,4 +1,5 @@
from backend.app.db.models.materials import Material
+from backend.app.db.utils import row2dict
from functools import lru_cache
@@ -16,4 +17,6 @@ def get_materials(session):
materials = session.query(Material).filter(Material.is_active).all()
- return materials if materials else []
+ materials = materials if materials else []
+
+ return [row2dict(material) for material in materials]
diff --git a/backend/app/db/functions/portfolio_functions.py b/backend/app/db/functions/portfolio_functions.py
index 37b6bf37..08e15a32 100644
--- a/backend/app/db/functions/portfolio_functions.py
+++ b/backend/app/db/functions/portfolio_functions.py
@@ -8,6 +8,7 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int):
aggregates = (
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")
@@ -20,6 +21,7 @@ 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
}
diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py
index b9ec6fc3..34c4ef96 100644
--- a/backend/app/db/functions/recommendations_functions.py
+++ b/backend/app/db/functions/recommendations_functions.py
@@ -1,10 +1,14 @@
-from sqlalchemy import insert
+from sqlalchemy import insert, delete
+from sqlalchemy.orm import Session
from backend.app.db.models.recommendations import Plan, Recommendation, RecommendationMaterials, PlanRecommendations
+from backend.app.db.models.portfolio import PropertyModel, PropertyTargetsModel, PropertyDetailsMeter, \
+ PropertyDetailsEpcModel
-def create_plan(session, plan):
+def create_plan(session: Session, plan):
"""
This function will create a record for the plan in the database if it does not exist.
+ :param session: The database session
:param plan: dictionary of data representing a plan to be created
"""
@@ -15,7 +19,7 @@ def create_plan(session, plan):
return new_plan.id
-def create_recommendation(session, recommendation):
+def create_recommendation(session: Session, recommendation):
"""
This function will create a record for the recommendation in the database if it does not exist.
:param session: The database session
@@ -29,7 +33,7 @@ def create_recommendation(session, recommendation):
return new_recommendation.id
-def create_recommendation_material(session, recommendation_id, material_id, depth):
+def create_recommendation_material(session: Session, recommendation_id, material_id, depth):
"""
This function will create a record for the recommendation_material in the database if it does not exist.
:param session: The databse session
@@ -49,9 +53,10 @@ def create_recommendation_material(session, recommendation_id, material_id, dept
return new_recommendation_material.id
-def create_plan_recommendations(session, plan_id, recommendation_ids):
+def create_plan_recommendations(session: Session, plan_id, recommendation_ids):
"""
This function will create records for the plan_recommendation in the database.
+ :param session: The database session
:param plan_id: ID of the plan
:param recommendation_ids: list of recommendation IDs
"""
@@ -63,18 +68,19 @@ def create_plan_recommendations(session, plan_id, recommendation_ids):
session.execute(insert(PlanRecommendations).values(data))
-def upload_recommendations(session, recommendations_to_upload, property_id):
+def upload_recommendations(session: Session, recommendations_to_upload, property_id):
# Prepare data for bulk insert for Recommendation
recommendations_data = [
{
"property_id": property_id,
"type": rec["type"],
"description": rec["description"],
- "estimated_cost": rec["cost"],
+ "estimated_cost": rec["total"],
"default": rec["default"],
"starting_u_value": rec.get("starting_u_value"),
"new_u_value": rec.get("new_u_value"),
- "sap_points": rec["sap_points"]
+ "sap_points": rec["sap_points"],
+ "total_work_hours": rec["labour_hours"],
}
for rec in recommendations_to_upload
]
@@ -97,10 +103,10 @@ def upload_recommendations(session, recommendations_to_upload, property_id):
{
"recommendation_id": recommendation_id,
"material_id": part["id"],
- "depth": part["depths"][0] if part["depths"] else None,
+ "depth": int(part["depth"]) if part["depth"] else None,
"quantity": part["quantity"],
"quantity_unit": part["quantity_unit"],
- "estimated_cost": part["estimated_cost"],
+ "estimated_cost": part["total"],
}
for rec, recommendation_id in zip(recommendations_to_upload, uploaded_recommendation_ids)
for part in rec["parts"]
@@ -112,3 +118,39 @@ def upload_recommendations(session, recommendations_to_upload, property_id):
session.flush()
return uploaded_recommendation_ids
+
+
+def clear_portfolio(session: Session, portfolio_id: int):
+ # Fetch all property IDs associated with the given portfolio
+ property_ids = session.query(PropertyModel.id).filter(PropertyModel.portfolio_id == portfolio_id).all()
+ property_ids = [p.id for p in property_ids]
+
+ # Fetch all recommendation IDs associated with the properties
+ recommendation_ids = session.query(Recommendation.id).filter(Recommendation.property_id.in_(property_ids)).all()
+ recommendation_ids = [r.id for r in recommendation_ids]
+
+ # Delete all entries from RecommendationMaterials for these recommendations
+ session.execute(
+ delete(RecommendationMaterials).where(RecommendationMaterials.recommendation_id.in_(recommendation_ids))
+ )
+
+ # Delete all entries from PlanRecommendations that reference plans in the portfolio
+ session.execute(delete(PlanRecommendations).where(PlanRecommendations.plan_id.in_(
+ session.query(Plan.id).filter(Plan.portfolio_id == portfolio_id).subquery().as_scalar()
+ )))
+
+ # Delete all Plans associated with the portfolio
+ session.execute(delete(Plan).where(Plan.portfolio_id == portfolio_id))
+
+ # Delete all Recommendations associated with the properties
+ session.execute(delete(Recommendation).where(Recommendation.property_id.in_(property_ids)))
+
+ # Now, delete the PropertyModels and related details
+ # Delete PropertyTargetsModel, PropertyDetailsMeter, PropertyDetailsEpcModel, and PropertyModel
+ session.execute(delete(PropertyTargetsModel).where(PropertyTargetsModel.portfolio_id == portfolio_id))
+ # session.execute(delete(PropertyDetailsMeter).where(PropertyDetailsMeter.uprn.in_(property_ids)))
+ session.execute(delete(PropertyDetailsEpcModel).where(PropertyDetailsEpcModel.portfolio_id == portfolio_id))
+ session.execute(delete(PropertyModel).where(PropertyModel.portfolio_id == portfolio_id))
+
+ # Commit the changes
+ session.commit()
diff --git a/backend/app/db/models/materials.py b/backend/app/db/models/materials.py
index 1dc47276..e191c5ee 100644
--- a/backend/app/db/models/materials.py
+++ b/backend/app/db/models/materials.py
@@ -15,6 +15,23 @@ class MaterialType(enum.Enum):
cavity_wall_insulation = "cavity_wall_insulation"
mechanical_ventilation = "mechanical_ventilation"
loft_insulation = "loft_insulation"
+ exposed_floor_insulation = "exposed_floor_insulation"
+ flat_roof_insulation = "flat_roof_insulation"
+ room_roof_insulation = "room_roof_insulation"
+
+ iwi_wall_demolition = "iwi_wall_demolition"
+ iwi_vapour_barrier = "iwi_vapour_barrier"
+ iwi_redecoration = "iwi_redecoration"
+ suspended_floor_demolition = "suspended_floor_demolition"
+ suspended_floor_redecoration = "suspended_floor_redecoration"
+ suspended_floor_vapour_barrier = "suspended_floor_vapour_barrier"
+ solid_floor_demolition = "solid_floor_demolition"
+ solid_floor_preparation = "solid_floor_preparation"
+ solid_floor_vapour_barrier = "solid_floor_vapour_barrier"
+ solid_floor_redecoration = "solid_floor_redecoration"
+ ewi_wall_demolition = "ewi_wall_demolition"
+ ewi_wall_preparation = "ewi_wall_preparation"
+ ewi_wall_redecoration = "ewi_wall_redecoration"
class DepthUnit(enum.Enum):
@@ -24,6 +41,7 @@ class DepthUnit(enum.Enum):
class CostUnit(enum.Enum):
gbp_sq_meter = "gbp_sq_meter"
gbp_per_unit = "gbp_per_unit"
+ gbp_per_m2 = "gbp_per_m2"
class RValueUnit(enum.Enum):
@@ -38,9 +56,11 @@ class Material(Base):
__tablename__ = 'material'
id = Column(Integer, primary_key=True, autoincrement=True)
- type = Column(Enum(MaterialType, values_callable=lambda x: [e.value for e in x]), nullable=False)
+ type = Column(Enum(MaterialType, values_callable=lambda x: [e.value for e in x], create_constraint=False),
+ nullable=False)
+
description = Column(String, nullable=False)
- depths = Column(String) # You may want to use a specific JSON type depending on the database
+ depth = Column(String) # You may want to use a specific JSON type depending on the database
depth_unit = Column(Enum(DepthUnit, values_callable=lambda x: [e.value for e in x]), nullable=False)
cost = Column(String)
cost_unit = Column(Enum(CostUnit, values_callable=lambda x: [e.value for e in x]), nullable=False)
@@ -54,3 +74,11 @@ class Material(Base):
link = Column(String)
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
is_active = Column(Boolean, nullable=False, default=True)
+
+ prime_material_cost = Column(Float)
+ material_cost = Column(Float)
+ labour_cost = Column(Float)
+ labour_hours_per_unit = Column(Float)
+ plant_cost = Column(Float)
+ total_cost = Column(Float)
+ notes = Column(String)
diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py
index 23ad4262..a20369cc 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, filter_materials, get_cleaned, insert_temp_recommendation_id
+ create_recommendation_scoring_data, get_cleaned, insert_temp_recommendation_id
)
from backend.app.utils import epc_to_sap_lower_bound, read_csv_from_s3, read_parquet_from_s3
@@ -39,7 +39,6 @@ from recommendations.optimiser.optimiser_functions import prepare_input_measures
from recommendations.WallRecommendations import WallRecommendations
from utils.logger import setup_logger
from utils.s3 import read_dataframe_from_s3_parquet
-from tqdm import tqdm
logger = setup_logger()
@@ -74,7 +73,8 @@ async def trigger_plan(body: PlanTriggerRequest):
input_properties = []
for config in plan_input:
# We validate each record in the file. If the record is NOT valid, we need to handle this accordingly
- # TODO: implment validation
+ # TODO: implment validation. We should also standardise postcode and address in some fashion as
+ # a postcode of abcdef would be considered different to ABCDEF
# Create a record in db
property_id, is_new = create_property(
session, portfolio_id=body.portfolio_id, address=config['address'], postcode=config['postcode']
@@ -114,7 +114,6 @@ async def trigger_plan(body: PlanTriggerRequest):
# the same data
logger.info("Reading in materials and cleaned datasets")
materials = get_materials(session)
- materials_by_type = filter_materials(materials)
cleaned = get_cleaned()
logger.info("Getting components and epc recommendations")
@@ -122,46 +121,18 @@ async def trigger_plan(body: PlanTriggerRequest):
# TODO: Move this to a class. We probably want a Recommender class which takes the injects the optimisers
# in as a dependency and then the optimisers can take the input measures in as part of the setup() method
- # import pickle
- # with open("input_properties.pickle", "rb") as f:
- # input_properties = pickle.load(f)
- #
- # import pickle
- # with open("new_sap_dataset.pickle", "rb") as f:
- # new_sap_dataset = pickle.load(f)
-
- # import pickle
- # with open("cleaned.pickle", "rb") as f:
- # cleaned = pickle.load(f)
-
- # with open("sap_dataset.pickle", "rb") as f:
- # sap_dataset = pickle.load(f)
-
- # with open("materials_by_type", "rb") as f:
- # materials_by_type = pickle.load(f)
-
- # materials_by_type["floor"].append(
- # {'id': 18, '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',
- # 'created_at': datetime(2023, 8, 10, 16, 59, 10, 815531), 'is_active': True}
- #
- # )
-
recommendations = {}
recommendations_scoring_data = []
for p in input_properties:
+
# Property recommendations
p.get_components(cleaned)
property_recommendations = []
# Floor recommendations
- floor_recommender = FloorRecommendations(property_instance=p, materials=materials_by_type["floor"])
+ floor_recommender = FloorRecommendations(property_instance=p, materials=materials)
floor_recommender.recommend()
if floor_recommender.recommendations:
@@ -169,14 +140,14 @@ async def trigger_plan(body: PlanTriggerRequest):
# Wall recommendations
- wall_recomender = WallRecommendations(property_instance=p, materials=materials_by_type["walls"])
+ 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_by_type["roof"])
+ roof_recommender = RoofRecommendations(property_instance=p, materials=materials)
roof_recommender.recommend()
if roof_recommender.recommendations:
@@ -185,7 +156,7 @@ async def trigger_plan(body: PlanTriggerRequest):
# Ventilation recommendations
ventilation_recomender = VentilationRecommendations(
property_instance=p,
- materials=materials_by_type["ventilation"]
+ materials=[part for part in materials if part["type"] == "mechanical_ventilation"]
)
ventilation_recomender.recommend()
@@ -241,11 +212,18 @@ async def trigger_plan(body: PlanTriggerRequest):
logger.info("Preparing data for scoring in sap change api")
recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data)
- # Perform the same cleaning as in the model
+ # Perform the same cleaning as in the model - first clean number of room variables though
recommendations_scoring_data = DataProcessor.apply_averages_cleaning(
data_to_clean=recommendations_scoring_data,
cleaning_data=cleaning_data,
- cols_to_merge_on=COLUMNS_TO_MERGE_ON + ["LOCAL_AUTHORITY"]
+ cols_to_merge_on=['PROPERTY_TYPE', 'BUILT_FORM', 'CONSTRUCTION_AGE_BAND', 'LOCAL_AUTHORITY'],
+ colnames=["NUMBER_HABITABLE_ROOMS", "NUMBER_HEATED_ROOMS"],
+ )
+
+ recommendations_scoring_data = DataProcessor.apply_averages_cleaning(
+ data_to_clean=recommendations_scoring_data,
+ cleaning_data=cleaning_data,
+ cols_to_merge_on=COLUMNS_TO_MERGE_ON + ["LOCAL_AUTHORITY"],
).drop(columns=["LOCAL_AUTHORITY"])
recommendations_scoring_data = DataProcessor.clean_missings_after_description_process(
@@ -333,19 +311,24 @@ async def trigger_plan(body: PlanTriggerRequest):
# 3) the recommendations
logger.info("Uploading recommendations to the database")
- for i in tqdm(range(0, len(input_properties), BATCH_SIZE)):
+ session.commit()
+ for i in range(0, len(input_properties), BATCH_SIZE):
try:
# Take a slice of the input_properties list to make a batch
batch_properties = input_properties[i:i + BATCH_SIZE]
for p in batch_properties:
-
# Your existing operations
property_details_epc = p.get_property_details_epc(
portfolio_id=body.portfolio_id, rating_lookup=rating_lookup
)
create_property_details_epc(session, property_details_epc)
+ # TODO: TEMP
+ if p.data["uprn"] == "":
+ print("Get rid of me!")
+ p.data["uprn"] = 0
+
property_data = p.get_full_property_data()
update_property_data(
session, property_id=p.id, portfolio_id=body.portfolio_id, property_data=property_data
@@ -384,7 +367,7 @@ async def trigger_plan(body: PlanTriggerRequest):
# the portfolion level impact
aggregate_portfolio_recommendations(session, portfolio_id=body.portfolio_id)
- # Commit all changes at once
+ # Commit final changes
session.commit()
except IntegrityError:
logger.error("Database integrity error occurred", exc_info=True)
diff --git a/backend/app/plan/temp_script_for_flight.py b/backend/app/plan/temp_script_for_flight.py
deleted file mode 100644
index 9170b4c1..00000000
--- a/backend/app/plan/temp_script_for_flight.py
+++ /dev/null
@@ -1,176 +0,0 @@
-from datetime import datetime
-
-import pandas as pd
-from epc_api.client import EpcClient
-from fastapi import APIRouter, Depends
-from sqlalchemy.exc import IntegrityError, OperationalError
-from sqlalchemy.orm import sessionmaker
-from starlette.responses import Response
-
-from backend.app.config import get_settings
-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
-)
-from backend.app.db.functions.recommendations_functions import (
- create_plan, create_plan_recommendations, upload_recommendations
-)
-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, filter_materials, get_cleaned, insert_temp_recommendation_id
-)
-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.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.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 utils.logger import setup_logger
-from utils.s3 import read_dataframe_from_s3_parquet
-
-logger = setup_logger()
-
-import pickle
-
-with open('local_data.pickle', 'rb') as f:
- local_data = pickle.load(f)
-
-with open("property_dimensions.pickle", "rb") as f:
- property_dimensions = pickle.load(f)
-
-with open("sap_change_dataset.pickle", "rb") as f:
- sap_change_dataset = pickle.load(f)
-
-created_at = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
-
-plan_input = local_data["plan_input"]
-uprn_filenames = local_data["uprn_filenames"]
-local_property_data = local_data["local_property_data"]
-materials = local_data["materials"]
-materials_by_type = filter_materials(materials)
-cleaned = local_data["cleaned"]
-cleaning_data = local_data["cleaning_data"]
-
-# Need to find some proper materials
-materials_by_type["walls"] += [
- {'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}
-]
-
-epc_client = EpcClient(auth_token="NO-TOKEN")
-
-input_properties = []
-for i, config in enumerate(plan_input):
- property_id = local_property_data[i]["id"]
- input_properties.append(
- Property(
- postcode=config['postcode'],
- address1=config['address'],
- epc_client=epc_client,
- id=property_id
- )
- )
-
-logger.info("Getting EPC, and spatial data")
-for i, p in enumerate(input_properties):
- p.data = local_property_data[i]["data"]
- p.uprn = local_property_data[i]["uprn"]
- p.id = local_property_data[i]["id"]
- p.full_sap_epc = local_property_data[i]["full_sap_epc"]
- p.old_data = local_property_data[i]["old_data"]
- p.is_listed = False
- p.in_conservation_area = False
- p.is_heritage = False
-
- p.set_year_built()
-
- # TODO: TESTING
- p.data['number-habitable-rooms'] = 3
-
-recommendations = {}
-recommendations_scoring_data = []
-
-for p in input_properties:
- property_recommendations = []
-
- # Property recommendations
- p.get_components(cleaned)
-
- # Floor recommendations
- floor_recommender = FloorRecommendations(
- property_instance=p,
- materials=materials_by_type["floor"],
- )
- floor_recommender.recommend()
-
- if floor_recommender.recommendations:
- property_recommendations.append(floor_recommender.recommendations)
-
- # Wall recommendations
-
- wall_recomender = WallRecommendations(
- property_instance=p,
- materials=materials_by_type["walls"]
- )
- wall_recomender.recommend()
-
- if wall_recomender.recommendations:
- property_recommendations.append(wall_recomender.recommendations)
-
- # We insert temporary ids into the recommendations which is important for the optimiser later
- property_recommendations = insert_temp_recommendation_id(property_recommendations)
-
- if not property_recommendations:
- continue
-
- recommendations[p.id] = property_recommendations
-
- # Finally, we'll prepare data for predicting the impact on SAP
- # TODO: We should use the cleaned data from get_components in the data rather than the raw
- # values. We should create a method in Property which takes the EPC data and inserts the cleaned
- # data
-
- data_processor = DataProcessor(None, newdata=True)
- data_processor.insert_data(pd.DataFrame([p.data.copy()]))
- data_processor.pre_process()
-
- starting_epc_data = data_processor.get_component_features(suffix="_STARTING")
- ending_epc_data = data_processor.get_component_features(suffix="_ENDING")
- fixed_data = data_processor.get_fixed_features()
-
- # We update the ending record with the recommended updates and we set lodgement date to today
- ending_epc_data["LODGEMENT_DATE_ENDING"] = created_at
-
- for recommendations_by_type in property_recommendations:
- for rec in recommendations_by_type:
- scoring_dict = create_recommendation_scoring_data(
- property=p,
- recommendation=rec,
- starting_epc_data=starting_epc_data,
- ending_epc_data=ending_epc_data,
- fixed_data=fixed_data,
- )
-
- recommendations_scoring_data.append(scoring_dict)
-
-# cleanup
-del data_processor
diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py
index 71a61be1..20b5db5b 100644
--- a/backend/app/plan/utils.py
+++ b/backend/app/plan/utils.py
@@ -1,33 +1,13 @@
import pandas as pd
from backend.Property import Property
-from collections import defaultdict
from utils.s3 import read_from_s3
from recommendations.recommendation_utils import get_wall_u_value, get_floor_u_value, get_roof_u_value
-from backend.app.db.utils import row2dict
from backend.app.config import get_settings
import msgpack
-def filter_materials(materials):
- materials_by_type = defaultdict(list)
-
- mapping = {
- "walls": ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"],
- "floor": ["suspended_floor_insulation", "solid_floor_insulation", "exposed_floor_insulation"],
- "ventilation": ["mechanical_ventilation"],
- "roof": ["loft_insulation"]
- }
-
- materials = [row2dict(material) for material in materials]
-
- for component, types in mapping.items():
- materials_by_type[component] = [part for part in materials if part["type"] in types]
-
- return dict(materials_by_type)
-
-
def insert_temp_recommendation_id(property_recommendations):
"""
Creates a temporary recommendation id which is needed for
@@ -174,7 +154,7 @@ 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(parts[0]["depths"][0])
+ scoring_dict["roof_insulation_thickness_ENDING"] = str(int(parts[0]["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/datatypes/enums.py b/datatypes/enums.py
index 1b0959e0..31f094ad 100644
--- a/datatypes/enums.py
+++ b/datatypes/enums.py
@@ -3,3 +3,4 @@ import enum
class QuantityUnits(enum.Enum):
m2 = "m2"
+ part = "part"
diff --git a/etl/costs/README.md b/etl/costs/README.md
new file mode 100644
index 00000000..969a3173
--- /dev/null
+++ b/etl/costs/README.md
@@ -0,0 +1,35 @@
+### Costs ETL Application
+
+This is a simple application to push the materials costs data to the database.
+
+#### How to run
+
+Ensure you have a .env file in the base Model directory with the following variables
+
+```
+DB_HOST="Your db host"
+DB_PORT="Your db port"
+DB_USER="Your db user"
+DB_PASSWORD="Your db password"
+DB_NAME="Your db name"
+```
+
+Make sure your python path environment variable pouints to the base Model directory. To set the
+`PYTHONPATH` environment variable, run the following command from the base Model directory
+
+```
+export PYTHONPATH=`pwd`
+```
+
+From the base Model directory, install the requirements by running the following command
+
+```
+pip install -r etl/costs/requirements.txt
+```
+
+Then run the following command to run the application
+
+```
+python etl/costs/app.py
+```
+
diff --git a/etl/costs/app.py b/etl/costs/app.py
new file mode 100644
index 00000000..1ecbbb5f
--- /dev/null
+++ b/etl/costs/app.py
@@ -0,0 +1,106 @@
+import os
+import dotenv
+import pandas as pd
+import numpy as np
+from pathlib import Path
+from sqlalchemy.orm import Session
+from sqlalchemy import create_engine
+from backend.app.db.models.materials import Material
+from recommendations.recommendation_utils import calculate_r_value_per_mm
+
+DATA_DIRECTORY = Path(__file__).parent / "local_data" / "Hestia Materials.xlsx"
+# Environment file is at the same level as this file
+ENV_FILE = Path(__file__).parent / "etl" / "costs" / ".env"
+dotenv.load_dotenv(ENV_FILE)
+
+DB_USERNAME = os.getenv('DB_USERNAME')
+DB_PASSWORD = os.getenv('DB_PASSWORD')
+DB_HOST = os.getenv('DB_HOST')
+DB_PORT = os.getenv('DB_PORT')
+DB_NAME = os.getenv('DB_NAME')
+
+
+def push_costs_to_db(engine, costs_df):
+ """
+ Push costs DataFrame to the database.
+
+ :param engine: The SQLAlchemy engine connected to your database.
+ :param costs_df: The DataFrame containing cost data.
+ """
+ materials = []
+
+ for _, row in costs_df.iterrows():
+ row_dict = row.to_dict()
+
+ # Add other necessary transformations here
+
+ # Create Material object and add it to the list
+ materials.append(Material(**row_dict))
+
+ # Use SQLAlchemy session for bulk insert
+ with Session(engine) as session:
+ session.bulk_save_objects(materials)
+ session.commit()
+
+
+def app():
+ """
+ This application uploads the cost data to our database
+
+ The most recent cost data can be found in OneDrive, in the
+ shared folder > 04. Product Development > Cost data > Hestia Materials.xlsx
+
+ For the moment, the data is uploaded manually. In the future, we will automate this so the data can be
+ stored locally and then is uploaded from the local_data folder
+ :return:
+ """
+
+ connection_string = "postgresql+{drivername}://{username}:{password}@{server}:{port}/{dbname}"
+ db_string = connection_string.format(
+ drivername="psycopg2", # You'll need to use psycopg2 driver for PostgreSQL
+ username=DB_USERNAME,
+ password=DB_PASSWORD,
+ server=DB_HOST,
+ port=DB_PORT,
+ dbname=DB_NAME,
+ )
+
+ db_engine = create_engine(db_string, pool_size=5, max_overflow=5)
+
+ cwi_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="cavity_wall_insulation", header=0)
+ loft_insulation_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="loft_insulation", header=0)
+ iwi_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="internal_wall_insulation", header=0)
+ suspended_floor_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="suspended_floor_insulation", header=0)
+ 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)
+
+ # Form a single table to be uploaded
+ costs = pd.concat(
+ [
+ cwi_costs,
+ loft_insulation_costs,
+ iwi_costs,
+ suspended_floor_costs,
+ solid_floor_costs,
+ ewi_costs,
+ ]
+ )
+
+ costs = costs.replace({np.nan: None})
+ costs["depth"] = costs["depth"].fillna(0)
+ costs["depth"] = costs["depth"].astype(str)
+
+ costs["r_value_per_mm"] = costs.apply(
+ lambda row: calculate_r_value_per_mm(float(row["depth"]), row["thermal_conductivity"]), axis=1
+ )
+ costs["r_value_unit"] = "square_meter_kelvin_per_watt"
+
+ for col in ["material_cost", "labour_cost", "labour_hours_per_unit", "plant_cost"]:
+ costs[col] = costs[col].fillna(0)
+
+ # Push the costs to the database
+ push_costs_to_db(db_engine, costs)
+
+
+if __name__ == "__main__":
+ app()
diff --git a/etl/costs/requirements.txt b/etl/costs/requirements.txt
new file mode 100644
index 00000000..7d6afa9e
--- /dev/null
+++ b/etl/costs/requirements.txt
@@ -0,0 +1,5 @@
+pandas==1.5.3
+sqlalchemy==2.0.19
+python-dotenv
+psycopg2-binary
+openpyxl
\ No newline at end of file
diff --git a/etl/epc/DataProcessor.py b/etl/epc/DataProcessor.py
index 3ef485b8..0587fdbe 100644
--- a/etl/epc/DataProcessor.py
+++ b/etl/epc/DataProcessor.py
@@ -179,7 +179,6 @@ class DataProcessor:
# We have some non-standard construction age bands which we'll clean for matching
if not self.newdata:
self.standardise_construction_age_band()
-
self.clean_missing_rooms()
self.recast_df_columns(
@@ -451,7 +450,7 @@ class DataProcessor:
self.data["PHOTO_SUPPLY"] = self.data["PHOTO_SUPPLY"].fillna(0)
@staticmethod
- def apply_averages_cleaning(data_to_clean, cleaning_data, cols_to_merge_on):
+ def apply_averages_cleaning(data_to_clean, cleaning_data, cols_to_merge_on, colnames=None):
"""
Clean the input DataFrame using averages from a cleaning DataFrame.
@@ -459,11 +458,16 @@ class DataProcessor:
:param cleaning_data: DataFrame containing data for cleaning.
:param cols_to_merge_on: Columns on which merging is based. We pass cols_to_merge_on to this function as this
differs depending on where the function is being used.
+ :param colnames: If specified can be used to state exactly which columns to clean
:return: Cleaned DataFrame.
"""
+ # The desired colnames to clean - which may not be present
+ if colnames is None:
+ colnames = ["TOTAL_FLOOR_AREA", "FLOOR_HEIGHT", "FIXED_LIGHTING_OUTLETS_COUNT"]
+
cols_to_clean = [
- c for c in ["TOTAL_FLOOR_AREA", "FLOOR_HEIGHT", "FIXED_LIGHTING_OUTLETS_COUNT"] if
+ c for c in colnames if
c in data_to_clean.columns
]
@@ -492,6 +496,8 @@ class DataProcessor:
for col in cols_to_clean:
data_to_clean[col].fillna(data_to_clean[f"{col}_AVERAGE"], inplace=True)
data_to_clean.drop(columns=[f"{col}_AVERAGE"], inplace=True)
+ # If we still have missings
+ data_to_clean[col].fillna(data_to_clean[col].mean(), inplace=True)
return data_to_clean
diff --git a/recommendations/Costs.py b/recommendations/Costs.py
new file mode 100644
index 00000000..a96e1215
--- /dev/null
+++ b/recommendations/Costs.py
@@ -0,0 +1,571 @@
+import numpy as np
+
+# This data comes from SPONs
+regional_labour_variations = [
+ {"Region": "Outer London (Spon’s 2023)", "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": "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:
+ """
+ A class to calculate the costs associated with construction works,
+ specifically focusing on cavity wall insulation.
+ It includes contingency, preliminaries, profit margin, and VAT calculations.
+
+ As a sense check, there is a useful article from checkatrade on retrofitting and expected costs:
+ https://www.checkatrade.com/blog/cost-guides/retrofit-insulation-cost/
+
+ Another useful article for benchmarking the cost of floor insulation:
+ https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost/
+ """
+
+ # Contingency is a percentage of the total cost of the work and covers unforseen expenses
+ # We assume a conservative 10% contingency for all works which is a rate defined by SPONs
+ CONTINGENCY = 0.1
+
+ # Where there is more uncertainty, a higher contingency rate is used
+ HIGH_RISK_CONTINGENCY = 0.15
+ # When there is less uncertainty, a lower contingency rate is used
+ LOW_RISK_CONTINGENCY = 0.05
+
+ # Preliminaries are a percentage of the total cost of the work and covers the cost of site-specific costs
+ # such as site preparation, safety measures and project management. This rate can vary but we'll assume a 10%
+ # rate, on the total cost before VAT, as recommended by SPONs
+ PRELIMINARIES = 0.1
+
+ # For higher risk projects, a higher preliminaries rate is used. SPONs indicates that a higher risk project might
+ # have a preliminaries of 12-14% so we use 12% as the median for the preliminaries rate.
+ # For External wall insulation (EWI), we use 15% as the preliminaries rate if we think the property might
+ # need scaffolding, otherwise we use 12%. This is to account for any site preparation that might be required
+ EWI_NO_SCAFFOLDING_PRELIMINARIES = 0.12
+ EWI_SCAFFOLDING_PRELIMINARIES = 0.15
+
+ VAT_RATE = 0.2
+ PROFIT_MARGIN = 0.15
+
+ def __init__(self, property_instance):
+ """
+ Initializes the Costs class with a property instance.
+
+ :param property_instance: Instance of a Property class containing relevant details like wall area.
+ """
+ if not hasattr(property_instance, 'insulation_wall_area'):
+ raise ValueError("Property instance must have an 'insulation_wall_area' attribute")
+ self.property = property_instance
+ self.regional_labour_variations = regional_labour_variations
+
+ self.county = county_map.get(self.property.data["county"], None)
+ if self.county is None:
+ raise ValueError("County not found in county map")
+
+ self.labour_adjustment_factor = [
+ x["Adjustment_Factor"] for x in self.regional_labour_variations if
+ x["Region"] == self.county
+ ][0]
+
+ if not self.labour_adjustment_factor:
+ raise ValueError("Labour adjustment factor not found")
+
+ def cavity_wall_insulation(self, wall_area, material):
+ """
+ Calculates the total cost for cavity wall insulation based on material and labor costs,
+ including contingency, preliminaries, profit, and VAT.
+
+ Because of some limitations in the SPONs data, there are no materials that can be blown through a wall,
+ therefore we have adapted similar materials, basing our estimates on 75mm cavity slabs, and have halved the
+ labour time required. That is why we still price based on wall area despite volume actually being the correct
+ metric.
+
+ :return: A dictionary containing detailed cost breakdown.
+ """
+
+ material_cost_per_m2 = material["material_cost"]
+
+ base_material_cost = material_cost_per_m2 * wall_area
+ labour_cost = material["labour_cost"] * wall_area * self.labour_adjustment_factor
+
+ subtotal_before_profit = base_material_cost + labour_cost
+
+ contingency_cost = subtotal_before_profit * self.CONTINGENCY
+ preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
+ profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
+
+ subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
+
+ vat_cost = subtotal_before_vat * self.VAT_RATE
+
+ total_cost = subtotal_before_vat + vat_cost
+
+ labour_hours = material["labour_hours_per_unit"] * wall_area
+
+ return {
+ "total": total_cost,
+ "subtotal": subtotal_before_vat,
+ "vat": vat_cost,
+ "contingency": contingency_cost,
+ "preliminaries": preliminaries_cost,
+ "material": base_material_cost,
+ "profit": profit_cost,
+ "labour_hours": labour_hours,
+ "labour_cost": labour_cost
+ }
+
+ def loft_insulation(self, floor_area, material):
+ """
+ Calculates the total cost for cavity wall insulation based on material and labor costs,
+ including contingency, preliminaries, profit, and VAT.
+
+ :return: A dictionary containing detailed cost breakdown.
+ """
+ material_cost_per_m2 = material["material_cost"]
+
+ base_material_cost = material_cost_per_m2 * floor_area
+ labour_cost = material["labour_cost"] * floor_area * self.labour_adjustment_factor
+
+ subtotal_before_profit = base_material_cost + labour_cost
+
+ contingency_cost = subtotal_before_profit * self.CONTINGENCY
+ preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
+ profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
+
+ subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
+
+ vat_cost = subtotal_before_vat * self.VAT_RATE
+
+ total_cost = subtotal_before_vat + vat_cost
+
+ labour_hours = material["labour_hours_per_unit"] * floor_area
+
+ return {
+ "total": total_cost,
+ "subtotal": subtotal_before_vat,
+ "vat": vat_cost,
+ "contingency": contingency_cost,
+ "preliminaries": preliminaries_cost,
+ "material": base_material_cost,
+ "profit": profit_cost,
+ "labour_hours": labour_hours,
+ "labour_cost": labour_cost
+ }
+
+ def internal_wall_insulation(self, wall_area, material, non_insulation_materials):
+ """
+ Broadly speaking, the high level steps to an internal wall insulation job are the following:
+
+ 1) Demolition: This involves removing existing wall linings, fittings, and any other obstacles.
+ It's important to factor in the disposal of debris and the potential need for additional protective
+ measures to ensure the safety of the work area.
+
+ 2) Insulation Installation: This is the core part of the process where the chosen insulation material is
+ applied. The choice of insulation material will depend on several factors including thermal performance,
+ wall construction, and space constraints.
+
+ 3) Vapour Barrier Installation: This is crucial for preventing moisture from penetrating the insulation,
+ which can compromise its effectiveness and lead to mold growth.
+
+ 4) Re-decoration: This involves applying plaster to the wall and then painting.
+ The quality of finish here is important for both aesthetic and functional reasons.
+
+ 5) Trim and Finishing Work: Post-insulation, tasks such as re-installing skirting boards, door frames,
+ or window sills might be necessary.
+ :return:
+ """
+
+ # Extract and check the different types of data we'll need
+ demolition_data = [x for x in non_insulation_materials if x["type"] == "iwi_wall_demolition"]
+ vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "iwi_vapour_barrier"]
+ redecoration_data = [x for x in non_insulation_materials if x["type"] == "iwi_redecoration"]
+ if not demolition_data:
+ raise ValueError("No data found for iwi_wall_demolition")
+
+ if (len(vapour_barrier_data) != 1) or (len(redecoration_data) != 3):
+ raise ValueError("Incorrect number of data entries for non-insulation materials")
+
+ # Break out the individual material costs
+ # Since we don't know the exact wall construction, we take an average for demolition costs, since
+ # the cost will depend on the type of wall construction
+ demolition_material_costs = np.mean([x["material_cost"] * wall_area for x in demolition_data])
+ insulation_material_costs = material["material_cost"] * wall_area
+ vapour_barrier_material_costs = vapour_barrier_data[0]["material_cost"] * wall_area
+ redecoration_material_costs = sum([x["material_cost"] * wall_area for x in redecoration_data])
+
+ demolition_plant_costs = np.mean([x["plant_cost"] * wall_area for x in demolition_data])
+
+ # Again for demolition, we average since we aren't sure which demolition process will be used
+ demolition_labour_costs = np.mean([x["labour_cost"] * wall_area for x in demolition_data])
+ insulation_labour_costs = material["labour_cost"] * wall_area
+ vapour_barrier_labour_costs = vapour_barrier_data[0]["labour_cost"] * wall_area
+ redecoration_labour_costs = sum([x["labour_cost"] * wall_area for x in redecoration_data])
+
+ labour_costs = (demolition_labour_costs + insulation_labour_costs + vapour_barrier_labour_costs +
+ redecoration_labour_costs)
+
+ labour_costs = labour_costs * self.labour_adjustment_factor
+
+ materials_costs = (demolition_material_costs + insulation_material_costs + vapour_barrier_material_costs +
+ redecoration_material_costs)
+
+ subtotal_before_profit = labour_costs + materials_costs + demolition_plant_costs
+
+ # We use high risk contingency for iwi
+ contingency_cost = subtotal_before_profit * self.HIGH_RISK_CONTINGENCY
+ preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
+ profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
+
+ subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
+
+ vat_cost = subtotal_before_vat * self.VAT_RATE
+
+ total_cost = subtotal_before_vat + vat_cost
+
+ demolition_labour_hours = np.mean([x["labour_hours_per_unit"] * wall_area for x in demolition_data])
+ insulation_labour_hours = material["labour_hours_per_unit"] * wall_area
+ vapour_barrier_labour_hours = vapour_barrier_data[0]["labour_hours_per_unit"] * wall_area
+ redecoration_labour_hours = sum([x["labour_hours_per_unit"] * wall_area for x in redecoration_data])
+
+ labour_hours = (demolition_labour_hours + insulation_labour_hours + vapour_barrier_labour_hours +
+ redecoration_labour_hours)
+
+ # To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5 people
+ labour_days = (labour_hours / 8) / 4
+
+ return {
+ "total": total_cost,
+ "subtotal": subtotal_before_vat,
+ "vat": vat_cost,
+ "contingency": contingency_cost,
+ "preliminaries": preliminaries_cost,
+ "material": materials_costs,
+ "profit": profit_cost,
+ "labour_hours": labour_hours,
+ "labour_days": labour_days,
+ "labour_cost": labour_costs
+ }
+
+ def suspended_floor_insulation(self, insulation_floor_area, material, non_insulation_materials):
+ """
+ We characterise the steps for suspended floor insulation as the following tasks:
+
+ 1) Removal of Carpet and Underfelt: Where necessary, remove existing floor coverings to access the floorboards.
+ 2) Removal of Floor Boarding: Carefully remove floorboards to access the space beneath for insulation.
+ 3) Installation of Vapour Barrier: Install a vapour barrier to prevent moisture from affecting
+ the insulation and floor structure.
+ 4) Installation of Insulation: Fit the chosen insulation material between the joists in the floor void.
+ 5) Refixing Floorboards: Replace and secure the floorboards after insulation installation.
+ 6) Re-carpeting: Lay down the carpet or other floor coverings once the insulation and floorboards are in place.
+ :return:
+ """
+
+ demolition_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_demolition"]
+ vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_vapour_barrier"]
+ redecoration_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_redecoration"]
+
+ if (len(demolition_data) != 2) or (len(vapour_barrier_data) != 1) or (len(redecoration_data) != 2):
+ raise ValueError("Incorrect number of data entries for non-insulation materials")
+
+ # Break out the individual material costs
+ demolition_material_costs = sum([x["material_cost"] * insulation_floor_area for x in demolition_data])
+ insulation_material_costs = material["material_cost"] * insulation_floor_area
+ vapour_barrier_material_costs = vapour_barrier_data[0]["material_cost"] * insulation_floor_area
+ redecoration_material_costs = sum([x["material_cost"] * insulation_floor_area for x in redecoration_data])
+
+ demolition_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in demolition_data])
+ insulation_labour_costs = material["labour_cost"] * insulation_floor_area
+ vapour_barrier_labour_costs = vapour_barrier_data[0]["labour_cost"] * insulation_floor_area
+ redecoration_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in redecoration_data])
+
+ labour_costs = (demolition_labour_costs + insulation_labour_costs + vapour_barrier_labour_costs +
+ redecoration_labour_costs)
+
+ labour_costs = labour_costs * self.labour_adjustment_factor
+
+ materials_costs = (demolition_material_costs + insulation_material_costs + vapour_barrier_material_costs +
+ redecoration_material_costs)
+
+ subtotal_before_profit = labour_costs + materials_costs
+
+ contingency_cost = subtotal_before_profit * self.CONTINGENCY
+ preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
+ profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
+
+ subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
+
+ vat_cost = subtotal_before_vat * self.VAT_RATE
+
+ total_cost = subtotal_before_vat + vat_cost
+
+ demolition_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in demolition_data])
+ insulation_labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
+ vapour_barrier_labour_hours = vapour_barrier_data[0]["labour_hours_per_unit"] * insulation_floor_area
+ redecoration_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in redecoration_data])
+
+ labour_hours = (demolition_labour_hours + insulation_labour_hours + vapour_barrier_labour_hours +
+ redecoration_labour_hours)
+
+ # Assume a team of 3 people for a small to medium size project
+ labour_days = (labour_hours / 8) / 3
+
+ return {
+ "total": total_cost,
+ "subtotal": subtotal_before_vat,
+ "vat": vat_cost,
+ "contingency": contingency_cost,
+ "preliminaries": preliminaries_cost,
+ "material": materials_costs,
+ "profit": profit_cost,
+ "labour_hours": labour_hours,
+ "labour_days": labour_days,
+ "labour_cost": labour_costs
+ }
+
+ def solid_floor_insulation(self, insulation_floor_area, material, non_insulation_materials):
+ """
+ We characterise the steps for solid floor insulation as the following tasks:
+
+ 1) Removal of Carpet and Underfelt: This is the initial stage where any existing floor coverings, like carpets,
+ tiles, or linoleum, are carefully removed. This exposes the solid floor beneath, which is typically concrete.
+
+ 2) Preparation of Flooring: This step is critical. It involves:
+ - Cleaning the existing floor surface thoroughly to remove debris and ensure a flat surface.
+ - Assessing and repairing any damage to the concrete floor. This might include filling cracks or leveling
+ uneven areas.
+
+ 3) Installation of a Damp Proof Membrane (DPM): Before installing insulation, a DPM is often laid down to
+ prevent moisture from rising into the insulation and the interior space. This step is crucial in areas prone to
+ dampness.
+
+ 4) Install Insulation: The insulation, often in the form of rigid foam boards, is laid over the DPM.
+ The choice of insulation material will depend on the desired thermal properties and the available floor height.
+ Care is taken to minimize thermal bridges and ensure a snug fit between insulation boards.
+
+ 5) Laying a New Subfloor: Over the insulation, a new subfloor is often installed. This could be a layer of
+ screed (a type of concrete) or wooden boarding, depending on the specific requirements and preferences.
+
+ 6) Re-decoration and Finishing Touches: Once the subfloor is in place and has set or dried (if necessary),
+ the final floor finish can be applied. This might involve:
+ - Laying new tiles, wooden flooring, or other chosen materials.
+ - If you're planning to re-carpet, this would be the stage to do it.
+ - Skirting boards may need to be refitted or replaced.
+
+ 7) Considerations for Doors and Fixtures: It's important to note that raising the floor level can affect door
+ thresholds and other fixtures. Doors may need to be trimmed, and fixtures might need adjustments.
+
+ :param insulation_floor_area: Area of the floor to be insulated
+ :param material: Selected insulation material
+ :param non_insulation_materials: Non-insulation materials required for the job
+ :return:
+ """
+
+ demolition_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_demolition"]
+ preparation_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_preparation"]
+ vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_vapour_barrier"]
+ redecoration_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_redecoration"]
+
+ if ((len(demolition_data) != 1) or (len(preparation_data) != 2) or (len(vapour_barrier_data) != 1) or
+ (len(redecoration_data) != 3)):
+ raise ValueError("Incorrect number of data entries for non-insulation materials")
+
+ # Break out the individual material costs
+ preparation_material_costs = sum([x["material_cost"] * insulation_floor_area for x in preparation_data])
+ insulation_material_costs = material["material_cost"] * insulation_floor_area
+ vapour_barrier_material_costs = vapour_barrier_data[0]["material_cost"] * insulation_floor_area
+ redecoration_material_costs = sum([x["material_cost"] * insulation_floor_area for x in redecoration_data])
+
+ demolition_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in demolition_data])
+ preparation_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in preparation_data])
+ insulation_labour_costs = material["labour_cost"] * insulation_floor_area
+ vapour_barrier_labour_costs = vapour_barrier_data[0]["labour_cost"] * insulation_floor_area
+ redecoration_labour_costs = sum([x["labour_cost"] * insulation_floor_area for x in redecoration_data])
+
+ preparation_plant_costs = sum([x["plant_cost"] * insulation_floor_area for x in preparation_data])
+
+ labour_costs = (demolition_labour_costs + insulation_labour_costs + vapour_barrier_labour_costs +
+ redecoration_labour_costs + preparation_labour_costs)
+
+ labour_costs = labour_costs * self.labour_adjustment_factor
+
+ materials_cost = (preparation_material_costs + insulation_material_costs + vapour_barrier_material_costs +
+ redecoration_material_costs)
+
+ subtotal_before_profit = labour_costs + materials_cost + preparation_plant_costs
+
+ # We use HIGH_RISH_CONTINGENCY because of the potential for issues with moving fittings and trimming doors,
+ # as well as scope for damage to the existing floor during preparation.
+ contingency_cost = subtotal_before_profit * self.HIGH_RISK_CONTINGENCY
+ preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
+ profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
+
+ subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
+ vat_cost = subtotal_before_vat * self.VAT_RATE
+ total_cost = subtotal_before_vat + vat_cost
+
+ demolition_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in demolition_data])
+ preparation_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in preparation_data])
+ insulation_labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
+ vapour_barrier_labour_hours = vapour_barrier_data[0]["labour_hours_per_unit"] * insulation_floor_area
+ redecoration_labour_hours = sum([x["labour_hours_per_unit"] * insulation_floor_area for x in redecoration_data])
+
+ labour_hours = (demolition_labour_hours + insulation_labour_hours + vapour_barrier_labour_hours +
+ redecoration_labour_hours + preparation_labour_hours)
+
+ # Assume a team of 3 people for a small to medium size project
+ labour_days = (labour_hours / 8) / 3
+
+ return {
+ "total": total_cost,
+ "subtotal": subtotal_before_vat,
+ "vat": vat_cost,
+ "contingency": contingency_cost,
+ "preliminaries": preliminaries_cost,
+ "material": materials_cost,
+ "profit": profit_cost,
+ "labour_hours": labour_hours,
+ "labour_days": labour_days,
+ "labour_cost": labour_costs
+ }
+
+ def external_wall_insulation(self, wall_area, material, non_insulation_materials):
+ """
+ We characterise external wall insulation as the following steps:
+
+ 1) Preparation of the Area: Tidying up the surroundings, trimming back foliage, and laying down protective
+ sheets to protect the flooring and landscaping around the work area.
+
+ 2) Scaffolding Setup (if needed): Erecting scaffolding for safe access to the walls of semi-detached or
+ detached houses. For terraced houses or lower-level work, scaffolding might not be necessary.
+
+ 3) Wall Surface Preparation: Cleaning the wall surface, removing any loose or flaking material,
+ and possibly applying a primer. If the existing wall is weak or damaged, partial or full replacement
+ of the top surface may be necessary.
+
+ 4) Applying Primer: If the existing wall is suitable, applying a primer to improve adhesion of the insulation
+ boards and stabilize the wall surface, especially if it's old or weathered.
+
+ 5) Insulation Application: Attaching insulation boards to the primed wall using adhesive, mechanical fixings,
+ or a combination of both.
+
+ 6) Basecoat and Mesh Application: Applying a basecoat embedded with a reinforcing mesh over the insulation.
+ This layer provides strength and helps prevent cracking.
+
+ 7) Decorative Finish: Applying a decorative finish, such as render or cladding, which protects the insulation
+ and provides an aesthetic look.
+
+ 8) Reinstalling Fixtures: Reattaching any fixtures like downpipes, satellite dishes, or lighting fixtures that
+ were removed during preparation. Extensions or adjustments may be required due to the increased wall thickness.
+
+ 9) Inspection and Cleanup: Conducting a thorough inspection to ensure quality and integrity of the EWI system,
+ followed by cleaning up the site to remove all debris and materials.
+
+ In the actual materials data, at this point, we have costing for:
+ - wall preparation, hacking off existing wall finishes, linings, etc (ewi_wall_demolition)
+ - wall surface cleaning and priming (ewi_wall_preparation)
+ - insulation (external_wall_insulation)
+ - basecoat and mesh with decorative render topcoat finish (ewi_basecoat_and_mesh)
+
+ All of this data comes from SPONS, however there are some clear features missing. Because we could not find
+ suitable cost records in SPONS for steps like cleaning the area, setting up small scale scaffolding,
+ re-attaching any fitings and cleaning up the area afterwards, instead we have accounted for these steps by
+ increasing the preliminaries rate. It is acknowldeged though, that this is not ideal and that the cost of these
+ steps should be included in the materials data. We will look to improve this in the future, with data from
+ installers
+
+ :param wall_area:
+ :param material:
+ :param non_insulation_materials:
+ :return:
+ """
+
+ # For semi detatched and detatched houses, as well as maisonettes, we price for scaffolding
+
+ if self.property.data["property-type"] == "House":
+ if self.property.data["built-form"] in ['Semi-Detached', 'Detached', "End-Terrace"]:
+ preliminaries_rate = self.EWI_SCAFFOLDING_PRELIMINARIES
+ else:
+ preliminaries_rate = self.EWI_NO_SCAFFOLDING_PRELIMINARIES
+ elif self.property.data["property-type"] == "Maisonette":
+ preliminaries_rate = self.EWI_SCAFFOLDING_PRELIMINARIES
+ elif self.property.data["property-type"] == "Bungalow":
+ preliminaries_rate = self.EWI_NO_SCAFFOLDING_PRELIMINARIES
+ else:
+ raise ValueError("Unsupported property type - haven't handled flats")
+
+ demolition_data = [x for x in non_insulation_materials if x["type"] == "ewi_wall_demolition"]
+ preparation_data = [x for x in non_insulation_materials if x["type"] == "ewi_wall_preparation"]
+ redecoration_data = [x for x in non_insulation_materials if x["type"] == "ewi_wall_redecoration"]
+
+ if (len(demolition_data) != 3) or (len(preparation_data) != 1) or (len(redecoration_data) != 1):
+ raise ValueError("Incorrect number of data entries for non-insulation materials")
+
+ # Break out the individual material costs
+ # Since we don't know the exact wall construction, we take an average for demolition costs, since
+ # the cost will depend on the type of wall construction
+ demolition_material_costs = np.mean([x["material_cost"] * wall_area for x in demolition_data])
+ insulation_material_costs = material["material_cost"] * wall_area
+ preparation_material_costs = preparation_data[0]["material_cost"] * wall_area
+ redecoration_material_costs = redecoration_data[0]["material_cost"] * wall_area
+
+ demolition_plant_costs = np.mean([x["plant_cost"] * wall_area for x in demolition_data])
+
+ demolition_labour_costs = np.mean([x["labour_cost"] * wall_area for x in demolition_data])
+ insulation_labour_costs = material["labour_cost"] * wall_area
+ preparation_labour_costs = preparation_data[0]["labour_cost"] * wall_area
+ redecoration_labour_costs = redecoration_data[0]["labour_cost"] * wall_area
+
+ labour_costs = (demolition_labour_costs + insulation_labour_costs + redecoration_labour_costs +
+ preparation_labour_costs)
+
+ labour_costs = labour_costs * self.labour_adjustment_factor
+
+ materials_costs = (demolition_material_costs + insulation_material_costs + preparation_material_costs +
+ redecoration_material_costs)
+
+ subtotal_before_profit = labour_costs + materials_costs + demolition_plant_costs
+
+ contingency_cost = subtotal_before_profit * self.CONTINGENCY
+ preliminaries_cost = subtotal_before_profit * preliminaries_rate
+ profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
+
+ subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
+ vat_cost = subtotal_before_vat * self.VAT_RATE
+ total_cost = subtotal_before_vat + vat_cost
+
+ demolition_labour_hours = np.mean([x["labour_hours_per_unit"] * wall_area for x in demolition_data])
+ insulation_labour_hours = material["labour_hours_per_unit"] * wall_area
+ preparation_labour_hours = preparation_data[0]["labour_hours_per_unit"] * wall_area
+ redecoration_labour_hours = redecoration_data[0]["labour_hours_per_unit"] * wall_area
+
+ labour_hours = (demolition_labour_hours + insulation_labour_hours + redecoration_labour_hours +
+ preparation_labour_hours)
+
+ # Assume a team of 3-5 people for a small to medium size project
+ labour_days = (labour_hours / 8) / 4
+
+ return {
+ "total": total_cost,
+ "subtotal": subtotal_before_vat,
+ "vat": vat_cost,
+ "contingency": contingency_cost,
+ "preliminaries": preliminaries_cost,
+ "material": materials_costs,
+ "profit": profit_cost,
+ "labour_hours": labour_hours,
+ "labour_days": labour_days,
+ "labour_cost": labour_costs
+ }
diff --git a/recommendations/FireplaceRecommendations.py b/recommendations/FireplaceRecommendations.py
index 3e82b9d1..30ab1ad2 100644
--- a/recommendations/FireplaceRecommendations.py
+++ b/recommendations/FireplaceRecommendations.py
@@ -43,6 +43,8 @@ class FireplaceRecommendations(Definitions):
"starting_u_value": None,
"new_u_value": None,
"sap_points": None,
- "cost": estimated_cost,
+ "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
}
]
diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py
index bc24b6c3..96b1356c 100644
--- a/recommendations/FloorRecommendations.py
+++ b/recommendations/FloorRecommendations.py
@@ -1,5 +1,8 @@
import math
from typing import List
+
+import pandas as pd
+
from BaseUtility import Definitions
from datatypes.enums import QuantityUnits
from backend.Property import Property
@@ -8,6 +11,7 @@ from recommendations.recommendation_utils import (
get_recommended_part, get_floor_u_value
)
from recommendations.rdsap_tables import FLOOR_LEVEL_MAP
+from recommendations.Costs import Costs
class FloorRecommendations(Definitions):
@@ -30,25 +34,41 @@ class FloorRecommendations(Definitions):
materials: List,
):
self.property = property_instance
+ self.costs = Costs(self.property)
# For audit purposes, when estimating u values we'll store it
self.estimated_u_value = None
# Will contains a list of recommended measures
self.recommendations = []
- self.materials = materials
-
- self.suspended_floor_insulation_parts = [
- part for part in self.materials if part["type"] == "suspended_floor_insulation"
- ]
- self.solid_floor_insulation_parts = [
- part for part in self.materials if part["type"] == "solid_floor_insulation"
+ self.suspended_floor_insulation_materials = [
+ part for part in materials if part["type"] == "suspended_floor_insulation"
]
- self.exposed_floor_insulation_parts = [
- part for part in self.materials if part["type"] == "exposed_floor_insulation"
+ self.suspended_floor_non_insulation_materials = [
+ part for part in materials if part["type"] in [
+ "suspended_floor_demolition", "suspended_floor_redecoration", "suspended_floor_vapour_barrier"
+ ]
]
+ self.solid_floor_insulation_materials = [
+ part for part in materials if part["type"] == "solid_floor_insulation"
+ ]
+
+ self.solid_floor_non_insulation_materials = [
+ part for part in materials if part["type"] in [
+ "solid_floor_demolition", "solid_floor_preparation", "solid_floor_vapour_barrier",
+ "solid_floor_redecoration"
+ ]
+ ]
+
+ self.exposed_floor_insulation_materials = [
+ part for part in materials if part["type"] == "exposed_floor_insulation"
+ ]
+
+ # TODO: To be completed
+ self.exposed_floor_non_insulation_materials = []
+
def recommend(self):
u_value = self.property.floor["thermal_transmittance"]
@@ -58,7 +78,7 @@ class FloorRecommendations(Definitions):
)
property_type = self.property.data["property-type"]
- floor_area = self.property.floor_area / self.property.number_of_floors
+ floor_area = self.property.insulation_floor_area
year_built = self.property.year_built
if self.property.floor["another_property_below"] | (self.property.floor["insulation_thickness"] in [
@@ -98,12 +118,20 @@ class FloorRecommendations(Definitions):
if self.property.floor["is_suspended"]:
# Given the U-value, we recommend underfloor insulation
- self.recommend_floor_insulation(u_value=u_value, parts=self.suspended_floor_insulation_parts)
+ self.recommend_floor_insulation(
+ u_value=u_value,
+ insulation_materials=self.suspended_floor_insulation_materials,
+ non_insulation_materials=self.suspended_floor_non_insulation_materials
+ )
return
if self.property.floor["is_solid"]:
# Given the U-value, we recommend solid floor insulation options which are usually solid foam
- self.recommend_floor_insulation(u_value=u_value, parts=self.solid_floor_insulation_parts)
+ self.recommend_floor_insulation(
+ u_value=u_value,
+ insulation_materials=self.solid_floor_insulation_materials,
+ non_insulation_materials=self.solid_floor_non_insulation_materials
+ )
return
if self.property.floor["is_to_unheated_space"] or self.property.floor["is_to_external_air"]:
@@ -113,20 +141,23 @@ class FloorRecommendations(Definitions):
raise NotImplementedError("Implement me!")
@staticmethod
- def _make_floor_description(part, depth):
- return f"Install {depth}{part['depth_unit']} {part['description']} insulation"
+ def _make_floor_description(material):
+ return f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation"
- def recommend_floor_insulation(self, u_value, parts):
+ def recommend_floor_insulation(self, u_value, insulation_materials, non_insulation_materials):
"""
This method is tasked with estimating the impact of performing suspended floor insulation
:return:
"""
- lowest_selected_u_value = None
- for part in parts:
- for depth, cost_per_unit in zip(part["depths"], part["cost"]):
+ insulation_materials = pd.DataFrame(insulation_materials)
- part_u_value = r_value_per_mm_to_u_value(depth, part["r_value_per_mm"])
+ lowest_selected_u_value = None
+ for _, insulation_material_group in insulation_materials.groupby("description"):
+
+ for _, material in insulation_material_group.iterrows():
+
+ part_u_value = r_value_per_mm_to_u_value(material["depth"], material["r_value_per_mm"])
_, new_u_value = calculate_u_value_uplift(u_value, part_u_value)
new_u_value = math.ceil(new_u_value * 100.0) / 100.0
@@ -137,26 +168,37 @@ class FloorRecommendations(Definitions):
if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value)
- quantity = self.property.floor_area / self.property.number_of_floors
- estimated_cost = cost_per_unit * quantity
+ if material["type"] == "suspended_floor_insulation":
+ cost_result = self.costs.suspended_floor_insulation(
+ insulation_floor_area=self.property.insulation_floor_area,
+ material=material.to_dict(),
+ non_insulation_materials=non_insulation_materials
+ )
+ elif material["type"] == "solid_floor_insulation":
+ cost_result = self.costs.solid_floor_insulation(
+ insulation_floor_area=self.property.insulation_floor_area,
+ material=material.to_dict(),
+ non_insulation_materials=non_insulation_materials
+ )
+ else:
+ raise NotImplementedError("Implement me!")
self.recommendations.append(
{
"parts": [
get_recommended_part(
- part=part,
- selected_depth=depth,
- quantity=quantity,
+ part=material.to_dict(),
+ quantity=self.property.insulation_floor_area,
quantity_unit=QuantityUnits.m2.value,
- selected_total_cost=estimated_cost
+ cost_result=cost_result
),
],
"type": "floor_insulation",
- "description": self._make_floor_description(part, depth),
+ "description": self._make_floor_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
- "cost": estimated_cost,
+ **cost_result
}
)
diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py
index 283370ac..1bee1e8e 100644
--- a/recommendations/RoofRecommendations.py
+++ b/recommendations/RoofRecommendations.py
@@ -1,4 +1,5 @@
import math
+import pandas as pd
from backend.Property import Property
from typing import List
from datatypes.enums import QuantityUnits
@@ -6,6 +7,7 @@ from recommendations.recommendation_utils import (
get_roof_u_value, r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns,
update_lowest_selected_u_value, get_recommended_part, convert_thickness_to_numeric
)
+from recommendations.Costs import Costs
class RoofRecommendations:
@@ -27,15 +29,23 @@ class RoofRecommendations:
materials: List
):
self.property = property_instance
+ self.costs = Costs(self.property)
# For audit purposes, when estimating u values we'll store it
self.estimated_u_value = None
# Will contains a list of recommended measures
self.recommendations = []
- self.materials = materials
+ self.loft_insulation_materials = [
+ part for part in materials if part["type"] == "loft_insulation"
+ ]
+ self.loft_non_insulation_materials = []
def recommend(self):
+
+ if self.property.roof["has_dwelling_above"]:
+ return
+
u_value = self.property.roof["thermal_transmittance"]
insulation_thickness = convert_thickness_to_numeric(
@@ -47,38 +57,52 @@ class RoofRecommendations:
# Building regulations part L recommend installing at least 270mm of insulation, however generally we
# experience diminishing returns in terms of SAP once we go beyond around 150mm of insulation
- if insulation_thickness >= self.MINIMUM_LOFT_ISULATION_MM:
+ # This only holds true for pitched roofs
+ if (insulation_thickness >= self.MINIMUM_LOFT_ISULATION_MM) and self.property.roof["is_pitched"]:
return
# If we have a u-value already, need to implement this
if u_value:
+ if u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
+ # The Roof is already compliant
+ return
+
+ if self.property.data["transaction-type"] == "new dwelling":
+ return
raise NotImplementedError("Implement me")
u_value = get_roof_u_value(**{**self.property.roof, "age_band": self.property.age_band})
+ self.estimated_u_value = u_value
+ if u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
+ # The Roof is already compliant
+ return
if self.property.roof["is_pitched"] or self.property.roof["is_flat"]:
self.recommend_roof_insulation(u_value, insulation_thickness, self.property.roof)
return
if self.property.roof["is_roof_room"]:
- self.recommend_room_roof_insulation(u_value, insulation_thickness)
+ self.recommend_room_roof_insulation(u_value)
return
raise NotImplementedError("Implement me")
@staticmethod
- def make_loft_insulation_description(material, depth):
- return f"Install {depth}{material['depth_unit']} of {material['description']} in your loft"
+ def make_loft_insulation_description(material):
+ 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']}"
@staticmethod
- def make_flat_roof_insulation_description(material, depth):
- return f"Insulate the home's flat roof with {depth}{material['depth_unit']} of {material['description']}"
+ 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']}")
- def recommend_roof_insulation(self, u_value, insulation_thickness, roof):
+ def recommend_roof_insulation(
+ self, u_value, insulation_thickness, roof
+ ):
"""
This method will recommend which insulation materials to use
@@ -109,27 +133,31 @@ class RoofRecommendations:
# from the base layer
if roof["is_pitched"]:
- materials = [m for m in self.materials if m["type"] == "loft_insulation"]
+ insulation_materials = self.loft_insulation_materials
+ non_insulation_materials = self.loft_non_insulation_materials
elif roof["is_flat"]:
- materials = [m for m in self.materials if m["type"] == "flat_roof_insulation"]
+ raise ValueError("UPDATE ME")
else:
raise ValueError("Roof is not pitched or flat")
- if not materials:
+ if not insulation_materials:
raise ValueError("No roof insulation materials found")
+ insulation_materials = pd.DataFrame(insulation_materials)
+
lowest_selected_u_value = None
recommendations = []
- for material in materials:
+ for _, insulation_material_group in insulation_materials.groupby("description"):
- for depth, cost_per_unit in zip(material["depths"], material["cost"]):
+ for _, material in insulation_material_group.iterrows():
# We make sure we hit a depth of 270mm. We should factor in any existing insulation if the
- # loft is already partially insulated
- if (depth + insulation_thickness) < self.MINIMUM_LOFT_ISULATION_MM:
+ # loft is already partially insulated.
+ # Note: This requirement is only for loft insulation
+ if ((material["depth"] + insulation_thickness) < self.MINIMUM_LOFT_ISULATION_MM) and roof["is_pitched"]:
continue
- part_u_value = r_value_per_mm_to_u_value(depth, material["r_value_per_mm"])
+ part_u_value = r_value_per_mm_to_u_value(material["depth"], material["r_value_per_mm"])
_, new_u_value = calculate_u_value_uplift(u_value, part_u_value)
new_u_value = math.ceil(new_u_value * 100.0) / 100.0
@@ -149,22 +177,26 @@ class RoofRecommendations:
if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value)
- estimated_cost = cost_per_unit * self.property.floor_area
-
- if roof["is_pitched"]:
- description = self.make_loft_insulation_description(material, depth)
+ if material["type"] == "loft_insulation":
+ cost_result = self.costs.loft_insulation(
+ 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:
- description = self.make_flat_roof_insulation_description(material, depth)
+ raise ValueError("Invalid material type")
recommendations.append(
{
"parts": [
get_recommended_part(
- part=material,
- selected_depth=depth,
+ part=material.to_dict(),
quantity=self.property.insulation_wall_area,
quantity_unit=QuantityUnits.m2.value,
- selected_total_cost=estimated_cost
+ cost_result=cost_result
)
],
"type": "roof_insulation",
@@ -172,13 +204,13 @@ class RoofRecommendations:
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
- "cost": estimated_cost,
+ **cost_result
}
)
self.recommendations = recommendations
- def recommend_room_roof_insulation(self, u_value, insulation_thickness):
+ def recommend_room_roof_insulation(self, u_value):
"""
This method recommends room in roof insulation for properties that have been identified
to possess a room in roof.
@@ -217,7 +249,6 @@ class RoofRecommendations:
- Flat ceilings can be insulated like a standard loft.
:param u_value: Current u-value of the roof
- :param insulation_thickness: Current insulation thickness of the roof
:return:
"""
@@ -232,10 +263,6 @@ class RoofRecommendations:
recommendations = []
for material in roof_roof_insulation_materials:
for depth, cost_per_unit in zip(material["depths"], material["cost"]):
- # We make sure we hit a depth of 270mm. We should factor in any existing insulation if the
- # loft is already partially insulated
- if (depth + insulation_thickness) < self.MINIMUM_LOFT_ISULATION_MM:
- continue
part_u_value = r_value_per_mm_to_u_value(depth, material["r_value_per_mm"])
diff --git a/recommendations/VentilationRecommendations.py b/recommendations/VentilationRecommendations.py
index 35de9b3b..419029a3 100644
--- a/recommendations/VentilationRecommendations.py
+++ b/recommendations/VentilationRecommendations.py
@@ -52,19 +52,21 @@ class VentilationRecommendations(Definitions):
estimated_cost = n_units * part[0]["cost"]
- part[0]["estimated_cost"] = estimated_cost
+ part[0]["total"] = estimated_cost
part[0]["quantity"] = n_units
- part[0]["quantity_unit"] = None
+ part[0]["quantity_unit"] = "part"
# We recommend installing two mechanical ventilation systems
self.recommendation = [
{
"parts": part,
"type": part[0]["type"],
- "description": "Install %s" % part[0]["description"],
+ "description": f"Install {n_units} {part[0]['description']} units",
"starting_u_value": None,
"new_u_value": None,
"sap_points": None,
- "cost": estimated_cost,
+ "total": estimated_cost,
+ # We use a very simple and rough estimate of 4 hours per unit
+ "labour_hours": 4 * n_units
}
]
diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py
index 12085840..acc74ead 100644
--- a/recommendations/WallRecommendations.py
+++ b/recommendations/WallRecommendations.py
@@ -1,6 +1,8 @@
import math
from typing import List
+import pandas as pd
+
from datatypes.enums import QuantityUnits
from backend.Property import Property
from BaseUtility import Definitions
@@ -9,6 +11,7 @@ from recommendations.recommendation_utils import (
get_recommended_part, get_wall_u_value
)
from recommendations.config import PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION
+from recommendations.Costs import Costs
from utils.logger import setup_logger
logger = setup_logger()
@@ -50,13 +53,36 @@ class WallRecommendations(Definitions):
materials: List
):
self.property = property_instance
+ self.costs = Costs(self.property)
# For audit purposes, when estimating u values we'll store it
self.estimated_u_value = None
# Will contains a list of recommended measures
self.recommendations = []
- self.materials = materials
+ self.cavity_wall_insulation_materials = [
+ part for part in materials if part["type"] == "cavity_wall_insulation"
+ ]
+
+ self.internal_wall_insulation_materials = [
+ part for part in materials if part["type"] == "internal_wall_insulation"
+ ]
+
+ self.internal_wall_non_insulation_materials = [
+ part for part in materials if part["type"] in [
+ "iwi_wall_demolition", "iwi_vapour_barrier", "iwi_redecoration"
+ ]
+ ]
+
+ self.external_wall_insulation_materials = [
+ part for part in materials if part["type"] == "external_wall_insulation"
+ ]
+
+ self.external_wall_non_insulation_materials = [
+ part for part in materials if part["type"] in [
+ "ewi_wall_demolition", "ewi_wall_preparation", "ewi_wall_redecoration"
+ ]
+ ]
@property
def ewi_valid(self):
@@ -154,7 +180,7 @@ class WallRecommendations(Definitions):
filled cavity wall
"""
- cavity_wall_fills = [m for m in self.materials if m["type"] == "cavity_wall_insulation"]
+ insulation_materials = pd.DataFrame(self.cavity_wall_insulation_materials)
cavity_width = 75
if insulation_thickness == "below average":
cavity_width = cavity_width * (1 - PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION)
@@ -162,8 +188,9 @@ class WallRecommendations(Definitions):
# Test the different fill options
lowest_selected_u_value = None
recommendations = []
- for part in cavity_wall_fills:
- part_u_value = r_value_per_mm_to_u_value(cavity_width, part["r_value_per_mm"])
+ for _, material in insulation_materials.iterrows():
+
+ part_u_value = r_value_per_mm_to_u_value(cavity_width, material["r_value_per_mm"])
_, new_u_value = calculate_u_value_uplift(u_value, part_u_value)
new_u_value = math.ceil(new_u_value * 100.0) / 100.0
@@ -176,39 +203,41 @@ class WallRecommendations(Definitions):
if new_u_value <= self.BUILDING_REGULATIONS_PART_L_CAVITY_WALL_MAX_U_VALUE:
lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value)
- estimated_cost = part["cost"] * self.property.insulation_wall_area
+ cost_result = self.costs.cavity_wall_insulation(
+ wall_area=self.property.insulation_wall_area,
+ material=material.to_dict(),
+ )
recommendations.append(
{
"parts": [
get_recommended_part(
- part=part,
- selected_depth=None,
+ part=material.to_dict(),
quantity=self.property.insulation_wall_area,
quantity_unit=QuantityUnits.m2.value,
- selected_total_cost=estimated_cost
+ cost_result=cost_result
)
],
"type": "wall_insulation",
- "description": f"Fill cavity with {part['description']}",
+ "description": f"Fill cavity with {material['description']}",
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
- "cost": estimated_cost,
+ **cost_result
}
)
self.recommendations = recommendations
- def _find_insulation(self, parts, u_value):
+ def _find_insulation(self, u_value, insulation_materials, non_insulation_materials):
+
lowest_selected_u_value = None
recommendations = []
- for part in parts:
+ for _, insulation_material_group in insulation_materials.groupby("description"):
- for depth, cost_per_unit in zip(part["depths"], part["cost"]):
-
- part_u_value = r_value_per_mm_to_u_value(depth, part["r_value_per_mm"])
+ for _, material in insulation_material_group.iterrows():
+ part_u_value = r_value_per_mm_to_u_value(material["depth"], material["r_value_per_mm"])
_, new_u_value = calculate_u_value_uplift(u_value, part_u_value)
new_u_value = math.ceil(new_u_value * 100.0) / 100.0
@@ -225,27 +254,40 @@ class WallRecommendations(Definitions):
# We allow a small tolerance for error so we don't discount the recommendation entirely
if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
+
lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value)
- estimated_cost = cost_per_unit * self.property.insulation_wall_area
+ if material["type"] == "internal_wall_insulation":
+ cost_result = self.costs.internal_wall_insulation(
+ wall_area=self.property.insulation_wall_area,
+ material=material.to_dict(),
+ non_insulation_materials=non_insulation_materials
+ )
+ 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
+ )
+ else:
+ raise ValueError("Invalid material type")
recommendations.append(
{
"parts": [
get_recommended_part(
- part=part,
- selected_depth=depth,
+ part=material.to_dict(),
quantity=self.property.insulation_wall_area,
quantity_unit=QuantityUnits.m2.value,
- selected_total_cost=estimated_cost
+ cost_result=cost_result
)
],
"type": "wall_insulation",
- "description": "Install " + self._make_description(part, depth),
+ "description": "Install " + self._make_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
- "cost": estimated_cost,
+ **cost_result
}
)
@@ -258,27 +300,32 @@ class WallRecommendations(Definitions):
:return:
"""
- ewi_parts = [
- part for part in self.materials if part["type"] == "external_wall_insulation"
- ] if self.ewi_valid else []
-
- iwi_parts = [part for part in self.materials if part["type"] == "internal_wall_insulation"]
-
# 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
- ewi_recommendations = self._find_insulation(ewi_parts, u_value)
- iwi_recommendations = self._find_insulation(iwi_parts, u_value)
+ ewi_recommendations = []
+ if self.ewi_valid:
+ ewi_recommendations = self._find_insulation(
+ u_value=u_value,
+ insulation_materials=pd.DataFrame(self.external_wall_insulation_materials),
+ non_insulation_materials=self.external_wall_non_insulation_materials
+ )
+
+ iwi_recommendations = self._find_insulation(
+ u_value=u_value,
+ insulation_materials=pd.DataFrame(self.internal_wall_insulation_materials),
+ non_insulation_materials=self.internal_wall_non_insulation_materials
+ )
self.recommendations += ewi_recommendations + iwi_recommendations
self.prune_diminishing_recommendations()
@staticmethod
- def _make_description(part, depth):
- return f"{depth}{part['depth_unit']} {part['description']}"
+ def _make_description(material):
+ return f"{int(material['depth'])}{material['depth_unit']} {material['description']}"
def prune_diminishing_recommendations(self):
# For any recommendations, if we have at least 1 reommendation that does not exhibit diminishing returns
diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py
index 869880cf..03aa38bd 100644
--- a/recommendations/optimiser/optimiser_functions.py
+++ b/recommendations/optimiser/optimiser_functions.py
@@ -22,7 +22,7 @@ def prepare_input_measures(property_recommendations, goal):
[
{
"id": rec["recommendation_id"],
- "cost": rec["cost"],
+ "cost": rec["total"],
"gain": rec[goal_key],
"type": rec["type"]
}
diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py
index 13f58fd9..5bd77a2a 100644
--- a/recommendations/recommendation_utils.py
+++ b/recommendations/recommendation_utils.py
@@ -109,22 +109,21 @@ def update_lowest_selected_u_value(lowest_selected_u_value, new_u_value):
return lowest_selected_u_value
-def get_recommended_part(part, selected_depth, selected_total_cost, quantity, quantity_unit):
+def get_recommended_part(part, cost_result, quantity, quantity_unit):
"""
Utility function to return a recommended part with the selected depth.
:param part: part to be recommended
- :param selected_depth: depth of the selected part
- :param selected_total_cost: Total cost of the selected part
+ :param cost_result: Total cost of the selected part, as returned by the Cost class
:param quantity: Quantity of the selected part
:param quantity_unit: Unit of the quantity
:return:
"""
recommended_part = deepcopy(part)
- recommended_part["depths"] = [selected_depth]
- recommended_part["estimated_cost"] = selected_total_cost
recommended_part["quantity"] = quantity
recommended_part["quantity_unit"] = quantity_unit
+ recommended_part.update(cost_result)
+
return recommended_part
@@ -527,31 +526,31 @@ def get_wall_type(
return None
-def estimate_floors(floor_area, num_rooms):
+def estimate_external_wall_area(num_floors, floor_height, perimeter, built_form):
"""
- Simple utility funciton, which assuming a 15m squared room, estimates the number of floors in a property
- :param floor_area: Gross floor area of a property
- :param num_rooms: Number of rooms in a property
- :return: Number of floors in a property
+ This method estimates the external wall area based on fundamental assumptions about the home
+
+
+ :param num_floors: Number of floors in the building.
+ :param floor_height: Height of one floor in meters.
+ :param perimeter: Total perimeter of the building on one floor in meters.
+ :param built_form: The built form of the property. This is used to determine the number of exposed walls.
+ :return:
"""
- # Estimate total room area
- total_room_area = num_rooms * 15
-
- # Estimate the number of floors
- floors = floor_area / total_room_area
-
- # Round up to the nearest whole number
- floors = round(floors)
-
- return floors
-
-
-def estimate_wall_area(num_floors, floor_height, perimeter):
wall_area_one_floor = perimeter * floor_height
total_wall_area = wall_area_one_floor * num_floors
- return total_wall_area
+ number_exposed_walls = {
+ 'End-Terrace': 3,
+ 'Mid-Terrace': 2,
+ 'Semi-Detached': 3,
+ 'Detached': 4,
+ }
+
+ exposed_wall_area = total_wall_area * (number_exposed_walls[built_form] / 4)
+
+ return exposed_wall_area
def calculate_r_value_per_mm(thickness_mm, thermal_conductivity_w_mK):
@@ -563,6 +562,9 @@ def calculate_r_value_per_mm(thickness_mm, thermal_conductivity_w_mK):
:return:
"""
+ if thermal_conductivity_w_mK is None:
+ return None
+
r_value_m2k_w = (thickness_mm / 1000) / thermal_conductivity_w_mK
# Calculate R-value per mm
@@ -585,6 +587,9 @@ def convert_thickness_to_numeric(string_thickness, is_pitched):
:return: integer measure of insulation thickness
"""
+ if string_thickness is None:
+ return 0
+
if is_pitched:
lookup = {
"none": 0,
diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py
new file mode 100644
index 00000000..1ba601a8
--- /dev/null
+++ b/recommendations/tests/test_costs.py
@@ -0,0 +1,411 @@
+from recommendations.Costs import Costs
+from unittest.mock import Mock
+
+
+class TestCosts:
+
+ def test_cavity_wall_insulation(self):
+ mock_property = Mock()
+ mock_property.data = {
+ "county": "Northamptonshire"
+ }
+
+ costs = Costs(mock_property)
+
+ cwi_material = {
+ "description": "cwi",
+ "depth": 75,
+ "thermal_conductivity": 0.037,
+ "prime_cost": 5.17,
+ "material_cost": 5.62,
+ "labour_cost": 1.125,
+ "labour_hours": 0.065
+ }
+
+ cwi_results = costs.cavity_wall_insulation(
+ wall_area=95.9104281347967,
+ 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}
+
+ def test_loft_insulation(self):
+ mock_property = Mock()
+ mock_property.data = {
+ "county": "Northamptonshire"
+ }
+
+ costs = Costs(mock_property)
+ loft_material = {
+ "description": "Crown Loft Roll 44 glass fibre roll",
+ "depth": 270,
+ "thermal_conductivity": 0.044,
+ "prime_cost": None,
+ "material_cost": 5.91938,
+ "labour_cost": 1.96,
+ "labour_hours": 0.11
+ }
+
+ loft_results = costs.loft_insulation(
+ floor_area=33.5,
+ 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}
+
+ def test_internal_wall_insulation(self):
+ mock_property = Mock()
+ mock_property.data = {
+ "county": "Northamptonshire"
+ }
+
+ costs = Costs(mock_property)
+ iwi_non_insulation_materials = [
+ {'type': 'iwi_wall_demolition',
+ 'description': 'Solid & Dry Lined walls: Hack of wall finishes with chipping hammer; plaster to walls.',
+ 'depth': 0.0, 'depth_unit': 0.0, 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.0,
+ 'thermal_conductivity_unit': 0.0, 'prime_material_cost': 0.0, 'material_cost': 0.0, 'labour_cost': 10.27,
+ 'labour_hours_per_unit': 0.33, 'plant_cost': 1.28, 'total_cost': 11.55, 'link': 'SPONs', 'Notes': 0.0},
+ {'type': 'iwi_wall_demolition',
+ 'description': 'Stud walls: Remove wall linings including battening behind; plasterboard and skim',
+ 'depth': 0.0, 'depth_unit': 0.0, 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.0,
+ 'thermal_conductivity_unit': 0.0, 'prime_material_cost': 0.0, 'material_cost': 0.0, 'labour_cost': 6.23,
+ 'labour_hours_per_unit': 0.2, 'plant_cost': 1.25, 'total_cost': 7.48, 'link': 'SPONs', 'Notes': 0.0},
+ {'type': 'iwi_wall_demolition',
+ 'description': 'Lathe and Plaster walls: Remove wall linings including battening behind; wood lath and '
+ 'plaster',
+ 'depth': 0.0, 'depth_unit': 0.0, 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.0,
+ 'thermal_conductivity_unit': 0.0, 'prime_material_cost': 0.0, 'material_cost': 0.0, 'labour_cost': 6.85,
+ 'labour_hours_per_unit': 0.22, 'plant_cost': 2.09, 'total_cost': 8.94, 'link': 'SPONs', 'Notes': 0.0},
+ {'Notes': "",
+ 'cost_unit': "",
+ 'depth': "",
+ 'depth_unit': "",
+ 'description': 'Visqueen High Performance Vapour Barrier',
+ 'labour_cost': 0.48,
+ 'labour_hours_per_unit': 0.02,
+ 'link': 'SPONs',
+ 'material_cost': 1.21,
+ 'plant_cost': 0,
+ 'prime_material_cost': 0.58,
+ 'thermal_conductivity': "",
+ 'thermal_conductivity_unit': "",
+ 'total_cost': 1.69,
+ 'type': 'iwi_vapour_barrier'},
+ {'Notes': "",
+ 'cost_unit': "",
+ 'depth': "",
+ 'depth_unit': "",
+ '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',
+ 'labour_cost': 6.58,
+ 'labour_hours_per_unit': 0.25,
+ 'link': "",
+ 'material_cost': 0.06,
+ 'plant_cost': 0,
+ 'prime_material_cost': 0.0,
+ 'thermal_conductivity': "",
+ 'thermal_conductivity_unit': "",
+ 'total_cost': 6.64,
+ 'type': 'iwi_redecoration'},
+ {'Notes': "",
+ 'cost_unit': "",
+ 'depth': "",
+ 'depth_unit': "",
+ 'description': 'Two coats emulsion paint on plaster, over 40mm girth; 3.5m - '
+ '5m high',
+ 'labour_cost': 0.0,
+ 'labour_hours_per_unit': 0.21,
+ 'link': "",
+ 'material_cost': 0.41,
+ 'plant_cost': 0,
+ 'prime_material_cost': "",
+ 'thermal_conductivity': "",
+ 'thermal_conductivity_unit': "",
+ 'total_cost': 4.34,
+ 'type': 'iwi_redecoration'},
+ {'Notes': "",
+ 'cost_unit': "",
+ 'depth': "",
+ 'depth_unit': "",
+ 'description': 'Fitting existing softwood skirting or architrave to new '
+ 'frames; 150mm high',
+ 'labour_cost': 4.87,
+ 'labour_hours_per_unit': 0.01,
+ 'link': "",
+ 'material_cost': 4.86,
+ 'plant_cost': 0,
+ 'prime_material_cost': "",
+ 'thermal_conductivity': "",
+ 'thermal_conductivity_unit': "",
+ 'total_cost': 4.88,
+ 'type': 'iwi_redecoration'}
+ ]
+
+ iwi_material = {
+ "type": "internal_wall_insulation",
+ "description": "Ecotherm Eco-Versal PIR Insulation Board",
+ "depth": 150,
+ "depth_unit": "mm",
+ "cost_unit": "gbp_per_m2",
+ "thermal_conductivity": 0.022,
+ "thermal_conductivity_unit": "watt_per_meter_kelvin",
+ "prime_material_cost": "",
+ "material_cost": 11.68,
+ "labour_cost": 3.12,
+ "labour_hours_per_unit": 0.18,
+ "plant_cost": "",
+ "total_cost": 14.8,
+ "link": "SPONs"
+ }
+
+ iwi_results = costs.internal_wall_insulation(
+ wall_area=95.9104281347967,
+ material=iwi_material,
+ 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}
+
+ def test_suspended_floor_insulation(self):
+ mock_property = Mock()
+ mock_property.data = {
+ "county": "Northamptonshire"
+ }
+
+ 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_non_insulation_materials = [
+ {'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0,
+ 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
+ 'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs',
+ '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'},
+ {'type': 'suspended_floor_demolition',
+ 'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0,
+ 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3,
+ 'plant_cost': 0, 'total_cost': 9.34, 'link': 'SPONs', 'Notes': 0},
+ {'type': 'suspended_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,
+ 'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0},
+ {'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside',
+ 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
+ 'thermal_conductivity_unit': 0, 'prime_material_cost': 0, 'material_cost': 1.54, 'labour_cost': 24.98,
+ 'labour_hours_per_unit': 0.74, 'plant_cost': 0, 'total_cost': 26.52, 'link': 'SPONs', 'Notes': 0},
+ {'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0,
+ 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37,
+ 'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs',
+ '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'}]
+
+ sus_floor_results = costs.suspended_floor_insulation(
+ insulation_floor_area=33.5,
+ material=sus_floor_material,
+ non_insulation_materials=sus_floor_non_insulation_materials
+ )
+
+ 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,
+ 'labour_days': 2.289166666666667, 'labour_cost': 1370.5252
+ }
+
+ def test_solid_floor_insulation(self):
+ mock_property = Mock()
+ mock_property.data = {
+ "county": "Northamptonshire"
+ }
+
+ costs = Costs(mock_property)
+ sol_floor_material = {
+ 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0,
+ 'material_cost': 12.02, 'labour_cost': 4.4, 'labour_hours_per_unit': 0.19, 'plant_cost': 0,
+ 'total_cost': 16.42, 'link': 'SPONs', 'Notes': 0
+ }
+
+ sol_floor_non_insulation_materials = [
+ {'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0,
+ 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
+ 'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs',
+ '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'},
+ {'type': 'solid_floor_preparation',
+ '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'},
+ {'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,
+ 'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0},
+ {'type': 'solid_floor_redecoration',
+ 'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0,
+ 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15,
+ 'plant_cost': 0, 'total_cost': 12.56, 'link': 'SPONs',
+ 'Notes': 'This is the screed layer, placed on top of the insulation'},
+ {'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0,
+ 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37,
+ 'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs',
+ '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'},
+ {'type': 'solid_floor_redecoration',
+ 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0,
+ 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12,
+ 'plant_cost': 0, 'total_cost': 4.88, 'link': 'SPONs', 'Notes': 0}
+ ]
+
+ sol_floor_results = costs.solid_floor_insulation(
+ insulation_floor_area=33.5,
+ material=sol_floor_material,
+ non_insulation_materials=sol_floor_non_insulation_materials
+ )
+
+ 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,
+ 'labour_days': 2.386875, 'labour_cost': 1346.6464
+ }
+
+ def test_external_wall_insulation(self):
+ mock_property = Mock()
+ mock_property.data = {
+ "county": "Northamptonshire",
+ "property-type": "House",
+ "built-form": 'End-Terrace'
+ }
+
+ 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_non_insulation_materials = [
+ {'type': 'ewi_wall_demolition',
+ 'description': 'Solid & Dry Lined walls: Hack of wall finishes with chipping '
+ 'hammer; plaster to walls.',
+ 'depth': 0, 'depth_unit': 0, 'cost_unit': 'gbp_per_m2',
+ 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 10.27,
+ 'labour_hours_per_unit': 0.33, 'plant_cost': 1.28, 'total_cost': 11.55,
+ 'link': 'SPONs', 'Notes': 0}, {'type': 'ewi_wall_demolition',
+ 'description': 'Stud walls: Remove wall linings '
+ 'including battening behind; '
+ 'plasterboard and skim',
+ 'depth': 0, 'depth_unit': 0,
+ 'cost_unit': 'gbp_per_m2',
+ 'thermal_conductivity': 0,
+ 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0,
+ 'labour_cost': 6.23, 'labour_hours_per_unit': 0.2,
+ 'plant_cost': 1.25, 'total_cost': 7.48,
+ 'link': 'SPONs', 'Notes': 0},
+ {'type': 'ewi_wall_demolition',
+ 'description': 'Lathe and Plaster walls: Remove wall linings including battening '
+ 'behind; wood lath and plaster',
+ 'depth': 0, 'depth_unit': 0, 'cost_unit': 'gbp_per_m2',
+ 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.85,
+ 'labour_hours_per_unit': 0.22, 'plant_cost': 2.09, 'total_cost': 8.94,
+ 'link': 'SPONs', 'Notes': 0}, {'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, 'depth_unit': 0, 'cost_unit': 0,
+ 'thermal_conductivity': 0,
+ 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0, 'material_cost': 7.3,
+ 'labour_cost': 5.62, 'labour_hours_per_unit': 0.3,
+ 'plant_cost': 0, 'total_cost': 12.92,
+ 'link': 'SPONs',
+ 'Notes': 'This work covers the preparation and '
+ 'priming of the wall before insulating'},
+ {'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, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
+ 'thermal_conductivity_unit': 0, 'prime_material_cost': 0, 'material_cost': 0,
+ 'labour_cost': 0, 'labour_hours_per_unit': 0, 'plant_cost': 0,
+ 'total_cost': 69.94, 'link': 'SPONs',
+ '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'}]
+
+ ewi_results = costs.external_wall_insulation(
+ wall_area=95.9104281347967,
+ material=ewi_material,
+ non_insulation_materials=ewi_non_insulation_materials
+ )
+
+ 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,
+ 'labour_cost': 3921.5600094613983
+ }
diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py
index 22280ed5..73796979 100644
--- a/recommendations/tests/test_recommendation_utils.py
+++ b/recommendations/tests/test_recommendation_utils.py
@@ -405,3 +405,18 @@ def test_esimtate_pitched_roof_area():
)
assert zero_roof_area2 == 0
+
+
+def test_external_wall_area():
+ # Arrange: Define the test cases
+ test_cases = [
+ (2, 3, 40, 'End-Terrace', 180), # 3 exposed walls
+ (2, 3, 40, 'Mid-Terrace', 120), # 2 exposed walls
+ (2, 3, 40, 'Semi-Detached', 180), # 3 exposed walls
+ (2, 3, 40, 'Detached', 240), # 4 exposed walls
+ ]
+
+ # Act and Assert: Run the test cases
+ for num_floors, floor_height, perimeter, built_form, expected in test_cases:
+ result = recommendation_utils.estimate_external_wall_area(num_floors, floor_height, perimeter, built_form)
+ assert result == expected, f"Test failed for {built_form}: Expected {expected}, got {result}"
diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py
index 37cc2daf..551407da 100644
--- a/recommendations/tests/test_roof_recommendations.py
+++ b/recommendations/tests/test_roof_recommendations.py
@@ -427,3 +427,26 @@ class TestRoofRecommendations:
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.roof = {
+ 'original_description': '(other premises above)',
+ 'clean_description': '(other premises above)', 'thermal_transmittance': 0,
+ 'thermal_transmittance_unit': 'w/m-¦k', 'is_pitched': False, 'is_roof_room': False,
+ 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
+ 'is_assumed': False, 'has_dwelling_above': True, 'is_valid': True,
+ 'insulation_thickness': None
+ }
+
+ roof_recommender14 = RoofRecommendations(
+ property_instance=property_instance14, materials=loft_insulation_materials
+ )
+
+ assert not roof_recommender14.recommendations
+
+ roof_recommender14.recommend()
+
+ assert not roof_recommender14.recommendations