mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Merge pull request #256 from Hestia-Homes/new-demo-portfolio
New demo portfolio
This commit is contained in:
commit
c2ebd80f6c
26 changed files with 1575 additions and 382 deletions
2
.idea/Model.iml
generated
2
.idea/Model.iml
generated
|
|
@ -7,7 +7,7 @@
|
|||
<sourceFolder url="file://$MODULE_DIR$/open_uprn" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/recommendations" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.10 (model_data)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.10 (backend)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyNamespacePackagesService">
|
||||
|
|
|
|||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
|
|
@ -3,7 +3,7 @@
|
|||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.10 (backend)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (model_data)" project-jdk-type="Python SDK" />
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (backend)" project-jdk-type="Python SDK" />
|
||||
<component name="PythonCompatibilityInspectionAdvertiser">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ import enum
|
|||
|
||||
class QuantityUnits(enum.Enum):
|
||||
m2 = "m2"
|
||||
part = "part"
|
||||
|
|
|
|||
35
etl/costs/README.md
Normal file
35
etl/costs/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
|
||||
106
etl/costs/app.py
Normal file
106
etl/costs/app.py
Normal file
|
|
@ -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()
|
||||
5
etl/costs/requirements.txt
Normal file
5
etl/costs/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pandas==1.5.3
|
||||
sqlalchemy==2.0.19
|
||||
python-dotenv
|
||||
psycopg2-binary
|
||||
openpyxl
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
571
recommendations/Costs.py
Normal file
571
recommendations/Costs.py
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
411
recommendations/tests/test_costs.py
Normal file
411
recommendations/tests/test_costs.py
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue