Merge pull request #259 from Hestia-Homes/integrate-new-models

Integrate new models
This commit is contained in:
KhalimCK 2023-12-04 15:05:45 +00:00 committed by GitHub
commit 2174f9d283
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2772 additions and 1166 deletions

2
.idea/Model.iml generated
View file

@ -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 (backend)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.10 (model_data)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyNamespacePackagesService">

2
.idea/misc.xml generated
View file

@ -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 (backend)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (model_data)" project-jdk-type="Python SDK" />
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>

View file

@ -4,7 +4,7 @@ import os
import pandas as pd
from etl.epc.DataProcessor import DataProcessor
from etl.epc.settings import POTENTIAL_COLUMNS, EFFICIENCY_FEATURES
from etl.epc.settings import POTENTIAL_COLUMNS, EFFICIENCY_FEATURES, BUILT_FORM_REMAP
from etl.epc_clean.epc_attributes.all_cleaners import all_cleaner_map
from utils.logger import setup_logger
from utils.s3 import read_dataframe_from_s3_parquet
@ -45,7 +45,7 @@ class Property(Definitions):
windows = None
lighting = None
coordinates = None
spatial = None
def __init__(self, id, postcode, address1, epc_client=None, data=None):
self.id = id
@ -83,6 +83,10 @@ class Property(Definitions):
self.floor_area = None
self.pitched_roof_area = None
self.insulation_floor_area = None
self.number_lighting_outlets = None
self.current_adjusted_energy = None
self.expected_adjusted_energy = None
if epc_client:
self.epc_client = epc_client
@ -125,13 +129,6 @@ class Property(Definitions):
else:
self.uprn = int(self.data["uprn"])
def set_coordinates(self, coordinates):
"""
This method sets the coordinates of the property, given the open uprn data
:param coordinates: dictionary
"""
self.coordinates = {key.lower(): value for key, value in coordinates.items()}
def set_energy(self):
"""
Extracts and formats data about the home's energy and co2 consumption
@ -274,6 +271,9 @@ class Property(Definitions):
if not self.data:
raise ValueError("Property does not contain data")
# We need to implement an EPC cleaning process, which we run on the EPC data, immediately after we download
# it
self.data["built-form"] = BUILT_FORM_REMAP.get(self.data["built-form"], self.data["built-form"])
self.set_energy()
self.set_ventilation()
self.set_solar_pv()
@ -360,6 +360,9 @@ class Property(Definitions):
def set_spatial(self, spatial: pd.DataFrame):
"""
Sets whether the property is in a conservation area given the output of the ConservationAreaClient
Will store a dictionary, spatial, which is used to populate the property spatial table in the database
:param spatial: Dataframe, containing the spatial data for the property
"""
self.in_conservation_area = spatial["conservation_status"].values[0]
@ -369,6 +372,17 @@ class Property(Definitions):
if self.in_conservation_area is True | self.is_listed is True | self.is_heritage is True:
self.restricted_measures = True
spatial_dict = spatial.to_dict("records")[0]
self.spatial = {
"x_coordinate": spatial_dict["X_COORDINATE"],
"y_coordinate": spatial_dict["Y_COORDINATE"],
"latitude": spatial_dict["LATITUDE"],
"longitude": spatial_dict["LONGITUDE"],
"conservation_status": spatial_dict["conservation_status"],
"is_listed_building": spatial_dict["is_listed_building"],
"is_heritage_building": spatial_dict["is_heritage_building"],
}
def set_year_built(self):
"""
Estimates when the property was built based on as much available data as possible.
@ -461,7 +475,7 @@ class Property(Definitions):
"year_built": self.year_built,
"tenure": self.data["tenure"],
"current_epc_rating": self.data["current-energy-rating"],
"current_sap_points": self.data["current-energy-efficiency"]
"current_sap_points": self.data["current-energy-efficiency"],
}
property_data = self._clean_upload_data(property_data)
@ -513,6 +527,7 @@ class Property(Definitions):
"energy_tariff": self.data["energy-tariff"],
"primary_energy_consumption": self.energy["primary_energy_consumption"],
"co2_emissions": self.energy["co2_emissions"],
"adjusted_energy_consumption": self.current_adjusted_energy,
}
return property_details_epc
@ -703,7 +718,6 @@ class Property(Definitions):
'PROPERTY_TYPE',
'UPRN',
'NUMBER_OPEN_FIREPLACES',
'FIXED_LIGHTING_OUTLETS_COUNT',
'MULTI_GLAZE_PROPORTION',
'MECHANICAL_VENTILATION',
'PHOTO_SUPPLY',
@ -752,9 +766,28 @@ class Property(Definitions):
"FLOOR_HEIGHT": self.floor_height,
"NUMBER_HABITABLE_ROOMS": self.number_of_rooms,
"TOTAL_FLOOR_AREA": self.floor_area,
"FIXED_LIGHTING_OUTLETS_COUNT": self.number_lighting_outlets,
**epc_raw_data,
"BUILT_FORM": built_form,
"POSTCODE": self.data["postcode"],
}
return property_data
def set_number_lighting_outlets(self, cleaned_property_data):
"""
Extracts and cleans the estimated number of lighting outlets
:return:
"""
if self.data["fixed-lighting-outlets-count"] == "":
self.number_lighting_outlets = round(cleaned_property_data["FIXED_LIGHTING_OUTLETS_COUNT"].values[0])
else:
self.number_lighting_outlets = float(self.data["fixed-lighting-outlets-count"])
def set_adjusted_energy(self, current_adjusted_energy, expected_adjusted_energy):
"""
Stores these values for usage later
"""
self.current_adjusted_energy = current_adjusted_energy
self.expected_adjusted_energy = expected_adjusted_energy

View file

@ -8,7 +8,9 @@ class Settings(BaseSettings):
SECRET_KEY: str
ENVIRONMENT: str
DATA_BUCKET: str
PREDICTIONS_BUCKET: str
SAP_PREDICTIONS_BUCKET: str
CARBON_PREDICTIONS_BUCKET: str
HEAT_PREDICTIONS_BUCKET: str
PLAN_TRIGGER_BUCKET: str
EPC_AUTH_TOKEN: str
DB_HOST: str

View file

@ -3,15 +3,17 @@ from backend.app.db.models.recommendations import Plan, PlanRecommendations, Rec
from backend.app.db.models.portfolio import Portfolio
def aggregate_portfolio_recommendations(session, portfolio_id: int):
def aggregate_portfolio_recommendations(
session, portfolio_id: int, total_valuation_increase: float, labour_days: float
):
# Aggregate multiple fields
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")
func.sum(Recommendation.heat_demand).label("energy_savings"),
func.sum(Recommendation.co2_equivalent_savings).label("co2_equivalent_savings"),
func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings"),
)
.join(PlanRecommendations, PlanRecommendations.recommendation_id == Recommendation.id)
.join(Plan, Plan.id == PlanRecommendations.plan_id)
@ -22,8 +24,9 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int):
aggregates_dict = {
"cost": aggregates.cost or 0,
"total_work_hours": aggregates.total_work_hours or 0,
# "total_heat_demand": aggregates.total_heat_demand or 0,
# "total_energy_savings": aggregates.total_energy_savings or 0
"energy_savings": aggregates.energy_savings or 0,
"co2_equivalent_savings": aggregates.co2_equivalent_savings or 0,
"energy_cost_savings": aggregates.energy_cost_savings or 0,
}
# Get the portfolio and update the fields
@ -32,6 +35,10 @@ def aggregate_portfolio_recommendations(session, portfolio_id: int):
for key, value in aggregates_dict.items():
setattr(portfolio, key, value)
# Insert total valuation increase and labour days
portfolio.property_valuation_increase = total_valuation_increase
portfolio.labour_days = labour_days
# Merge the updated portfolio back into the session
session.merge(portfolio)
session.flush()

View file

@ -3,13 +3,15 @@
###
import datetime
import pytz
from sqlalchemy.orm import Session
from backend.app.db.models.portfolio import (
PropertyModel, PropertyCreationStatus, PortfolioStatus, PropertyTargetsModel, PropertyDetailsEpcModel
PropertyModel, PropertyCreationStatus, PortfolioStatus, PropertyTargetsModel, PropertyDetailsEpcModel,
PropertyDetailsSpatial
)
from sqlalchemy.orm.exc import NoResultFound
def create_property(session, portfolio_id: int, address: str, postcode: str) -> (int, bool):
def create_property(session: Session, portfolio_id: int, address: str, postcode: str) -> (int, bool):
"""
This function will create a record for the property in the database if it does not exist.
If it does exist, it will just update the updated_at field.
@ -55,7 +57,9 @@ def create_property(session, portfolio_id: int, address: str, postcode: str) ->
return new_property.id, True
def create_property_targets(session, property_id: int, portfolio_id: int, epc_target=None, heat_demand_target=None):
def create_property_targets(
session: Session, property_id: int, portfolio_id: int, epc_target=None, heat_demand_target=None
):
"""
This function will create a record for the property targets in the database if it does not exist.
:param session: The database session
@ -78,7 +82,9 @@ def create_property_targets(session, property_id: int, portfolio_id: int, epc_ta
return True
def update_property_data(session, property_id: int, portfolio_id: int, property_data: dict):
def update_property_data(
session: Session, property_id: int, portfolio_id: int, property_data: dict
):
now = datetime.datetime.now(pytz.utc)
try:
@ -103,7 +109,9 @@ def update_property_data(session, property_id: int, portfolio_id: int, property_
return True
def create_property_details_epc(session, property_details_epc: dict):
def create_property_details_epc(
session: Session, property_details_epc: dict
):
"""
This function will create or update a record for the property details EPC in the database.
:param session: The database session
@ -128,3 +136,36 @@ def create_property_details_epc(session, property_details_epc: dict):
session.flush()
return True
def update_or_create_property_spatial_details(session: Session, uprn: int, property_details_spatial: dict):
"""
Update an existing property details record or create a new one based on the UPRN.
:param session: The SQLAlchemy session for database interaction.
:param uprn: The unique property reference number (UPRN) of the property.
:param property_details_spatial: A dictionary containing the spatial property details to store or update.
:return: True if the operation is successful, otherwise raises an exception.
"""
try:
# Attempt to fetch the existing property details
existing_property_details = session.query(PropertyDetailsSpatial).filter_by(
uprn=uprn
).one()
# Update the fields with the data in property_details
for key, value in property_details_spatial.items():
setattr(existing_property_details, key, value)
# Merge the updated property details back into the session and flush
session.merge(existing_property_details)
session.flush()
except NoResultFound:
# Create a new record if not found
new_property_details = PropertyDetailsSpatial(uprn=uprn, **property_details_spatial)
session.add(new_property_details)
session.flush()
return True

View file

@ -80,7 +80,11 @@ def upload_recommendations(session: Session, recommendations_to_upload, property
"starting_u_value": rec.get("starting_u_value"),
"new_u_value": rec.get("new_u_value"),
"sap_points": rec["sap_points"],
"heat_demand": rec["heat_demand"],
"co2_equivalent_savings": rec["co2_equivalent_savings"],
"total_work_hours": rec["labour_hours"],
"energy_cost_savings": rec["energy_cost_savings"],
"labour_days": rec["labour_days"]
}
for rec in recommendations_to_upload
]

View file

@ -32,6 +32,7 @@ class MaterialType(enum.Enum):
ewi_wall_demolition = "ewi_wall_demolition"
ewi_wall_preparation = "ewi_wall_preparation"
ewi_wall_redecoration = "ewi_wall_redecoration"
low_energy_lighting_installation = "low_energy_lighting_installation"
class DepthUnit(enum.Enum):

View file

@ -42,6 +42,7 @@ class Portfolio(Base):
property_valuation_increase = Column(Float) # Unit is always £ so we don't need to store the unit for the moment
rental_yield_increase = Column(Float) # Unit is always £ so we don't need to store the unit for the moment
total_work_hours = Column(Float)
labour_days = Column(Float)
created_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
updated_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
@ -151,6 +152,20 @@ class PropertyDetailsEpcModel(Base):
energy_tariff = Column(Text)
primary_energy_consumption = Column(Float)
co2_emissions = Column(Float)
adjusted_energy_consumption = Column(Float)
class PropertyDetailsSpatial(Base):
__tablename__ = "property_details_spatial"
id = Column(Integer, primary_key=True, autoincrement=True)
uprn = Column(Integer, nullable=False)
x_coordinate = Column(Float)
y_coordinate = Column(Float)
latitude = Column(Float)
longitude = Column(Float)
conservation_status = Column(Boolean)
is_listed_building = Column(Boolean)
is_heritage_building = Column(Boolean)
class PropertyDetailsMeter(Base):

View file

@ -28,6 +28,7 @@ class Recommendation(Base):
property_valuation_increase = Column(Float)
rental_yield_increase = Column(Float)
total_work_hours = Column(Float)
labour_days = Column(Float)
class RecommendationMaterials(Base):

View file

@ -1,5 +1,6 @@
from datetime import datetime
import numpy as np
import pandas as pd
from epc_api.client import EpcClient
from fastapi import APIRouter, Depends
@ -12,7 +13,8 @@ from backend.app.db.connection import db_engine
from backend.app.db.functions.materials_functions import get_materials
from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations
from backend.app.db.functions.property_functions import (
create_property, create_property_details_epc, create_property_targets, update_property_data
create_property, create_property_details_epc, create_property_targets, update_property_data,
update_or_create_property_spatial_details
)
from backend.app.db.functions.recommendations_functions import (
create_plan, create_plan_recommendations, upload_recommendations
@ -20,25 +22,21 @@ from backend.app.db.functions.recommendations_functions import (
from backend.app.db.models.portfolio import rating_lookup
from backend.app.dependencies import validate_token
from backend.app.plan.schemas import PlanTriggerRequest
from backend.app.plan.utils import (
create_recommendation_scoring_data, get_cleaned, insert_temp_recommendation_id
)
from backend.app.utils import epc_to_sap_lower_bound, read_csv_from_s3, read_parquet_from_s3
from backend.app.plan.utils import create_recommendation_scoring_data, get_cleaned
from backend.app.utils import epc_to_sap_lower_bound, read_csv_from_s3, read_parquet_from_s3, sap_to_epc
from backend.ml_models.sap_change_model.api import SAPChangeModelAPI
from backend.ml_models.api import ModelApi
from backend.Property import Property
from etl.epc.DataProcessor import DataProcessor
from etl.epc.settings import COLUMNS_TO_MERGE_ON
from recommendations.FloorRecommendations import FloorRecommendations
from recommendations.RoofRecommendations import RoofRecommendations
from recommendations.VentilationRecommendations import VentilationRecommendations
from recommendations.FireplaceRecommendations import FireplaceRecommendations
from recommendations.optimiser.CostOptimiser import CostOptimiser
from recommendations.optimiser.GainOptimiser import GainOptimiser
from recommendations.optimiser.optimiser_functions import prepare_input_measures
from recommendations.WallRecommendations import WallRecommendations
from recommendations.Recommendations import Recommendations
from utils.logger import setup_logger
from utils.s3 import read_dataframe_from_s3_parquet
from backend.ml_models.Valuation import PropertyValuation
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
logger = setup_logger()
@ -83,6 +81,7 @@ async def trigger_plan(body: PlanTriggerRequest):
if not is_new:
continue
# TODO: Need to add heat demand target
create_property_targets(
session,
property_id=property_id,
@ -123,55 +122,23 @@ async def trigger_plan(body: PlanTriggerRequest):
recommendations = {}
recommendations_scoring_data = []
property_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)
floor_recommender.recommend()
if floor_recommender.recommendations:
property_recommendations.append(floor_recommender.recommendations)
# Wall recommendations
wall_recomender = WallRecommendations(property_instance=p, materials=materials)
wall_recomender.recommend()
if wall_recomender.recommendations:
property_recommendations.append(wall_recomender.recommendations)
# Roof recommendations
roof_recommender = RoofRecommendations(property_instance=p, materials=materials)
roof_recommender.recommend()
if roof_recommender.recommendations:
property_recommendations.append(roof_recommender.recommendations)
# Ventilation recommendations
ventilation_recomender = VentilationRecommendations(
property_instance=p,
materials=[part for part in materials if part["type"] == "mechanical_ventilation"]
# This is temp - this should happen after scoring
cleaned_property_data = DataProcessor.apply_averages_cleaning(
data_to_clean=pd.DataFrame([dict(**p.get_model_data(), LOCAL_AUTHORITY=p.data["local-authority"])]),
cleaning_data=cleaning_data,
cols_to_merge_on=['PROPERTY_TYPE', 'BUILT_FORM', 'CONSTRUCTION_AGE_BAND', 'LOCAL_AUTHORITY'],
)
ventilation_recomender.recommend()
p.set_number_lighting_outlets(cleaned_property_data)
if ventilation_recomender.recommendation:
property_recommendations.append(ventilation_recomender.recommendation)
# Fireplace sealing recommendations
fireplace_recommender = FireplaceRecommendations(property_instance=p)
fireplace_recommender.recommend()
if fireplace_recommender.recommendation:
property_recommendations.append(fireplace_recommender.recommendation)
# We insert temporary ids into the recommendations which is important for the optimiser later
property_recommendations = insert_temp_recommendation_id(property_recommendations)
recommender = Recommendations(property_instance=p, materials=materials)
property_recommendations = recommender.recommend()
if not property_recommendations:
continue
@ -194,6 +161,12 @@ async def trigger_plan(body: PlanTriggerRequest):
# We update the ending record with the recommended updates and we set lodgement date to today
ending_epc_data["DAYS_TO_ENDING"] = data_processor.calculate_days_to(created_at)
property_scoring_data[p.id] = {
"starting_epc_data": starting_epc_data,
"ending_epc_data": ending_epc_data,
"fixed_data": fixed_data
}
for recommendations_by_type in property_recommendations:
for i, rec in enumerate(recommendations_by_type):
scoring_dict = create_recommendation_scoring_data(
@ -234,55 +207,42 @@ async def trigger_plan(body: PlanTriggerRequest):
recommendations_scoring_data = DataProcessor.clean_efficiency_variables(recommendations_scoring_data)
sap_change_model_api = SAPChangeModelAPI(portfolio_id=body.portfolio_id, timestamp=created_at)
file_location = sap_change_model_api.upload_scoring_data(
df=recommendations_scoring_data, bucket=get_settings().DATA_BUCKET
model_api = ModelApi(portfolio_id=body.portfolio_id, timestamp=created_at)
all_predictions = model_api.predict_all(
df=recommendations_scoring_data,
bucket=get_settings().DATA_BUCKET,
prediction_buckets={
"sap_change_predictions": get_settings().SAP_PREDICTIONS_BUCKET,
"heat_demand_predictions": get_settings().HEAT_PREDICTIONS_BUCKET,
"carbon_change_predictions": get_settings().CARBON_PREDICTIONS_BUCKET
}
)
response = sap_change_model_api.predict(
file_location="s3://{DATA_BUCKET}/".format(DATA_BUCKET=get_settings().DATA_BUCKET) + file_location,
)
# Retrieve the predictions
predictions = pd.DataFrame(
read_parquet_from_s3(
bucket_name=get_settings().PREDICTIONS_BUCKET,
file_key=response["storage_filepath"].split(get_settings().PREDICTIONS_BUCKET + "/")[1]
)
)
predictions["predictions"] = predictions["predictions"].astype(float).round(1)
predictions[['property_id', 'recommendation_id']] = predictions['id'].str.split('+', expand=True)
# Insert the predictions into the recommendations and run the optimiser
logger.info("Optimising recommendations")
for property_id in recommendations.keys():
property = [p for p in input_properties if p.id == property_id][0]
property_predictions = predictions[predictions["property_id"] == str(property_id)]
property_instance = [p for p in input_properties if p.id == property_id][0]
for recommendations_by_type in recommendations[property_id]:
for rec in recommendations_by_type:
new_sap = property_predictions[property_predictions["recommendation_id"] == str(
rec["recommendation_id"]
)]["predictions"].values[0]
recommendations_with_impact = Recommendations.calculate_recommendation_impact(
property_instance=property_instance,
all_predictions=all_predictions,
recommendations=recommendations
)
rec["sap_points"] = new_sap - float(property.data["current-energy-efficiency"])
if rec["sap_points"] is None:
raise ValueError("Sap points missing")
input_measures = prepare_input_measures(recommendations[property_id], body.goal)
input_measures = prepare_input_measures(recommendations_with_impact, body.goal)
if body.budget:
optimiser = GainOptimiser(input_measures, max_cost=body.budget)
else:
# The minimum gain is the minimum number of SAP points required to get to the target SAP band
current_sap_points = int(property.data["current-energy-efficiency"])
current_sap_points = int(property_instance.data["current-energy-efficiency"])
target_sap_points = epc_to_sap_lower_bound(body.goal_value)
# If the gain is negative, the optimiser will return an empty solution
optimiser = CostOptimiser(
input_measures, min_gain=target_sap_points - current_sap_points
input_measures,
min_gain=CostOptimiser.calculate_sap_gain_with_slack(target_sap_points - current_sap_points)
)
optimiser.setup()
@ -291,13 +251,28 @@ async def trigger_plan(body: PlanTriggerRequest):
selected_recommendations = {r["id"] for r in solution}
# If wall ventilation is selected, we also include mechanical ventilation as a best practice measure
if any(x in [r["type"] for r in solution] for x in [
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"
]):
ventilation_rec = [
r for r in recommendations_with_impact if r[0]["type"] == "mechanical_ventilation"
][0]
selected_recommendations = set(
list(selected_recommendations) + [ventilation_rec[0]["recommendation_id"]]
)
# We check if the selected recommendation is wall ventilation and if so, we make sure
# mechanical ventilation is selected
# We'll use the set of selected recommendations to filter the recommendations to upload
final_recommendations = [
[
{**rec, "default": True if rec["recommendation_id"] in selected_recommendations else False}
for rec in recommendations_by_type
]
for recommendations_by_type in recommendations[property_id]
for recommendations_by_type in recommendations_with_impact
]
# We'll also unlist the recommendations so they're a bit easier to handle from here onwards
@ -306,11 +281,209 @@ async def trigger_plan(body: PlanTriggerRequest):
]
recommendations[property_id] = final_recommendations
# This is a temporary step, to estimate the impact of the measured on heat demand and carbon
# TODO: This needs to be cleaned up, if it happens to be kept
combined_recommendations_scoring_data = []
representative_recs = {}
for property_id, property_recommendations in recommendations.items():
default_recommendations = [r for r in property_recommendations if r["default"]]
default_types = {x["type"] for x in default_recommendations}
# Missing types
missing_types = list(set([r["type"] for r in property_recommendations if r["type"] not in default_types]))
# We might have a missing type as one of the solid wall options because for a solid wall, you might
# have ewi or iwi but only one of them will be a default
if ("internal_wall_insulation" in default_types) or ("external_wall_insaultion" in default_types):
missing_types = [
t for t in missing_types if t not in ["internal_wall_insulation", "external_wall_insulation"]
]
if missing_types:
for missed_type in missing_types:
missed = [r for r in property_recommendations if r["type"] == missed_type]
min_cost = min([r["total"] for r in missed])
# Grab a representative, based on cheapest cost
representative_rec = [r for r in property_recommendations if np.isclose(r["total"], min_cost)]
default_recommendations.append(representative_rec[0])
representative_recs[property_id] = default_recommendations
property_instance = [p for p in input_properties if p.id == property_id][0]
property_scoring_datasets = property_scoring_data[property_id]
starting_epc_data = property_scoring_datasets["starting_epc_data"].copy()
ending_epc_data = property_scoring_datasets["ending_epc_data"].copy()
fixed_data = property_scoring_datasets["fixed_data"].copy()
scoring_dict = {}
for rec in default_recommendations:
scoring_dict = create_recommendation_scoring_data(
property=property_instance,
recommendation=rec,
starting_epc_data=starting_epc_data,
ending_epc_data=ending_epc_data,
fixed_data=fixed_data,
)
# At each iteration, we want to update the ending_epc_data, so in the end, ending_epc_data contains
# all of the updates
for k in scoring_dict.keys():
if k in ending_epc_data.columns:
ending_epc_data[k] = scoring_dict[k]
combined_recommendations_scoring_data.append(scoring_dict)
# PERFORM SAME STEPS AGAIN - TODO: TO BE REMOVED
combined_recommendations_scoring_data = pd.DataFrame(combined_recommendations_scoring_data)
# Perform the same cleaning as in the model - first clean number of room variables though
combined_recommendations_scoring_data = DataProcessor.apply_averages_cleaning(
data_to_clean=combined_recommendations_scoring_data,
cleaning_data=cleaning_data,
cols_to_merge_on=['PROPERTY_TYPE', 'BUILT_FORM', 'CONSTRUCTION_AGE_BAND', 'LOCAL_AUTHORITY'],
colnames=["NUMBER_HABITABLE_ROOMS", "NUMBER_HEATED_ROOMS"],
)
combined_recommendations_scoring_data = DataProcessor.apply_averages_cleaning(
data_to_clean=combined_recommendations_scoring_data,
cleaning_data=cleaning_data,
cols_to_merge_on=COLUMNS_TO_MERGE_ON + ["LOCAL_AUTHORITY"],
).drop(columns=["LOCAL_AUTHORITY"])
combined_recommendations_scoring_data = DataProcessor.clean_missings_after_description_process(
combined_recommendations_scoring_data,
ignore_cols=[
c for c in combined_recommendations_scoring_data.columns if ("thermal_transmittance" in c) or (
"insulation_thickness" in c) or ("ENERGY_EFF" in c)
]
)
combined_recommendations_scoring_data = DataProcessor.clean_efficiency_variables(
combined_recommendations_scoring_data
)
model_api = ModelApi(portfolio_id=body.portfolio_id, timestamp=created_at)
all_combined_predictions = model_api.predict_all(
df=combined_recommendations_scoring_data,
bucket=get_settings().DATA_BUCKET,
prediction_buckets={
"sap_change_predictions": get_settings().SAP_PREDICTIONS_BUCKET,
"heat_demand_predictions": get_settings().HEAT_PREDICTIONS_BUCKET,
"carbon_change_predictions": get_settings().CARBON_PREDICTIONS_BUCKET
}
)
# We update the carbon and heat demand predictions
for property_id, property_recommendations in recommendations.items():
combined_heat_demand = all_combined_predictions["heat_demand_predictions"]
combined_heat_demand = combined_heat_demand[combined_heat_demand["property_id"] == str(property_id)]
combined_carbon = all_combined_predictions["carbon_change_predictions"]
combined_carbon = combined_carbon[combined_carbon["property_id"] == str(property_id)]
property_instance = [p for p in input_properties if p.id == property_id][0]
carbon_change = float(
property_instance.data["co2-emissions-current"]
) - combined_carbon["predictions"].values[0]
starting_heat_demand = (
float(property_instance.data["energy-consumption-current"]) * property_instance.floor_area
)
expected_heat_demand = starting_heat_demand - (
combined_heat_demand["predictions"].values[0] * property_instance.floor_area
)
# We don't want to adjust the heat demand for mechanical ventilation so we add it back on
# We adjust the heat demand figures to align to the UCL paper
current_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered(
epc_energy_consumption=starting_heat_demand,
current_epc_rating=property_instance.data["current-energy-rating"],
)
# We sum up the SAP points of the default recommendations and calculate a new EPC category. This
# category is then used to produce adjusted energy figures
total_sap_points = sum([x["sap_points"] for x in representative_recs[property_id]])
expected_epc = sap_to_epc(float(property_instance.data["current-energy-efficiency"]) + total_sap_points)
expected_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered(
epc_energy_consumption=expected_heat_demand,
current_epc_rating=expected_epc,
)
heat_demand_change = (
current_adjusted_energy - expected_adjusted_energy
)
# update the recommendations
# We need to totals for the representative recommendations
representative_rec_data = [
{
"recommendation_id": r["recommendation_id"],
"co2_equivalent_savings": r.get("co2_equivalent_savings"),
"heat_demand": r.get("heat_demand"),
"type": r["type"]
} for r
in representative_recs[property_id]
]
representative_rec_data = pd.DataFrame(representative_rec_data)
# Convert co2 and heat demand to proportions of their column sums
representative_rec_data["co2_equivalent_savings_percent"] = (
representative_rec_data["co2_equivalent_savings"] /
representative_rec_data["co2_equivalent_savings"].sum()
)
representative_rec_data["heat_demand_percent"] = (
representative_rec_data["heat_demand"] / representative_rec_data["heat_demand"].sum()
)
# We'll use the proportions to update the carbon and heat demand
representative_rec_data["co2_equivalent_savings"] = (
carbon_change * representative_rec_data["co2_equivalent_savings_percent"]
)
representative_rec_data["heat_demand"] = (
heat_demand_change * representative_rec_data["heat_demand_percent"]
)
# Finally, insert these values into the final recommendations
for rec in property_recommendations:
if rec["type"] in ["external_wall_insulation", "internal_wall_insulation"]:
change_data = representative_rec_data[
representative_rec_data["type"].isin(["external_wall_insulation", "internal_wall_insulation"])
]
else:
change_data = representative_rec_data[representative_rec_data["type"] == rec["type"]]
if rec["type"] == "mechanical_ventilation":
rec["co2_equivalent_savings"] = 0
rec["heat_demand"] = 0
rec["energy_cost_savings"] = 0
else:
rec["co2_equivalent_savings"] = change_data["co2_equivalent_savings"].values[0]
rec["heat_demand"] = change_data["heat_demand"].values[0]
rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["heat_demand"])
# Update recommendations
recommendations[property_id] = property_recommendations
# For expected adjust energy, we don't include mechanical ventilation so we'll add it back on
expected_adjusted_energy = expected_adjusted_energy + representative_rec_data[
representative_rec_data["type"] == "mechanical_ventilation"
]["heat_demand"].values[0]
property_instance.set_adjusted_energy(
current_adjusted_energy=current_adjusted_energy,
expected_adjusted_energy=expected_adjusted_energy
)
# 1) the property data
# 2) the property details (epc)
# 3) the recommendations
logger.info("Uploading recommendations to the database")
property_valuation_increases = []
session.commit()
for i in range(0, len(input_properties), BATCH_SIZE):
try:
@ -324,6 +497,8 @@ async def trigger_plan(body: PlanTriggerRequest):
)
create_property_details_epc(session, property_details_epc)
update_or_create_property_spatial_details(session, p.uprn, p.spatial)
# TODO: TEMP
if p.data["uprn"] == "":
print("Get rid of me!")
@ -350,6 +525,16 @@ async def trigger_plan(body: PlanTriggerRequest):
session, plan_id=new_plan_id, recommendation_ids=uploaded_recommendation_ids
)
# Get defaults
default_recommendations = [r for r in recommendations_to_upload if r["default"]]
total_sap_points = sum([r["sap_points"] for r in default_recommendations])
new_sap_points = float(p.data["current-energy-efficiency"]) + total_sap_points
new_epc = sap_to_epc(new_sap_points)
property_valuation_increases.append(
PropertyValuation.estimate(property_instance=p, target_epc=new_epc)
)
# Commit the session after each batch
session.commit()
@ -365,7 +550,18 @@ async def trigger_plan(body: PlanTriggerRequest):
# way to do this, but it's the simplest and will be a process that we can re-use since when we change a
# recommendation from being default to not default, we'll need to re-run this process to re-calculate the
# the portfolion level impact
aggregate_portfolio_recommendations(session, portfolio_id=body.portfolio_id)
total_valuation_increase = sum(property_valuation_increases)
labour_days = round(max(
[sum(r["labour_days"] for r in rec_group if r["default"]) for p_id, rec_group in recommendations.items()]
))
aggregate_portfolio_recommendations(
session,
portfolio_id=body.portfolio_id,
total_valuation_increase=total_valuation_increase,
labour_days=labour_days
)
# Commit final changes
session.commit()

View file

@ -8,25 +8,6 @@ from backend.app.config import get_settings
import msgpack
def insert_temp_recommendation_id(property_recommendations):
"""
Creates a temporary recommendation id which is needed for
filtering recommendations between default and no, after the optimiser has been
run
:param property_recommendations: nested list of recommendations, grouped by data_types
:return: Updated recommendations_to_upload, where where recommendation has a "recommendation_id"
integer inserted
"""
idx = 0
for recs in property_recommendations:
for rec in recs:
rec["recommendation_id"] = idx
idx += 1
return property_recommendations
def get_cleaned():
"""
This function will retrieve the cleaned dataset from s3 which has the cleaned
@ -106,7 +87,7 @@ def create_recommendation_scoring_data(
scoring_dict[col] = "none"
# We update the description to indicate it's insulated
if recommendation["type"] == "wall_insulation":
if recommendation["type"] in ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"]:
# The upgrade made here is to the u-value of the walls and the description of the
# insulation thickness
scoring_dict["walls_thermal_transmittance_ENDING"] = recommendation["new_u_value"]
@ -125,7 +106,7 @@ def create_recommendation_scoring_data(
scoring_dict["walls_insulation_thickness_ENDING"] = "none"
# Update description to indicate it's insulate
if recommendation["type"] == "floor_insulation":
if recommendation["type"] in ["solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation"]:
if len(recommendation["parts"]) > 1:
raise NotImplementedError("Have more than 1 floor insulation part - handle this case")
@ -147,14 +128,24 @@ def create_recommendation_scoring_data(
if scoring_dict["floor_insulation_thickness_ENDING"] is None:
scoring_dict["floor_insulation_thickness_ENDING"] = "none"
if recommendation["type"] == "roof_insulation":
if recommendation["type"] in ["loft_insulation", "room_roof_insulation", "flat_roof_insulation"]:
scoring_dict["roof_thermal_transmittance_ENDING"] = recommendation["new_u_value"]
parts = recommendation["parts"]
if len(parts) != 1:
raise ValueError("More than one part for roof insulation - investiage me")
scoring_dict["roof_insulation_thickness_ENDING"] = str(int(parts[0]["depth"]))
# This is based on the values we have in the training data
valid_numeric_values = [
12, 25, 50, 75, 100, 150, 200, 250, 270, 300, 350, 400
]
proposed_depth = int(parts[0]["depth"])
if proposed_depth not in valid_numeric_values:
# Take the nearest value for scoring
proposed_depth = min(valid_numeric_values, key=lambda x: abs(x - proposed_depth))
scoring_dict["roof_insulation_thickness_ENDING"] = str(proposed_depth)
scoring_dict["ROOF_ENERGY_EFF_ENDING"] = "Very Good"
else:
# Fill missing roof u-values - this fill is not based on recommended upgrades
@ -180,8 +171,15 @@ def create_recommendation_scoring_data(
if recommendation["type"] == "sealing_open_fireplace":
scoring_dict["NUMBER_OPEN_FIREPLACES_ENDING"] = 0
if recommendation["type"] == "low_energy_lighting":
scoring_dict["LOW_ENERGY_LIGHTING_ENDING"] = 100
scoring_dict["LIGHTING_ENERGY_EFF_STARTING"] = "Very Good"
if recommendation["type"] not in [
"wall_insulation", "floor_insulation", "roof_insulation", "mechanical_ventilation", "sealing_open_fireplace",
"mechanical_ventilation", "sealing_open_fireplace", "low_energy_lighting",
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
"loft_insulation", "room_roof_insulation", "flat_roof_insulation",
"solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation"
]:
raise NotImplementedError("Implement me")

View file

@ -69,10 +69,10 @@ def generate_api_key():
return api_key
def sap_to_epc(sap_points: int):
def sap_to_epc(sap_points: int | float):
"""
Simple utility function to convert SAP points to EPC rating.
:param sapPoints: numerical value of SAP points, typically between 0 and 100
:param sap_points: numerical value of SAP points, typically between 0 and 100
:return:
"""

View file

@ -0,0 +1,72 @@
class AnnualBillSavings:
"""
This is a simple class which will estimate the annual bill savings, based on the kwh savings.
This class uses data from Ofgem, including their price caps, to provide us with an estimate for
1KWH of energy.
"""
# These gas an electricity consumption figures are based off of figures presented by Ofgem
# https://www.ofgem.gov.uk/information-consumers/energy-advice-households/average-gas-and-electricity-use-explained
AVERAGE_ELECTRICITY_CONSUMPTION = 2700
AVERAGE_GAS_CONSUMPTION = 11500
# Latest price cap figures from Ofgem are for January 2024
# https://www.ofgem.gov.uk/publications/changes-energy-price-cap-1-january-2024
ELECTRICITY_PRICE_CAP = 0.29
GAS_PRICE_CAP = 0.07
# This is a weighted mean of the price caps, using the consumption figures above as weights
PRICE_FACTOR = 0.11183098591549295
@classmethod
def estimate(cls, kwh: float):
"""
Estimate the annual bill savings based on the kwh savings
:param kwh: The kwh savings
:return: An estimate for annual bill savings
"""
return cls.PRICE_FACTOR * kwh
@classmethod
def adjust_energy_to_metered(cls, epc_energy_consumption, current_epc_rating):
"""
The over-prediction of energy use by EPCs in Great Britain: A comparison
of EPC-modelled and metered primary energy use intensity
Which can be found here: https://www.sciencedirect.com/science/article/pii/S0378778823002542
We implement the results on page 10
:return:
"""
gradients = {
"A": -0.1,
"B": -0.1,
"C": -0.43,
"D": -0.52,
"E": -0.7,
"F": -0.76,
"G": -0.76
}
intercepts = {
"A": 28,
"B": 28,
"C": 97,
"D": 119,
"E": 160,
"F": 157,
"G": 157
}
gradient = gradients[current_epc_rating]
intercept = intercepts[current_epc_rating]
# This should be negative
consumption_difference = gradient * epc_energy_consumption + intercept
if consumption_difference > 0:
raise ValueError("consumption_difference should be negative")
adjusted_consumption = (epc_energy_consumption + consumption_difference)
return adjusted_consumption

View file

@ -0,0 +1,22 @@
class PropertyValuation:
"""
This is a placeholder class for the property valuation model
"""
UPRN_VALUE_LOOKUP = {
15038202: {"current_value": 202000, "increase_percentage": 0.05725},
37024763: {"current_value": 213000, "increase_percentage": 0.025},
}
@classmethod
def estimate(cls, property_instance, target_epc):
data = cls.UPRN_VALUE_LOOKUP.get(property_instance.uprn)
if not data:
raise ValueError("Have not implemented valuation for this property")
new_valuation = (1 + data["increase_percentage"]) * data["current_value"]
increase = round(new_valuation - data["current_value"], 2)
return increase

139
backend/ml_models/api.py Normal file
View file

@ -0,0 +1,139 @@
import pandas as pd
import requests
from requests.exceptions import RequestException
from utils.logger import setup_logger
from utils.s3 import save_dataframe_to_s3_parquet
from backend.app.utils import read_parquet_from_s3
logger = setup_logger()
class ModelApi:
MODEL_PREFIXES = [
"sap_change_predictions",
"heat_demand_predictions",
"carbon_change_predictions"
]
MODEL_URLS = {
"sap_change_predictions": "sapmodel",
"heat_demand_predictions": "heatmodel",
"carbon_change_predictions": "carbonmodel"
}
def __init__(
self,
portfolio_id,
timestamp,
base_url="https://api.dev.hestia.homes",
):
"""
This class handles the communication with the Model APIs. These models include SAP change, heat demain change
and carbon change
property_id (int, optional): :
:param portfolio_id: The portfolio ID to be passed in the request payload. Defaults to 4.
:param timestamp: The creation timestamp to be passed in the request payload. Defaults to None.
:param base_url:
"""
self.base_url = base_url
self.portfolio_id = portfolio_id
self.timestamp = timestamp
def upload_scoring_data(self, df: pd.DataFrame, bucket: str, model_prefix: str) -> str:
"""
The sap model api needs a scoring data that is sitting in s3 to use as a dataset to score on
This method allows the user to upload a table as a parquet file. This method will return the file
location, which can be used as the file location in the predict() method
:param df: Pandas dataframe with scoring data to be uploaded to s3
:param bucket: Name of the bucket in s3 to upload to
:param model_prefix: The model prefix to be used in the file location
:return:
"""
if model_prefix not in self.MODEL_PREFIXES:
raise ValueError(f"Model prefix specified is not in {self.MODEL_PREFIXES}")
# Store parquet file in s3 for scoring
file_location = f"{model_prefix}/{self.portfolio_id}/{self.timestamp}.parquet"
logger.info("Storing scoring data to s3")
save_dataframe_to_s3_parquet(
df=df,
bucket_name=bucket,
file_key=file_location
)
return file_location
def predict(self, file_location, model_prefix: str):
"""Makes a POST request to the SAP Change Model API with the provided parameters.
Args:
file_location (str): The file location to be passed in the request payload.
model_prefix (str): The model prefix to be used in the request URL.
Returns:
dict: The API response as a dictionary if the request was successful, None otherwise.
"""
logger.info(f"Making request to {model_prefix} change api")
url = f"{self.base_url}/{self.MODEL_URLS[model_prefix]}/predict"
payload = {
"file_location": file_location,
"property_id": "", # This should get removed
"portfolio_id": self.portfolio_id,
"created_at": self.timestamp
}
try:
response = requests.post(url, json=payload, headers={"Content-Type": "application/json"}, timeout=120)
# Check if the response status code is 2xx (success)
response.raise_for_status()
# Return the JSON response as a Python dictionary
return response.json()
except RequestException as e:
logger.error(f"An error occurred: {e}")
# In case of an error, you might want to return None or raise the exception
# depending on how you want to handle errors in your application
return None
def predict_all(self, df, bucket, prediction_buckets) -> dict:
"""
For each model prefix, this method will upload the scoring data to s3 and then make a request to the
model api to generate predictions. The predictions will be stored in the predictions bucket.
This method will then fetch the stored predictions and format them, returning all of the predictions as
a dictionary of panaas dataframes
:param df: Pandas dataframe with scoring data to be uploaded to s3
:param bucket: Name of the bucket in s3 to upload to
:param prediction_buckets: Dictionary containing the prediction buckets for each model prefix
:return:
"""
predictions = {}
for model_prefix in self.MODEL_PREFIXES:
logger.info(f"Scoring for model prefix: {model_prefix}")
file_location = self.upload_scoring_data(df, bucket, model_prefix)
response = self.predict(
"s3://{DATA_BUCKET}/".format(DATA_BUCKET=bucket) + file_location, model_prefix
)
predictions_bucket = prediction_buckets[model_prefix]
# Retrieve the predictions
predictions_df = pd.DataFrame(
read_parquet_from_s3(
bucket_name=predictions_bucket,
file_key=response["storage_filepath"].split(predictions_bucket + "/")[1]
)
)
predictions_df["predictions"] = predictions_df["predictions"].astype(float).round(1)
predictions_df[['property_id', 'recommendation_id']] = predictions_df['id'].str.split('+', expand=True)
predictions[model_prefix] = predictions_df
return predictions

View file

@ -1,83 +0,0 @@
import pandas as pd
import requests
from requests.exceptions import RequestException
from utils.logger import setup_logger
from utils.s3 import save_dataframe_to_s3_parquet
logger = setup_logger()
class SAPChangeModelAPI:
def __init__(
self,
portfolio_id,
timestamp,
base_url="https://api.dev.hestia.homes",
):
"""
property_id (int, optional): :
:param portfolio_id: The portfolio ID to be passed in the request payload. Defaults to 4.
:param timestamp: The creation timestamp to be passed in the request payload. Defaults to None.
:param base_url:
"""
self.base_url = base_url
self.portfolio_id = portfolio_id
self.timestamp = timestamp
def upload_scoring_data(self, df: pd.DataFrame, bucket: str) -> str:
"""
The sap model api needs a scoring data that is sitting in s3 to use as a dataset to score on
This method allows the user to upload a table as a parquet file. This method will return the file
location, which can be used as the file location in the predict() method
:param df: Pandas dataframe with scoring data to be uploaded to s3
:param bucket: Name of the bucket in s3 to upload to
:return:
"""
# Store parquet file in s3 for scoring
file_location = "sap_change_predictions/{portfolio_id}/{timestamp}.parquet".format(
portfolio_id=self.portfolio_id,
timestamp=self.timestamp
)
logger.info("Storing scoring data to s3")
save_dataframe_to_s3_parquet(
df=df,
bucket_name=bucket,
file_key=file_location
)
return file_location
def predict(self, file_location):
"""Makes a POST request to the SAP Change Model API with the provided parameters.
Args:
file_location (str): The file location to be passed in the request payload.
Returns:
dict: The API response as a dictionary if the request was successful, None otherwise.
"""
logger.info("Making request to sap change api")
url = f"{self.base_url}/sapmodel/predict"
payload = {
"file_location": file_location,
"property_id": "", # This should get removed
"portfolio_id": self.portfolio_id,
"created_at": self.timestamp
}
try:
response = requests.post(url, json=payload, headers={"Content-Type": "application/json"}, timeout=120)
# Check if the response status code is 2xx (success)
response.raise_for_status()
# Return the JSON response as a Python dictionary
return response.json()
except RequestException as e:
logger.error(f"An error occurred: {e}")
# In case of an error, you might want to return None or raise the exception
# depending on how you want to handle errors in your application
return None

View file

@ -12,6 +12,7 @@ mock_epc_response = {
"uprn": 1,
"number-habitable-rooms": 5,
"property-type": "House",
"built-form": "Detached",
"inspection-date": "2023-06-01",
'lodgement-datetime': '2023-06-01 20:29:01',
"some-other-key": "some-value",
@ -42,6 +43,7 @@ mock_epc_response = {
"uprn": 2,
"number-habitable-rooms": 5,
"property-type": "House",
"built-form": "Detached",
"inspection-date": "2023-05-01",
'lodgement-datetime': '2023-05-01 20:29:01',
"some-other-key": "some-other-value",

View file

@ -204,9 +204,9 @@ class TestSapModelPrep:
'external_insulation': False, 'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 0.7,
'is_park_home_ENDING': False, 'walls_insulation_thickness_ENDING': 'average',
'external_insulation_ENDING': False, 'internal_insulation_ENDING': False,
'floor_thermal_transmittance': 0.64, 'is_to_unheated_space': False, 'is_to_external_air': False,
'floor_thermal_transmittance': 0.52, 'is_to_unheated_space': False, 'is_to_external_air': False,
'is_suspended': True, 'is_solid': False, 'another_property_below': False,
'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.64,
'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.52,
'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 1.5, 'is_pitched': True,
'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'has_dwelling_above': False, 'roof_insulation_thickness': 'below average',
@ -260,7 +260,7 @@ class TestSapModelPrep:
'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown',
'fuel_type_ENDING': 'oil', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False,
'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown',
'estimated_perimeter_STARTING': 44.77882152472145, 'estimated_perimeter_ENDING': 44.77882152472145,
'estimated_perimeter_STARTING': 30.531014675946444, 'estimated_perimeter_ENDING': 30.531014675946444,
'HOT_WATER_ENERGY_EFF_STARTING': "Good",
"FLOOR_ENERGY_EFF_STARTING": "Unknown",
"WINDOWS_ENERGY_EFF_STARTING": "Good",
@ -310,7 +310,7 @@ class TestSapModelPrep:
recommendation = {
"recommendation_id": 0,
"new_u_value": 0.7,
"type": "wall_insulation"
"type": "cavity_wall_insulation"
}
test_record = create_recommendation_scoring_data(
@ -356,7 +356,7 @@ class TestSapModelPrep:
assert test_record[c].values[0] == row[c]
def test_solid_wall_insulation(self, cleaned, cleaning_data):
def test_internal_wall_insulation(self, cleaned, cleaning_data):
starting_epc2 = {
'low-energy-fixed-light-count': '2', 'address': 'FLAT 12, WAREHOUSE W, 3 WESTERN GATEWAY',
@ -513,6 +513,7 @@ class TestSapModelPrep:
data=starting_epc2
)
home2.get_components(cleaned)
home2.set_number_lighting_outlets(None)
data_processor2 = DataProcessor(None, newdata=True)
data_processor2.insert_data(pd.DataFrame([home2.get_model_data()]))
@ -530,7 +531,7 @@ class TestSapModelPrep:
recommendation2 = {
"recommendation_id": 0,
"new_u_value": 0.21,
"type": "wall_insulation"
"type": "internal_wall_insulation"
}
test_record2 = create_recommendation_scoring_data(
@ -644,9 +645,9 @@ class TestSapModelPrep:
'is_park_home': False, 'walls_insulation_thickness': 'none', 'external_insulation': False,
'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 2.0, 'is_park_home_ENDING': False,
'walls_insulation_thickness_ENDING': 'none', 'external_insulation_ENDING': False,
'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.62, 'is_to_unheated_space': False,
'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.51, 'is_to_unheated_space': False,
'is_to_external_air': False, 'is_suspended': True, 'is_solid': False, 'another_property_below': False,
'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.62,
'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.51,
'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 2.3, 'is_pitched': True,
'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'has_dwelling_above': False, 'roof_insulation_thickness': 'none', 'roof_thermal_transmittance_ENDING': 2.3,
@ -699,7 +700,7 @@ class TestSapModelPrep:
'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown',
'fuel_type_ENDING': 'mains gas', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False,
'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown',
'estimated_perimeter_STARTING': 41.634120622393354, 'estimated_perimeter_ENDING': 41.634120622393354,
'estimated_perimeter_STARTING': 30.06908711617298, 'estimated_perimeter_ENDING': 30.06908711617298,
'HOT_WATER_ENERGY_EFF_STARTING': "Good",
"FLOOR_ENERGY_EFF_STARTING": "Unknown",
"WINDOWS_ENERGY_EFF_STARTING": "Average",
@ -732,6 +733,7 @@ class TestSapModelPrep:
data=starting_epc3
)
home3.get_components(cleaned)
home3.set_number_lighting_outlets(None)
data_processor3 = DataProcessor(None, newdata=True)
data_processor3.insert_data(pd.DataFrame([home3.get_model_data()]))
@ -851,9 +853,9 @@ class TestSapModelPrep:
'is_park_home': False, 'walls_insulation_thickness': 'none', 'external_insulation': False,
'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 1.7, 'is_park_home_ENDING': False,
'walls_insulation_thickness_ENDING': 'none', 'external_insulation_ENDING': False,
'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.66, 'is_to_unheated_space': False,
'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.53, 'is_to_unheated_space': False,
'is_to_external_air': False, 'is_suspended': False, 'is_solid': True, 'another_property_below': False,
'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.66,
'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.53,
'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 0.21, 'is_pitched': True,
'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'has_dwelling_above': False, 'roof_insulation_thickness': '200', 'roof_thermal_transmittance_ENDING': 0.21,
@ -907,7 +909,7 @@ class TestSapModelPrep:
'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown',
'fuel_type_ENDING': 'mains gas', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False,
'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown',
'estimated_perimeter_STARTING': 37.54197650630557, 'estimated_perimeter_ENDING': 37.54197650630557,
'estimated_perimeter_STARTING': 27.113649698998472, 'estimated_perimeter_ENDING': 27.113649698998472,
'HOT_WATER_ENERGY_EFF_STARTING': "Good",
"FLOOR_ENERGY_EFF_STARTING": "Unknown",
"WINDOWS_ENERGY_EFF_STARTING": "Average",
@ -940,6 +942,7 @@ class TestSapModelPrep:
data=starting_epc4
)
home4.get_components(cleaned)
home4.set_number_lighting_outlets(None)
data_processor4 = DataProcessor(None, newdata=True)
data_processor4.insert_data(pd.DataFrame([home4.get_model_data()]))

View file

@ -73,6 +73,7 @@ def app():
suspended_floor_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="suspended_floor_insulation", header=0)
solid_floor_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="solid_floor_insulation", header=0)
ewi_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="external_wall_insulation", header=0)
lel_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="low_energy_lighting", header=0)
# Form a single table to be uploaded
costs = pd.concat(
@ -83,6 +84,7 @@ def app():
suspended_floor_costs,
solid_floor_costs,
ewi_costs,
lel_costs
]
)

View file

@ -0,0 +1,79 @@
"""
This script will create an input csv for the recommendation engine and upload it to S3, which can be used for
testing
"""
import os
import numpy as np
import pandas as pd
from epc_api.client import EpcClient
from utils.s3 import save_csv_to_s3
FILE_SIZE = 5
EPC_AUTH_TOKEN = os.getenv("EPC_AUTH_TOKEN", None)
USER_ID = 8
PORTFOLIO_ID = 54
def app():
# For this dataset, we want 3 properties, all hourses. A mid-terrace, and end-terrace and a semi-detached
epc_client = EpcClient(auth_token=EPC_AUTH_TOKEN)
# Birmingham has a Local Authority Code of E08000025
# Let's take an EPC D property
example_1_reponse = epc_client.domestic.search(
params={
"local-authority": "E08000025",
"property-type": "house",
}
)
g_data = epc_client.domestic.search(params={"energy-band": "g"}, size=n_g)
f_data = epc_client.domestic.search(params={"energy-band": "f"}, size=n_f)
e_data = epc_client.domestic.search(params={"energy-band": "e"}, size=n_e)
d_data = epc_client.domestic.search(params={"energy-band": "d"}, size=n_d)
c_data = epc_client.domestic.search(params={"energy-band": "c"}, size=n_c)
b_data = epc_client.domestic.search(params={"energy-band": "b"}, size=n_b)
a_data = epc_client.domestic.search(params={"energy-band": "a"}, size=n_a)
# Combine the final data
final_data = (
g_data["rows"] + f_data["rows"] + e_data["rows"] + d_data["rows"] + c_data["rows"] + b_data["rows"]
+ a_data["rows"]
)
# TODO: We also take homes with just a specific type of wall
final_data = [
x for x in final_data if ("cavity wall" in x["walls-description"].lower()) or (
"solid brick" in x["walls-description"].lower()
) or ("average thermal transmittance" in x["walls-description"].lower())
]
# TODO: For the moment, don't use park homes
final_csv_data = pd.DataFrame(
[{"address": x["address"], "postcode": x["postcode"], "Notes": None} for x
in final_data if
x["property-type"] not in ["Park home"]]
)
final_csv_data = pd.concat([starting_csv, final_csv_data]).reset_index(drop=True)
# Store the data in s3
filename = f"{USER_ID}/{PORTFOLIO_ID}/test_inputs.csv"
save_csv_to_s3(
dataframe=final_csv_data,
bucket_name="retrofit-plan-inputs-dev",
file_name=filename
)
body = {
"portfolio_id": str(PORTFOLIO_ID),
"housing_type": "Social",
"goal": "Increase EPC",
"goal_value": "B",
"trigger_file_path": filename
}
print(body)

View file

@ -1,27 +1,23 @@
import numpy as np
from recommendations.county_to_region import county_to_region_map
# This data comes from SPONs
# This data comes from SPONs 2023
regional_labour_variations = [
{"Region": "Outer London (Spons 2023)", "Adjustment_Factor": 1.00},
{"Region": "Outer London", "Adjustment_Factor": 1.00},
{"Region": "Inner London", "Adjustment_Factor": 1.05},
{"Region": "South East", "Adjustment_Factor": 0.96},
{"Region": "South West", "Adjustment_Factor": 0.90},
{"Region": "South East England", "Adjustment_Factor": 0.96},
{"Region": "South West England", "Adjustment_Factor": 0.90},
{"Region": "East of England", "Adjustment_Factor": 0.93},
{"Region": "East Midlands", "Adjustment_Factor": 0.88},
{"Region": "West Midlands", "Adjustment_Factor": 0.87},
{"Region": "North East", "Adjustment_Factor": 0.83},
{"Region": "North West", "Adjustment_Factor": 0.88},
{"Region": "Yorkshire and Humberside", "Adjustment_Factor": 0.86},
{"Region": "North East England", "Adjustment_Factor": 0.83},
{"Region": "North West England", "Adjustment_Factor": 0.88},
{"Region": "Yorkshire and the Humber", "Adjustment_Factor": 0.86},
{"Region": "Wales", "Adjustment_Factor": 0.88},
{"Region": "Scotland", "Adjustment_Factor": 0.88},
{"Region": "Northern Ireland", "Adjustment_Factor": 0.76}
]
county_map = {
"Northamptonshire": "East Midlands",
"Hampshire": "South East",
}
class Costs:
"""
@ -40,8 +36,12 @@ class Costs:
# We assume a conservative 10% contingency for all works which is a rate defined by SPONs
CONTINGENCY = 0.1
# We use a higher contingency rate for internal wall insulation because of the potential for issues with moving
# fittings and trimming doors, as well as scope for damage to the existing wall during preparation.
IWI_CONTINGENCY = 0.15
# Where there is more uncertainty, a higher contingency rate is used
HIGH_RISK_CONTINGENCY = 0.15
HIGH_RISK_CONTINGENCY = 0.2
# When there is less uncertainty, a lower contingency rate is used
LOW_RISK_CONTINGENCY = 0.05
@ -54,11 +54,11 @@ class Costs:
# have a preliminaries of 12-14% so we use 12% as the median for the preliminaries rate.
# For External wall insulation (EWI), we use 15% as the preliminaries rate if we think the property might
# need scaffolding, otherwise we use 12%. This is to account for any site preparation that might be required
EWI_NO_SCAFFOLDING_PRELIMINARIES = 0.12
EWI_SCAFFOLDING_PRELIMINARIES = 0.15
EWI_NO_SCAFFOLDING_PRELIMINARIES = 0.15
EWI_SCAFFOLDING_PRELIMINARIES = 0.20
VAT_RATE = 0.2
PROFIT_MARGIN = 0.15
PROFIT_MARGIN = 0.2
def __init__(self, property_instance):
"""
@ -71,13 +71,16 @@ class Costs:
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.region = county_to_region_map.get(self.property.data["county"], None)
if self.region is None:
# Try and grab using the local-authority-label
self.region = county_to_region_map.get(self.property.data["local-authority-label"], None)
if self.region is None:
raise ValueError("Region not found in county map")
self.labour_adjustment_factor = [
x["Adjustment_Factor"] for x in self.regional_labour_variations if
x["Region"] == self.county
x["Region"] == self.region
][0]
if not self.labour_adjustment_factor:
@ -115,6 +118,9 @@ class Costs:
labour_hours = material["labour_hours_per_unit"] * wall_area
# Assume a team of 2
labour_days = (labour_hours / 8) / 2
return {
"total": total_cost,
"subtotal": subtotal_before_vat,
@ -124,7 +130,8 @@ class Costs:
"material": base_material_cost,
"profit": profit_cost,
"labour_hours": labour_hours,
"labour_cost": labour_cost
"labour_cost": labour_cost,
"labour_days": labour_days
}
def loft_insulation(self, floor_area, material):
@ -153,6 +160,9 @@ class Costs:
labour_hours = material["labour_hours_per_unit"] * floor_area
# Assume a team of 1 person
labour_days = labour_hours / 8
return {
"total": total_cost,
"subtotal": subtotal_before_vat,
@ -162,7 +172,8 @@ class Costs:
"material": base_material_cost,
"profit": profit_cost,
"labour_hours": labour_hours,
"labour_cost": labour_cost
"labour_cost": labour_cost,
"labour_days": labour_days
}
def internal_wall_insulation(self, wall_area, material, non_insulation_materials):
@ -224,8 +235,7 @@ class Costs:
subtotal_before_profit = labour_costs + materials_costs + demolition_plant_costs
# We use high risk contingency for iwi
contingency_cost = subtotal_before_profit * self.HIGH_RISK_CONTINGENCY
contingency_cost = subtotal_before_profit * self.IWI_CONTINGENCY
preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
@ -569,3 +579,51 @@ class Costs:
"labour_days": labour_days,
"labour_cost": labour_costs
}
def low_energy_lighting(self, number_of_lights, number_current_lel_lights, material):
"""
Calculates the total cost for low energy lighting based on material and labor costs,
including contingency, preliminaries, profit, and VAT.
:param number_of_lights: Int, number of light
:param number_current_lel_lights: Int, number of low energy lights currently installed in the home
:material: Dict, material data containing costs of fittings
"""
# If there are no lights fitted in the property, we increase the contingency in case there are potential wiring
# blockers
if number_current_lel_lights == 0:
contingency = self.HIGH_RISK_CONTINGENCY
else:
contingency = self.CONTINGENCY
material_cost = material["material_cost"] * number_of_lights
labour_cost = material["labour_cost"] * number_of_lights * self.labour_adjustment_factor
subtotal_before_profit = material_cost + labour_cost
contingency_cost = subtotal_before_profit * contingency
preliminaries_cost = subtotal_before_profit * self.PRELIMINARIES
profit_cost = subtotal_before_profit * self.PROFIT_MARGIN
subtotal_before_vat = subtotal_before_profit + contingency_cost + preliminaries_cost + profit_cost
vat_cost = subtotal_before_vat * self.VAT_RATE
total_cost = subtotal_before_vat + vat_cost
labour_hours = material["labour_hours_per_unit"] * number_of_lights
# Assume a single electrician installing
labour_days = (labour_hours / 8)
return {
"total": total_cost,
"subtotal": subtotal_before_vat,
"vat": vat_cost,
"contingency": contingency_cost,
"preliminaries": preliminaries_cost,
"material": material_cost,
"profit": profit_cost,
"labour_hours": labour_hours,
"labour_days": labour_days,
"labour_cost": labour_cost
}

View file

@ -45,6 +45,7 @@ class FireplaceRecommendations(Definitions):
"sap_points": None,
"total": estimated_cost,
# Take a very basic estimate of 6 hours, multipled by the number of open fireplaces to seal
"labour_hours": 6 * number_open_fireplaces
"labour_hours": 6 * number_open_fireplaces,
"labour_days": 6 * number_open_fireplaces / 8, # Assume 8 hour day
}
]

View file

@ -51,8 +51,9 @@ class FloorRecommendations(Definitions):
]
]
# For solid floor, we don't use materials that are too thick
self.solid_floor_insulation_materials = [
part for part in materials if part["type"] == "solid_floor_insulation"
part for part in materials if part["type"] == "solid_floor_insulation" if float(part["depth"]) <= 75
]
self.solid_floor_non_insulation_materials = [
@ -142,7 +143,20 @@ class FloorRecommendations(Definitions):
@staticmethod
def _make_floor_description(material):
return f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation"
if material["type"] == "suspended_floor_insulation":
return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation in "
f"suspended floor")
if material["type"] == "solid_floor_insulation":
return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation on "
f"solid floor")
if material["type"] == "exposed_floor_insulation":
return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation in "
f"exposed floor")
raise ValueError("Invalid material type - implement me!")
def recommend_floor_insulation(self, u_value, insulation_materials, non_insulation_materials):
"""
@ -194,7 +208,7 @@ class FloorRecommendations(Definitions):
cost_result=cost_result
),
],
"type": "floor_insulation",
"type": material["type"],
"description": self._make_floor_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,

View file

@ -0,0 +1,73 @@
from backend.Property import Property
from typing import List
from recommendations.Costs import Costs
class LightingRecommendations:
def __init__(self, property_instance: Property, materials: List):
"""
:param property_instance: Instance of the Property class, for the home associated to property_id
:param materials: List of materials to be used in the recommendations
"""
self.property = property_instance
self.costs = Costs(self.property)
material = [
material for material in materials if material["type"] == "low_energy_lighting_installation"
]
if len(material) != 1:
raise ValueError("Incorrect number of low energy lighting materials specified")
self.material = material[0]
self.recommendation = []
def recommend(self):
"""
This method will check if there are any lighting fittings that aren't low energy.
If there are, the will recommend fitting the rest of the outlets with low energy lighting fittings
:return:
"""
if self.property.lighting["low_energy_proportion"] == 100:
return
number_lighting_outlets = self.property.number_lighting_outlets
# Number non lel outlets
number_non_lel_outlets = number_lighting_outlets - (
self.property.lighting["low_energy_proportion"] * number_lighting_outlets
)
number_non_lel_outlets = round(number_non_lel_outlets)
if number_non_lel_outlets == 0:
return
# Get the cost of the fittings
cost_result = self.costs.low_energy_lighting(
number_of_lights=number_non_lel_outlets,
number_current_lel_lights=number_lighting_outlets - number_non_lel_outlets,
material=self.material
)
if number_non_lel_outlets == 1:
description = "Install low energy lighting in 1 remaining outlet"
else:
description = "Install low energy lighting in %s outlets" % str(number_non_lel_outlets)
self.recommendation = [
{
"parts": [],
"type": "low_energy_lighting",
"description": description,
"starting_u_value": None,
"new_u_value": None,
# For SAP points, we use the fact that lighting is usually worth 2 points and we scale this to
# the proportion of lights that will be set to low energy
"sap_points": round(2 * (number_non_lel_outlets / number_lighting_outlets), 2),
**cost_result
}
]

View file

@ -0,0 +1,163 @@
from backend.Property import Property
from typing import List
from recommendations.FloorRecommendations import FloorRecommendations
from recommendations.WallRecommendations import WallRecommendations
from recommendations.RoofRecommendations import RoofRecommendations
from recommendations.VentilationRecommendations import VentilationRecommendations
from recommendations.FireplaceRecommendations import FireplaceRecommendations
from recommendations.LightingRecommendations import LightingRecommendations
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
class Recommendations:
"""
High level recommendations class, which sits above the measure specific recommendation classes
"""
def __init__(
self,
property_instance: Property,
materials: List
):
"""
:param property_instance: Instance of the Property class, for the home associated to property_id
:param materials: List of materials to be used in the recommendations
"""
self.property_instance = property_instance
self.materials = materials
self.floor_recommender = FloorRecommendations(property_instance=property_instance, materials=materials)
self.wall_recomender = WallRecommendations(property_instance=property_instance, materials=materials)
self.roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
self.ventilation_recomender = VentilationRecommendations(
property_instance=property_instance, materials=materials
)
self.fireplace_recommender = FireplaceRecommendations(property_instance=property_instance)
self.lighting_recommender = LightingRecommendations(property_instance=property_instance, materials=materials)
def recommend(self):
"""
This method runs the recommendations for the individual measures and then appends them to a list for output
:return:
"""
property_recommendations = []
# Floor recommendations
self.floor_recommender.recommend()
if self.floor_recommender.recommendations:
property_recommendations.append(self.floor_recommender.recommendations)
# Wall recommendations
self.wall_recomender.recommend()
if self.wall_recomender.recommendations:
property_recommendations.append(self.wall_recomender.recommendations)
# Roof recommendations
self.roof_recommender.recommend()
if self.roof_recommender.recommendations:
property_recommendations.append(self.roof_recommender.recommendations)
# Ventilation recommendations
self.ventilation_recomender.recommend()
if self.ventilation_recomender.recommendation:
property_recommendations.append(self.ventilation_recomender.recommendation)
# Fireplace sealing recommendations
self.fireplace_recommender.recommend()
if self.fireplace_recommender.recommendation:
property_recommendations.append(self.fireplace_recommender.recommendation)
# Lighting recommendations
self.lighting_recommender.recommend()
if self.lighting_recommender.recommendation:
property_recommendations.append(self.lighting_recommender.recommendation)
# We insert temporary ids into the recommendations which is important for the optimiser later
property_recommendations = self.insert_temp_recommendation_id(property_recommendations)
return property_recommendations
@staticmethod
def insert_temp_recommendation_id(property_recommendations):
"""
Creates a temporary recommendation id which is needed for
filtering recommendations between default and no, after the optimiser has been
run
:param property_recommendations: nested list of recommendations, grouped by data_types
:return: Updated recommendations_to_upload, where where recommendation has a "recommendation_id"
integer inserted
"""
idx = 0
for recs in property_recommendations:
for rec in recs:
rec["recommendation_id"] = idx
idx += 1
return property_recommendations
@classmethod
def calculate_recommendation_impact(cls, property_instance, all_predictions, recommendations):
"""
Given predictions from the model apis, with method will update the recommendations with the predicted
impact of the recommendation on the property
:param property_instance: Instance of the Property class, for the home associated to property_id
:param all_predictions: dictionary of predictions from the model apis
:param recommendations: dictionary of recommendations for the property
:return:
"""
property_sap_predictions = all_predictions["sap_change_predictions"][
all_predictions["sap_change_predictions"]["property_id"] == str(property_instance.id)
]
property_heat_predictions = all_predictions["heat_demand_predictions"][
all_predictions["heat_demand_predictions"]["property_id"] == str(property_instance.id)
]
property_carbon_predictions = all_predictions["carbon_change_predictions"][
all_predictions["carbon_change_predictions"]["property_id"] == str(property_instance.id)
]
property_recommendations = recommendations[property_instance.id].copy()
for recommendations_by_type in property_recommendations:
for rec in recommendations_by_type:
new_heat_demand = property_heat_predictions[property_heat_predictions["recommendation_id"] == str(
rec["recommendation_id"]
)]["predictions"].values[0]
new_carbon = property_carbon_predictions[property_carbon_predictions["recommendation_id"] == str(
rec["recommendation_id"]
)]["predictions"].values[0]
# We don't use the model for low energy lighting at the moment
if rec["type"] != "low_energy_lighting":
new_sap = property_sap_predictions[property_sap_predictions["recommendation_id"] == str(
rec["recommendation_id"]
)]["predictions"].values[0]
rec["sap_points"] = new_sap - float(property_instance.data["current-energy-efficiency"])
if rec["type"] == "mechanical_ventilation":
# For the moment, we cap the number of SAP points that can be achieved by ventilation at 2
rec["sap_points"] = min(rec["sap_points"], VentilationRecommendations.SAP_LIMIT)
rec["co2_equivalent_savings"] = float(property_instance.data["co2-emissions-current"]) - new_carbon
# Energy consumption current is per meter squared, so we need to multiply by the floor area to get
# an absolute figure for the home
rec["heat_demand"] = (
(float(property_instance.data["energy-consumption-current"]) - new_heat_demand
) * property_instance.floor_area)
rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["heat_demand"])
if (rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or (
rec["heat_demand"] is None) or (rec["energy_cost_savings"] is None):
raise ValueError("sap points, co2 or heat demand is missing")
return property_recommendations

View file

@ -88,17 +88,20 @@ class RoofRecommendations:
raise NotImplementedError("Implement me")
@staticmethod
def make_loft_insulation_description(material):
return f"Install {int(material['depth'])}{material['depth_unit']} of {material['description']} in your loft"
def make_roof_insulation_description(material):
if material["type"] == "loft_insulation":
return f"Install {int(material['depth'])}{material['depth_unit']} of {material['description']} in your loft"
@staticmethod
def make_room_roof_insulation_description(material, depth):
return f"Insulate your room roof with {depth}{material['depth_unit']} of {material['description']}"
if material["type"] == "flat_roof_insulation":
return (
f"Insulate the home's flat roof with {int(material['depth'])}{material['depth_unit']} of "
f"{material['description']}"
)
if material["type"] == "room_roof_insulation":
return (f"Insulate your room roof with {int(material['depth'])}{material['depth_unit']} of "
f"{material['description']}")
@staticmethod
def make_flat_roof_insulation_description(material):
return (f"Insulate the home's flat roof "
f"with {int(material['depth'])}{material['depth_unit']} of {material['description']}")
raise ValueError("Invalid material type")
def recommend_roof_insulation(
self, u_value, insulation_thickness, roof
@ -182,9 +185,7 @@ class RoofRecommendations:
floor_area=self.property.insulation_floor_area,
material=material
)
description = self.make_loft_insulation_description(material)
elif material["type"] == "flat_roof_insulation":
description = self.make_flat_roof_insulation_description(material)
raise ValueError("COMPLETE ME")
else:
raise ValueError("Invalid material type")
@ -199,8 +200,8 @@ class RoofRecommendations:
cost_result=cost_result
)
],
"type": "roof_insulation",
"description": description,
"type": material["type"],
"description": self.make_roof_insulation_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
@ -297,7 +298,7 @@ class RoofRecommendations:
selected_total_cost=estimated_cost
)
],
"type": "roof_insulation",
"type": "room_roof_insulation",
"description": self.make_room_roof_insulation_description(material, depth),
"starting_u_value": u_value,
"new_u_value": new_u_value,

View file

@ -15,6 +15,9 @@ class VentilationRecommendations(Definitions):
'mechanical, supply and extract'
]
# We introduce a SAP limit, to prevent over-predicting the SAP impact of mechanical ventilation
SAP_LIMIT = 2
def __init__(
self,
property_instance: Property,
@ -24,7 +27,7 @@ class VentilationRecommendations(Definitions):
self.has_ventilaion = None
self.recommendation = None
self.materials = materials
self.materials = [part for part in materials if part["type"] == "mechanical_ventilation"]
def identify_ventilation(self):
self.has_ventilaion = self.property.data["mechanical-ventilation"] in self.VENTILATION_DESCRIPTIONS
@ -67,6 +70,7 @@ class VentilationRecommendations(Definitions):
"sap_points": None,
"total": estimated_cost,
# We use a very simple and rough estimate of 4 hours per unit
"labour_hours": 4 * n_units
"labour_hours": 4 * n_units,
"labour_days": 4 * n_units / 8.0 # Assume 8 hour day
}
]

View file

@ -218,8 +218,8 @@ class WallRecommendations(Definitions):
cost_result=cost_result
)
],
"type": "wall_insulation",
"description": f"Fill cavity with {material['description']}",
"type": "cavity_wall_insulation",
"description": self._make_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
@ -282,8 +282,8 @@ class WallRecommendations(Definitions):
cost_result=cost_result
)
],
"type": "wall_insulation",
"description": "Install " + self._make_description(material),
"type": material["type"],
"description": self._make_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
@ -303,7 +303,7 @@ class WallRecommendations(Definitions):
# Recommend external and internal wall insulation separately
# Since external and internal wall insulation are sufficiently different,
# we separate the logic for for recommending them, therefore we don't
# consider diminishing returns between the two
# consider diminishing returns between the two as they are considered to be separate measures
ewi_recommendations = []
if self.ewi_valid:
@ -321,24 +321,20 @@ class WallRecommendations(Definitions):
self.recommendations += ewi_recommendations + iwi_recommendations
self.prune_diminishing_recommendations()
@staticmethod
def _make_description(material):
return f"{int(material['depth'])}{material['depth_unit']} {material['description']}"
if material["type"] == "internal_wall_insulation":
return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} on internal "
f"walls")
def prune_diminishing_recommendations(self):
# For any recommendations, if we have at least 1 reommendation that does not exhibit diminishing returns
# we trim all others that are beyond the diminishing returns threshold
if material["type"] == "external_wall_insulation":
return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} on external "
f"walls")
# We first check if we have any recommendations that are not diminishing returns
not_diminishing_return = [
rec for rec in self.recommendations if rec["new_u_value"] >= self.DIMINISHING_RETURNS_U_VALUE
]
if not_diminishing_return:
self.recommendations = [
rec for rec in self.recommendations if rec["new_u_value"] >= self.DIMINISHING_RETURNS_U_VALUE
]
if material["type"] == "cavity_wall_insulation":
return f"Fill cavity with {material['description']}"
raise ValueError("Invalid material type")
@staticmethod
def rvalue_per_mm(total_r_value: float, thickness_mm: float) -> float:

View file

@ -0,0 +1,179 @@
# This map was found here:
# https://gist.github.com/radiac/d91d2ed1b971c03d49e9b7bd85e23f1c#file-uk-counties-to-regions-csv
county_to_region_map = {
'Guernsey': 'Crown Dependencies', 'IOM': 'Crown Dependencies', 'Jersey': 'Crown Dependencies',
'North East Derbyshire': 'East Midlands', 'Amber Valley': 'East Midlands', 'Ashfield': 'East Midlands',
'Bassetlaw': 'East Midlands', 'Blaby': 'East Midlands', 'Bolsover': 'East Midlands', 'Boston': 'East Midlands',
'Broxtowe': 'East Midlands', 'Charnwood': 'East Midlands', 'Chesterfield': 'East Midlands',
'Corby': 'East Midlands', 'Daventry': 'East Midlands', 'Derby': 'East Midlands', 'Derbyshire': 'East Midlands',
'Derbyshire Dales': 'East Midlands', 'East Lindsey': 'East Midlands', 'East Northamptonshire': 'East Midlands',
'Erewash': 'East Midlands', 'Gedling': 'East Midlands', 'Harborough': 'East Midlands', 'High Peak': 'East Midlands',
'Hinckley and Bosworth': 'East Midlands', 'Kettering': 'East Midlands', 'Leicester': 'East Midlands',
'Leicestershire': 'East Midlands', 'Lincoln': 'East Midlands', 'Lincolnshire': 'Yorkshire and the Humber',
'Mansfield': 'East Midlands', 'Melton': 'East Midlands', 'Newark and Sherwood': 'East Midlands',
'North Kesteven': 'East Midlands', 'North West Leicestershire': 'East Midlands', 'Northampton': 'East Midlands',
'Northamptonshire': 'East Midlands', 'Nottingham': 'East Midlands', 'Nottinghamshire': 'East Midlands',
'Oadby and Wigston': 'East Midlands', 'Rushcliffe': 'East Midlands', 'Rutland': 'East Midlands',
'South Derbyshire': 'East Midlands', 'South Holland': 'East Midlands', 'South Kesteven': 'East Midlands',
'South Northamptonshire': 'East Midlands', 'Wellingborough': 'East Midlands', 'West Lindsey': 'East Midlands',
'Babergh': 'East of England', 'Basildon': 'East of England', 'Bedford': 'East of England',
'Bedford Borough': 'East of England', 'Bedfordshire': 'East of England', 'Braintree': 'East of England',
'Breckland': 'East of England', 'Brentwood': 'East of England', 'Broadland': 'East of England',
'Broxbourne': 'East of England', 'Cambridge': 'East of England', 'Cambridgeshire': 'East of England',
'Castle Point': 'East of England', 'Central Bedfordshire': 'East of England', 'Chelmsford': 'East of England',
'Colchester': 'East of England', 'Dacorum': 'East of England', 'East Cambridgeshire': 'East of England',
'East Hertfordshire': 'East of England', 'Epping Forest': 'East of England', 'Essex': 'East of England',
'Fenland': 'East of England', 'Forest Heath': 'East of England', 'Great Yarmouth': 'East of England',
'Harlow': 'East of England', 'Hertfordshire': 'East of England', 'Hertsmere': 'East of England',
'Huntingdonshire': 'East of England', 'Ipswich': 'East of England',
"King's Lynn and West Norfolk": 'East of England', 'Luton': 'East of England', 'Maldon': 'East of England',
'Mid Suffolk': 'East of England', 'Norfolk': 'East of England', 'North Hertfordshire': 'East of England',
'North Norfolk': 'East of England', 'Norwich': 'East of England', 'Peterborough': 'East of England',
'Rochford': 'East of England', 'South Cambridgeshire': 'East of England', 'South Norfolk': 'East of England',
'Southend-on-Sea': 'East of England', 'St Albans': 'East of England', 'St. Edmundsbury': 'East of England',
'Stevenage': 'East of England', 'Suffolk': 'East of England', 'Suffolk Coastal': 'East of England',
'Tendring': 'East of England', 'Three Rivers': 'East of England', 'Thurrock': 'East of England',
'Uttlesford': 'East of England', 'Watford': 'East of England', 'Waveney': 'East of England',
'Welwyn Hatfield': 'East of England',
'County Durham': 'North East England',
'Darlington': 'North East England', 'Durham': 'North East England', 'Gateshead': 'North East England',
'Hartlepool': 'North East England', 'Middlesbrough': 'North East England',
'Newcastle Upon Tyne': 'North East England', 'North Tyneside': 'North East England',
'North Yorkshire': 'Yorkshire and the Humber', 'Northumberland': 'North East England',
'Redcar and Cleveland': 'North East England', 'South Tyneside': 'North East England',
'Stockton-on-Tees': 'North East England', 'Sunderland': 'North East England', 'Tyne and Wear': 'North East England',
'Allerdale': 'North West England', 'Barrow-in-Furness': 'North West England',
'Blackburn with Darwen': 'North West England', 'Blackpool': 'North West England', 'Bolton': 'North West England',
'Burnley': 'North West England', 'Bury': 'North West England', 'Carlisle': 'North West England',
'Cheshire': 'North West England', 'Cheshire East': 'North West England',
'Cheshire West and Chester': 'North West England', 'Chorley': 'North West England',
'Copeland': 'North West England', 'Cumbria': 'North West England', 'Eden': 'North West England',
'Fylde': 'North West England', 'Greater Manchester': 'North West England', 'Halton': 'North West England',
'Hyndburn': 'North West England', 'Knowsley': 'North West England', 'Lancashire': 'North West England',
'Lancaster': 'North West England', 'Liverpool': 'North West England', 'Manchester': 'North West England',
'Merseyside': 'North West England', 'Oldham': 'North West England', 'Pendle': 'North West England',
'Preston': 'North West England', 'Ribble Valley': 'North West England', 'Rochdale': 'North West England',
'Rossendale': 'North West England', 'Salford': 'North West England', 'Sefton': 'North West England',
'South Lakeland': 'North West England', 'South Ribble': 'North West England', 'St Helens': 'North West England',
'St. Helens': 'North West England', 'Stockport': 'North West England', 'Tameside': 'North West England',
'Trafford': 'North West England', 'Warrington': 'North West England', 'West Lancashire': 'North West England',
'Wigan': 'North West England', 'Wirral': 'North West England', 'Wyre': 'North West England',
'Antrim': 'Northern Ireland', 'Ards': 'Northern Ireland', 'Armagh': 'Northern Ireland',
'Ballymena': 'Northern Ireland', 'Ballymoney': 'Northern Ireland', 'Banbridge': 'Northern Ireland',
'Belfast': 'Northern Ireland', 'Carrickfergus': 'Northern Ireland', 'Castlereagh': 'Northern Ireland',
'Coleraine': 'Northern Ireland', 'Cookstown': 'Northern Ireland', 'County Armagh': 'Northern Ireland',
'County Fermanagh': 'Northern Ireland', 'Craigavon': 'Northern Ireland', 'Derry': 'Northern Ireland',
'Down': 'Northern Ireland', 'Dungannon': 'Northern Ireland', 'Fermanagh': 'Northern Ireland',
'Larne': 'Northern Ireland', 'Limavady': 'Northern Ireland', 'Lisburn': 'Northern Ireland',
'Magherafelt': 'Northern Ireland', 'Moyle': 'Northern Ireland', 'Newry and Mourne': 'Northern Ireland',
'Newtownabbey': 'Northern Ireland', 'North Down': 'Northern Ireland', 'Omagh': 'Northern Ireland',
'South Tyrone': 'Northern Ireland', 'Strabane': 'Northern Ireland', 'Aberdeen City': 'Scotland',
'Aberdeenshire': 'Scotland', 'Angus': 'Scotland', 'Argyll and Bute': 'Scotland', 'Argyllshire': 'Scotland',
'Ayrshire': 'Scotland', 'Banffshire': 'Scotland', 'Berwickshire': 'Scotland', 'Bute': 'Scotland',
'Caithness': 'Scotland', 'City of Edinburgh': 'Scotland', 'Clackmannanshire': 'Scotland',
'Dumfries and Galloway': 'Scotland', 'Dumfriesshire': 'Scotland', 'Dunbartonshire': 'Scotland',
'Dundee City': 'Scotland', 'East Ayrshire': 'Scotland', 'East Dunbartonshire': 'Scotland',
'East Lothian': 'Scotland', 'East Renfrewshire': 'Scotland', 'Edinburgh City': 'Scotland',
'Eilean Siar': 'Scotland', 'Falkirk': 'Scotland', 'Fife': 'Scotland', 'Glasgow City': 'Scotland',
'Highland': 'Scotland', 'Inverclyde': 'Scotland', 'Inverness-shire': 'Scotland', 'Kincardineshire': 'Scotland',
'Kinross-shire': 'Scotland', 'Kirkcudbrightshire': 'Scotland', 'Lanarkshire': 'Scotland', 'Midlothian': 'Scotland',
'Moray': 'Scotland', 'Nairnshire': 'Scotland', 'North Ayrshire': 'Scotland', 'North Lanarkshire': 'Scotland',
'Orkney': 'Scotland', 'Orkney Islands': 'Scotland', 'Peeblesshire': 'Scotland', 'Perth and Kinross': 'Scotland',
'Perthshire': 'Scotland', 'Renfrewshire': 'Scotland', 'Ross and Cromarty': 'Scotland', 'Roxburghshire': 'Scotland',
'Selkirkshire': 'Scotland', 'Shetland Islands': 'Scotland', 'South Ayrshire': 'Scotland',
'South Lanarkshire': 'Scotland', 'Stirling': 'Scotland', 'Stirlingshire': 'Scotland', 'Sutherland': 'Scotland',
'The Scottish Borders': 'Scotland', 'West Ayrshire': 'Scotland', 'West Dunbartonshire': 'Scotland',
'West Lothian': 'Scotland', 'Wigtownshire': 'Scotland', 'Zetland': 'Scotland', 'Adur': 'South East England',
'Arun': 'South East England', 'Ashford': 'South East England', 'Aylesbury Vale': 'South East England',
'Basingstoke and Deane': 'South East England', 'Berkshire': 'South East England',
'Bracknell Forest': 'South East England', 'Brighton and Hove': 'South East England',
'Buckinghamshire': 'South East England', 'Canterbury': 'South East England', 'Cherwell': 'South East England',
'Chichester': 'South East England', 'Chiltern': 'South East England', 'Crawley': 'South East England',
'Dartford': 'South East England', 'Dover': 'South East England', 'East Hampshire': 'South East England',
'East Sussex': 'South East England', 'Eastbourne': 'South East England', 'Eastleigh': 'South East England',
'Elmbridge': 'South East England', 'Epsom and Ewell': 'South East England', 'Fareham': 'South East England',
'Gosport': 'South East England', 'Gravesham': 'South East England', 'Guildford': 'South East England',
'Hampshire': 'South East England', 'Hart': 'South East England', 'Hastings': 'South East England',
'Havant': 'South East England', 'Horsham': 'South East England', 'Isle of Wight': 'South East England',
'Kent': 'South East England', 'Lewes': 'South East England', 'Maidstone': 'South East England',
'Medway': 'South East England', 'Mid Sussex': 'South East England', 'Milton Keynes': 'South East England',
'Mole Valley': 'South East England', 'New Forest': 'South East England', 'Oxford': 'South East England',
'Oxfordshire': 'South East England', 'Portsmouth': 'South East England', 'Reading': 'South East England',
'Reigate and Banstead': 'South East England', 'Rother': 'South East England', 'Runnymede': 'South East England',
'Rushmoor': 'South East England', 'Sevenoaks': 'South East England', 'Shepway': 'South East England',
'Slough': 'South East England', 'South Bucks': 'South East England', 'South Oxfordshire': 'South East England',
'Southampton': 'South East England', 'Spelthorne': 'South East England', 'Surrey': 'South East England',
'Surrey Heath': 'South East England', 'Swale': 'South East England', 'Tandridge': 'South East England',
'Test Valley': 'South East England', 'Thanet': 'South East England', 'Tonbridge and Malling': 'South East England',
'Tunbridge Wells': 'South East England', 'Vale of White Horse': 'South East England',
'Waverley': 'South East England', 'Wealden': 'South East England', 'West Berkshire': 'South East England',
'West Oxfordshire': 'South East England', 'West Sussex': 'South East England', 'Winchester': 'South East England',
'Windsor and Maidenhead': 'South East England', 'Woking': 'South East England', 'Wokingham': 'South East England',
'Worthing': 'South East England', 'Wycombe': 'South East England',
'Bath and North East Somerset': 'South West England', 'Bournemouth': 'South West England',
'Bristol': 'South West England', 'Cheltenham': 'South West England', 'Christchurch': 'South West England',
'City of Bristol': 'South West England', 'Cornwall': 'South West England', 'Cotswold': 'South West England',
'Devon': 'South West England', 'Dorset': 'South West England', 'East Devon': 'South West England',
'East Dorset': 'South West England', 'Exeter': 'South West England', 'Forest of Dean': 'South West England',
'Gloucester': 'South West England', 'Gloucestershire': 'South West England',
'Isles of Scilly': 'South West England', 'Mendip': 'South West England', 'Mid Devon': 'South West England',
'North Devon': 'South West England', 'North Dorset': 'South West England', 'North Somerset': 'South West England',
'Plymouth': 'South West England', 'Poole': 'South West England', 'Purbeck': 'South West England',
'Sedgemoor': 'South West England', 'Somerset': 'South West England', 'South Gloucestershire': 'South West England',
'South Hams': 'South West England', 'South Somerset': 'South West England', 'Stroud': 'South West England',
'Swindon': 'South West England', 'Taunton Deane': 'South West England', 'Teignbridge': 'South West England',
'Tewkesbury': 'South West England', 'Torbay': 'South West England', 'Torridge': 'South West England',
'West Devon': 'South West England', 'West Dorset': 'South West England', 'West Somerset': 'South West England',
'Weymouth and Portland': 'South West England', 'Wiltshire': 'South West England', 'Aberdare': 'Wales',
'Bargoed': 'Wales', 'Barry': 'Wales', 'Blaenau Gwent': 'Wales', 'Bridgend': 'Wales', 'Caerphilly': 'Wales',
'Cardiff': 'Wales', 'Carmarthenshire': 'Wales', 'Ceredigion': 'Wales', 'Conwy': 'Wales', 'Cowbridge': 'Wales',
'Denbighshire': 'Wales', 'Dinas Powys': 'Wales', 'Ferndale': 'Wales', 'Flintshire': 'Wales', 'Gwynedd': 'Wales',
'Hengoed': 'Wales', 'Isle of Anglesey': 'Wales', 'Llantwit Major': 'Wales', 'Maesteg': 'Wales',
'Merthyr Tydfil': 'Wales', 'Monmouthshire': 'Wales', 'Mountain Ash': 'Wales', 'Neath Port Talbot': 'Wales',
'Newport': 'Wales', 'Pembrokeshire': 'Wales', 'Penarth': 'Wales', 'Pentre': 'Wales', 'Pontyclun': 'Wales',
'Pontypridd': 'Wales', 'Porth': 'Wales', 'Porthcawl': 'Wales', 'Powys': 'Wales', 'Rhondda Cynon Taff': 'Wales',
'Rhoose': 'Wales', 'Sully': 'Wales', 'Swansea': 'Wales', 'The Vale of Glamorgan': 'Wales', 'Tonypandy': 'Wales',
'Torfaen': 'Wales', 'Treharris': 'Wales', 'Treorchy': 'Wales', 'Wrexham': 'Wales', 'Birmingham': 'West Midlands',
'Bromsgrove': 'West Midlands', 'Cannock Chase': 'West Midlands', 'Coventry': 'West Midlands',
'Dudley': 'West Midlands', 'East Staffordshire': 'West Midlands', 'Herefordshire': 'West Midlands',
'Lichfield': 'West Midlands', 'Malvern Hills': 'West Midlands', 'Newcastle-under-Lyme': 'West Midlands',
'North Warwickshire': 'West Midlands', 'Nuneaton and Bedworth': 'West Midlands', 'Redditch': 'West Midlands',
'Rugby': 'West Midlands', 'Sandwell': 'West Midlands', 'Shropshire': 'West Midlands', 'Solihull': 'West Midlands',
'South Staffordshire': 'West Midlands', 'Stafford': 'West Midlands', 'Staffordshire': 'West Midlands',
'Staffordshire Moorlands': 'West Midlands', 'Stoke-on-Trent': 'West Midlands', 'Stratford-on-Avon': 'West Midlands',
'Tamworth': 'West Midlands', 'Telford and Wrekin': 'West Midlands', 'Walsall': 'West Midlands',
'Warwick': 'West Midlands', 'Warwickshire': 'West Midlands', 'West Midlands': 'West Midlands',
'Wolverhampton': 'West Midlands', 'Worcester': 'West Midlands', 'Worcestershire': 'West Midlands',
'Wychavon': 'West Midlands', 'Wyre Forest': 'West Midlands', 'Barnsley': 'Yorkshire and the Humber',
'Bradford': 'Yorkshire and the Humber', 'Calderdale': 'Yorkshire and the Humber',
'City of Kingston-upon-Hull': 'Yorkshire and the Humber', 'Craven': 'Yorkshire and the Humber',
'Doncaster': 'Yorkshire and the Humber', 'East Riding of Yorkshire': 'Yorkshire and the Humber',
'Hambleton': 'Yorkshire and the Humber', 'Harrogate': 'Yorkshire and the Humber',
'Kingston upon Hull': 'Yorkshire and the Humber', 'Kirklees': 'Yorkshire and the Humber',
'Leeds': 'Yorkshire and the Humber', 'North East Lincolnshire': 'Yorkshire and the Humber',
'North Lincolnshire': 'Yorkshire and the Humber', 'Richmondshire': 'Yorkshire and the Humber',
'Rotherham': 'Yorkshire and the Humber', 'Ryedale': 'Yorkshire and the Humber',
'Scarborough': 'Yorkshire and the Humber', 'Selby': 'Yorkshire and the Humber',
'Sheffield': 'Yorkshire and the Humber', 'South Yorkshire': 'Yorkshire and the Humber',
'Wakefield': 'Yorkshire and the Humber', 'West Yorkshire': 'Yorkshire and the Humber',
'York': 'Yorkshire and the Humber',
# Additional mappings requried, based on what we find in the EPC database
'Greater London Authority': 'Inner London',
# We have a bunch of inner London local authority mappings, which can be used if the county is not found
'Barking and Dagenham': 'Inner London', 'Barnet': 'Inner London', 'Bexley': 'Inner London',
'Brent': 'Inner London', 'Bromley': 'Inner London', 'Camden': 'Inner London', 'City of London': 'Inner London',
'City of Westminster': 'Inner London', 'Croydon': 'Inner London', 'Ealing': 'Inner London',
'Enfield': 'Inner London',
'Greater London': 'Inner London', 'Greenwich': 'Inner London', 'Hackney': 'Inner London',
'Hammersmith and Fulham': 'Inner London',
'Haringey': 'Inner London', 'Harrow': 'Inner London', 'Havering': 'Inner London', 'Hillingdon': 'Inner London',
'Hounslow': 'Inner London',
'Islington': 'Inner London', 'Kensington and Chelsea': 'Inner London', 'Kingston upon Thames': 'Inner London',
'Lambeth': 'Inner London',
'Lewisham': 'Inner London', 'Merton': 'Inner London', 'Newham': 'Inner London', 'Redbridge': 'Inner London',
'Richmond': 'Inner London',
'Southwark': 'Inner London', 'Sutton': 'Inner London', 'Tower Hamlets': 'Inner London',
'Waltham Forest': 'Inner London',
'Wandsworth': 'Inner London', 'Westminster': 'Inner London',
}

View file

@ -9,6 +9,9 @@ class CostOptimiser:
This class is used to minimise cost, given a constrained minimum gain
"""
# We add an optional buffer to the minimum gain to allow for slack in the optimisation
BUFFER = 0.2
def __init__(self, components, min_gain):
self.components = components
self.min_gain = min_gain
@ -20,6 +23,20 @@ class CostOptimiser:
self.solution_cost = None
self.solution_gain = None
@classmethod
def calculate_sap_gain_with_slack(cls, min_gain: int | float):
"""
Adds a small amount of buffer to the minimum gain, to account for possible error in SAP predictions
:param min_gain: Numerical value for the minimum gain
:return:
"""
if min_gain <= 5:
return min_gain + 0.5
elif min_gain <= 20:
return min_gain + 1.5
else:
return min_gain + 2
def setup(self):
# Initialize Model
self.m = Model("knapsack")

View file

@ -19,7 +19,7 @@ class TestCosts:
"prime_cost": 5.17,
"material_cost": 5.62,
"labour_cost": 1.125,
"labour_hours": 0.065
"labour_hours_per_unit": 0.065,
}
cwi_results = costs.cavity_wall_insulation(
@ -27,10 +27,12 @@ class TestCosts:
material=cwi_material,
)
assert cwi_results == {'total': 1027.0280465530302, 'subtotal': 855.8567054608585, 'vat': 171.1713410921717,
'contingency': 63.396792997100626, 'preliminaries': 63.396792997100626,
'material': 539.0166061175574, 'profit': 95.09518949565093,
'labour_hours': 6.234177828761786, 'labour_cost': 94.95132385344874}
assert cwi_results == {
'total': 1065.0661223512907, 'subtotal': 887.5551019594088, 'vat': 177.51102039188177,
'contingency': 63.396792997100626, 'preliminaries': 63.396792997100626, 'material': 539.0166061175574,
'profit': 126.79358599420125, 'labour_hours': 6.234177828761786, 'labour_cost': 94.95132385344874,
'labour_days': 0.38963611429761164
}
def test_loft_insulation(self):
mock_property = Mock()
@ -46,7 +48,7 @@ class TestCosts:
"prime_cost": None,
"material_cost": 5.91938,
"labour_cost": 1.96,
"labour_hours": 0.11
"labour_hours_per_unit": 0.11
}
loft_results = costs.loft_insulation(
@ -54,10 +56,11 @@ class TestCosts:
material=loft_material,
)
assert loft_results == {'total': 414.8496486, 'subtotal': 345.70804050000004, 'vat': 69.14160810000001,
'contingency': 25.608003000000004, 'preliminaries': 25.608003000000004,
'material': 198.29923000000002, 'profit': 38.4120045, 'labour_hours': 3.685,
'labour_cost': 57.7808}
assert loft_results == {
'total': 430.21445040000003, 'subtotal': 358.512042, 'vat': 71.70240840000001,
'contingency': 25.608003000000004, 'preliminaries': 25.608003000000004, 'material': 198.29923000000002,
'profit': 51.21600600000001, 'labour_hours': 3.685, 'labour_cost': 57.7808, 'labour_days': 0.460625
}
def test_internal_wall_insulation(self):
mock_property = Mock()
@ -171,11 +174,14 @@ class TestCosts:
non_insulation_materials=iwi_non_insulation_materials
)
assert iwi_results == {'total': 6421.5484411659245, 'subtotal': 5351.29036763827, 'vat': 1070.258073527654,
'contingency': 573.3525393898148, 'preliminaries': 382.2350262598765,
'material': 1747.488000615996, 'profit': 573.3525393898148,
'labour_hours': 88.23759388401297, 'labour_days': 2.757424808875405,
'labour_cost': 1927.1602026551818}
assert iwi_results == {
'total': 6650.889456921851, 'subtotal': 5542.407880768209, 'vat': 1108.4815761536418,
'contingency': 573.3525393898148, 'preliminaries': 382.2350262598765,
'material': 1747.488000615996,
'profit': 764.470052519753, 'labour_hours': 88.23759388401297,
'labour_days': 2.757424808875405,
'labour_cost': 1927.1602026551818
}
def test_suspended_floor_insulation(self):
mock_property = Mock()
@ -185,16 +191,18 @@ class TestCosts:
costs = Costs(mock_property)
sus_floor_material = {'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll',
'depth': 140.0,
'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0,
'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1,
'plant_cost': 0,
'total_cost': 13.46, 'link': 'SPONs',
'Notes': 'Spons did not contain labour costs so we use values for similar insulations. '
'We use the '
'same values as in Crown loft roll 44, since it is also an insulation roll'}
sus_floor_material = {
'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll',
'depth': 140.0,
'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0,
'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1,
'plant_cost': 0,
'total_cost': 13.46, 'link': 'SPONs',
'Notes': 'Spons did not contain labour costs so we use values for similar insulations. '
'We use the '
'same values as in Crown loft roll 44, since it is also an insulation roll'
}
sus_floor_non_insulation_materials = [
{'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0,
@ -231,9 +239,8 @@ class TestCosts:
)
assert sus_floor_results == {
'total': 3003.366924, 'subtotal': 2502.80577, 'vat': 500.561154,
'contingency': 185.39302, 'preliminaries': 185.39302, 'material': 483.405,
'profit': 278.08952999999997, 'labour_hours': 54.940000000000005,
'total': 3114.6027360000003, 'subtotal': 2595.50228, 'vat': 519.100456, 'contingency': 185.39302,
'preliminaries': 185.39302, 'material': 483.405, 'profit': 370.78604, 'labour_hours': 54.940000000000005,
'labour_days': 2.289166666666667, 'labour_cost': 1370.5252
}
@ -263,28 +270,29 @@ class TestCosts:
'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14,
'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, {'type': 'solid_floor_preparation',
'description': 'Clean out crack to '
'form a 20mm×20mm '
'groove and fill with '
'cement: mortar mixed '
'with bonding agent',
'depth': 0, 'depth_unit': 0,
'cost_unit': 0,
'thermal_conductivity': 0,
'thermal_conductivity_unit': 0,
'prime_material_cost': 0,
'material_cost': 6.91,
'labour_cost': 18.99,
'labour_hours_per_unit': 0.61,
'plant_cost': 0.16,
'total_cost': 26.06, 'link': 0,
'Notes': 'This step is the '
'assessment and repair of '
'any damage to the concrete '
'floor such as filling '
'cracks or levelling uneven '
'areas'},
'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, {
'type': 'solid_floor_preparation',
'description': 'Clean out crack to '
'form a 20mm×20mm '
'groove and fill with '
'cement: mortar mixed '
'with bonding agent',
'depth': 0, 'depth_unit': 0,
'cost_unit': 0,
'thermal_conductivity': 0,
'thermal_conductivity_unit': 0,
'prime_material_cost': 0,
'material_cost': 6.91,
'labour_cost': 18.99,
'labour_hours_per_unit': 0.61,
'plant_cost': 0.16,
'total_cost': 26.06, 'link': 0,
'Notes': 'This step is the '
'assessment and repair of '
'any damage to the concrete '
'floor such as filling '
'cracks or levelling uneven '
'areas'},
{'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48,
@ -316,8 +324,8 @@ class TestCosts:
)
assert sol_floor_results == {
'total': 3962.021952, 'subtotal': 3301.68496, 'vat': 660.336992, 'contingency': 353.75196,
'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 353.75196, 'labour_hours': 57.285,
'total': 4245.023520000001, 'subtotal': 3537.5196, 'vat': 707.5039200000001, 'contingency': 471.66928,
'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 471.66928, 'labour_hours': 57.285,
'labour_days': 2.386875, 'labour_cost': 1346.6464
}
@ -331,11 +339,13 @@ class TestCosts:
costs = Costs(mock_property)
ewi_material = {'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 150.0, 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 23.53,
'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0,
'total_cost': 67.68, 'link': 'SPONs', 'Notes': 0}
ewi_material = {
'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 150.0, 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 23.53,
'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0,
'total_cost': 67.68, 'link': 'SPONs', 'Notes': 0
}
ewi_non_insulation_materials = [
{'type': 'ewi_wall_demolition',
'description': 'Solid & Dry Lined walls: Hack of wall finishes with chipping '
@ -403,9 +413,8 @@ class TestCosts:
)
assert ewi_results == {
'total': 13590.909723215433, 'subtotal': 11325.758102679527, 'vat': 2265.1516205359053,
'contingency': 808.9827216199662, 'preliminaries': 1213.4740824299492,
'material': 4020.565147410677, 'profit': 1213.4740824299492,
'labour_hours': 187.02533486285358, 'labour_days': 5.8445417144641745,
'total': 14561.688989159393, 'subtotal': 12134.740824299493, 'vat': 2426.948164859899,
'contingency': 808.9827216199662, 'preliminaries': 1617.9654432399325, 'material': 4020.565147410677,
'profit': 1617.9654432399325, 'labour_hours': 187.02533486285358, 'labour_days': 5.8445417144641745,
'labour_cost': 3921.5600094613983
}

View file

@ -0,0 +1,835 @@
import datetime
materials = [
{'id': 17, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': None,
'depth_unit': None, 'cost': 500, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': None,
'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None,
'created_at': datetime.datetime(2023, 10, 18, 16, 39, 9, 827188), 'is_active': True, 'prime_material_cost': None,
'material_cost': None, 'labour_cost': None, 'labour_hours_per_unit': None, 'plant_cost': None, 'total_cost': None,
'notes': None},
{'id': 1109, 'type': 'cavity_wall_insulation', 'description': 'Expanded Polystyrene Beads cavity wall insulation',
'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://www.styrene.co.uk/downloads/Datasheets/Stylite_Cavity_Loose_Fill_Insulation_Datasheet_v20211.pdf',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 18.875, 'labour_cost': 1.125, 'labour_hours_per_unit': 0.065, 'plant_cost': 0.0,
'total_cost': 20.0,
'notes': "It is hard to find materials online. To price this, we've used this article: "
"https://www.greenmatch.co.uk/blog/cavity-wall-insulation-cost It puts EPS beads at around £22 per "
"meter squared, blowing wool insulation at £18 per meter squared and Polyurethane Foam at £26 per meter "
"squared, when taking the most pessimistic prices. These rates have been used to adjust the price of "
"the mineral wool insulation to give us the other forms of insulation"},
{'id': 1110, 'type': 'cavity_wall_insulation', 'description': 'Injected Polyurthane Foam cavity wall insulation',
'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://www.foaminstall.co.uk/wp-content/uploads/2017/04/Lapolla-Cavity-Fill-BBA-certificate-sheet1.pdf',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 22.875, 'labour_cost': 1.125, 'labour_hours_per_unit': 0.065, 'plant_cost': 0.0,
'total_cost': 24.0, 'notes': None},
{'id': 1111, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 100.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.03,
'material_cost': 2.1, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.66,
'notes': None},
{'id': 1112, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 150.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 3.06,
'material_cost': 3.16, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.94,
'notes': None},
{'id': 1113, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 170.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://insulation4less.co.uk/products/knauf-170mm-combi-cut?variant=31671561257013&dfw_tracker=77750'
'-31671561257013&utm_source=google&utm_medium=shopping&utm_campaign=shoptimised&gad_source=1&gclid'
'=CjwKCAiAx_GqBhBQEiwAlDNAZi1LiTWKVn0W1vktOYAPPQU3hss5Tq2qNn6GNhodCQoRD_tvqCLdxhoCKnIQAvD_BwE',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 3.81938, 'labour_cost': 1.71304, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0,
'total_cost': 5.53242,
'notes': "We don't have a 170mm in SPONs so the material cost is based on the fact that the 170mm insulation is "
"87.4% of the cost of the 200mm insulation"},
{'id': 1114, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 200.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.25,
'material_cost': 4.37, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 6.33,
'notes': None},
{'id': 1115, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 270.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 5.91938, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0,
'total_cost': 7.87938, 'notes': 'This is the 100mm product + the 170mm product'},
{'id': 1116, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 300.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 6.47, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 8.43,
'notes': 'This is the 100mm product + the 200mm product'},
{'id': 1117, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 100.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.99,
'material_cost': 2.05, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.65,
'notes': None},
{'id': 1118, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 150.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.96,
'material_cost': 3.05, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.83,
'notes': None},
{'id': 1119, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 170.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-170mm-x-1160mm-x-7-03m-8-15m2/',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 3.8706238, 'labour_cost': 2.281361, 'labour_hours_per_unit': 0.12816635, 'plant_cost': 0.0,
'total_cost': 6.1519847,
'notes': "We don't have a 170mm in SPONs so the material cost is based on the fact that the 170mm insulation is "
"85.4% of the cost of the 200mm insulation"},
{'id': 1120, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 200.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.4,
'material_cost': 4.53, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 7.2,
'notes': None},
{'id': 1121, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 270.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 5.920624, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0,
'total_cost': 8.590624, 'notes': 'This is the 100mm product + the 170mm product'},
{'id': 1122, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 300.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 6.58, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.25,
'notes': 'This is the 100mm product + the 200mm product'},
{'id': 1123, 'type': 'loft_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 100.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 5.93,
'material_cost': 6.4, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.07,
'notes': 'This provides acoustic insulation as well'},
{'id': 1124, 'type': 'loft_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 300.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 17.79,
'material_cost': 19.2, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 21.87,
'notes': 'This provides acoustic insulation as well'},
{'id': 1125, 'type': 'loft_insulation', 'description': 'Thermafleece EcoRoll Insulation', 'depth': 300.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 24.78, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 27.45,
'notes': 'This material is based on installing 3 layers of the 100mm product'},
{'id': 1126, 'type': 'loft_insulation', 'description': 'Thermafleece EcoRoll Insulation', 'depth': 280.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 23.36, 'labour_cost': 3.12, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 26.48,
'notes': 'This material is based on installed 2 layers of the 140mm product'},
{'id': 1127, 'type': 'iwi_wall_demolition',
'description': 'Solid & Dry Lined walls: Hack of wall finishes with chipping hammer; plaster to walls.',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 10.27, 'labour_hours_per_unit': 0.33,
'plant_cost': 1.28, 'total_cost': 11.55, 'notes': None}, {'id': 1128, 'type': 'iwi_wall_demolition',
'description': 'Stud walls: Remove wall linings '
'including battening behind; '
'plasterboard and skim',
'depth': 0.0, 'depth_unit': None, 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
244907),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 6.23,
'labour_hours_per_unit': 0.2, 'plant_cost': 1.25,
'total_cost': 7.48, 'notes': None},
{'id': 1129, 'type': 'iwi_wall_demolition',
'description': 'Lathe and Plaster walls: Remove wall linings including battening behind; wood lath and plaster',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.85, 'labour_hours_per_unit': 0.22,
'plant_cost': 2.09, 'total_cost': 8.94, 'notes': None},
{'id': 1130, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
'depth': 60.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 41.69,
'material_cost': 53.33, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0,
'total_cost': 82.85, 'notes': None},
{'id': 1131, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 86.86,
'material_cost': 99.85, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0,
'total_cost': 129.37, 'notes': None},
{'id': 1132, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': 130.29, 'material_cost': 144.58, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25,
'plant_cost': 0.0, 'total_cost': 174.1, 'notes': None},
{'id': 1133, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 30.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16,
'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07,
'notes': None},
{'id': 1134, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46,
'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44,
'notes': None},
{'id': 1135, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12,
'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66,
'notes': None},
{'id': 1136, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
'depth': 37.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 26.86, 'labour_cost': 5.21, 'labour_hours_per_unit': 0.23, 'plant_cost': 0.0, 'total_cost': 32.07,
'notes': None},
{'id': 1137, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
'depth': 42.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 17.37, 'labour_cost': 5.21, 'labour_hours_per_unit': 0.23, 'plant_cost': 0.0, 'total_cost': 22.58,
'notes': None},
{'id': 1138, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
'depth': 52.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 21.74, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 27.53,
'notes': None},
{'id': 1139, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
'depth': 62.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 19.3, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 25.09,
'notes': None},
{'id': 1140, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
'depth': 72.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 23.15, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 28.94,
'notes': None},
{'id': 1141, 'type': 'iwi_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02,
'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None}, {'id': 1142, 'type': 'iwi_redecoration',
'description': 'Plaster; one coat Thistle board finish '
'or other equal; steel trowelled; 3 mm '
'thick work to walls or ceilings; one '
'coat; to plasterboard base; over 600mm '
'wide',
'depth': 0.0, 'depth_unit': None, 'cost': None,
'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.06,
'labour_cost': 6.58, 'labour_hours_per_unit': 0.25,
'plant_cost': 0.0, 'total_cost': 6.64, 'notes': None},
{'id': 1143, 'type': 'iwi_redecoration',
'description': 'Two coats emulsion paint on plaster, over 40mm girth; 3.5m - 5m high', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.41, 'labour_cost': 3.93, 'labour_hours_per_unit': 0.21,
'plant_cost': 0.0, 'total_cost': 4.34, 'notes': None}, {'id': 1144, 'type': 'iwi_redecoration',
'description': 'Fitting existing softwood skirting or '
'architrave to new frames; 150mm high',
'depth': 0.0, 'depth_unit': None, 'cost': None,
'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.01,
'labour_cost': 4.87, 'labour_hours_per_unit': 0.12,
'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None},
{'id': 1145, 'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
'plant_cost': 0.0, 'total_cost': 3.32,
'notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and therefore '
'there is no need for a skip'},
{'id': 1146, 'type': 'suspended_floor_demolition',
'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3,
'plant_cost': 0.0, 'total_cost': 9.34, 'notes': None},
{'id': 1147, 'type': 'suspended_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02,
'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None},
{'id': 1148, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 50.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 4.24, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 5.8,
'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
'in Crown loft roll 44, since it is also an insulation roll'},
{'id': 1149, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 6.31, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 7.87,
'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
'in Crown loft roll 44, since it is also an insulation roll'},
{'id': 1150, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 100.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 8.26, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 9.82,
'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
'in Crown loft roll 44, since it is also an insulation roll'},
{'id': 1151, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 140.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 13.46,
'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
'in Crown loft roll 44, since it is also an insulation roll'},
{'id': 1152, 'type': 'suspended_floor_insulation',
'description': 'Thermafleece TF35 high density wool insulating batts', 'depth': 50.0, 'depth_unit': 'mm',
'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.028571429,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.035,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 6.63, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 8.19,
'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
'in Crown loft roll 44, since it is also an insulation roll'},
{'id': 1153, 'type': 'suspended_floor_insulation',
'description': 'Thermafleece TF35 high density wool insulating batts', 'depth': 75.0, 'depth_unit': 'mm',
'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.028571429,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.035,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 10.31, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 11.87,
'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
'in Crown loft roll 44, since it is also an insulation roll'},
{'id': 1154, 'type': 'suspended_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 30.0, 'depth_unit': 'mm',
'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16,
'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07,
'notes': None}, {'id': 1155, 'type': 'suspended_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': 8.46, 'material_cost': 19.1, 'labour_cost': 28.34,
'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44, 'notes': None},
{'id': 1156, 'type': 'suspended_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 100.0, 'depth_unit': 'mm',
'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12,
'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66,
'notes': None}, {'id': 1157, 'type': 'suspended_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 150.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': 23.53, 'material_cost': 34.62, 'labour_cost': 33.06,
'labour_hours_per_unit': 1.4, 'plant_cost': 0.0, 'total_cost': 67.68, 'notes': None},
{'id': 1158, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll',
'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.03,
'material_cost': 2.1, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.66,
'notes': None},
{'id': 1159, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 3.06,
'material_cost': 3.16, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.94,
'notes': None},
{'id': 1160, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll',
'depth': 200.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.25,
'material_cost': 4.37, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 6.33,
'notes': None},
{'id': 1161, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll',
'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.99,
'material_cost': 2.05, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.65,
'notes': None},
{'id': 1162, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.96,
'material_cost': 3.05, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.83,
'notes': None},
{'id': 1163, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll',
'depth': 200.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.4,
'material_cost': 4.53, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 7.2,
'notes': None},
{'id': 1164, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 25.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.025641026,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.67,
'material_cost': 2.01, 'labour_cost': 1.43, 'labour_hours_per_unit': 0.08, 'plant_cost': 0.0, 'total_cost': 3.44,
'notes': None},
{'id': 1165, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 50.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.025641026,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.74,
'material_cost': 3.11, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 4.71,
'notes': None},
{'id': 1166, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.57,
'material_cost': 5.01, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 6.79,
'notes': None},
{'id': 1167, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 100.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 5.93,
'material_cost': 6.4, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.07,
'notes': None},
{'id': 1168, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
'depth': 25.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12,
'notes': None},
{'id': 1169, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33,
'notes': None},
{'id': 1170, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47,
'notes': None},
{'id': 1171, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 12.02, 'labour_cost': 4.4, 'labour_hours_per_unit': 0.19, 'plant_cost': 0.0, 'total_cost': 16.42,
'notes': None}, {'id': 1172, 'type': 'suspended_floor_insulation',
'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 50.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 10.36, 'labour_cost': 4.06,
'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 14.42, 'notes': None},
{'id': 1173, 'type': 'suspended_floor_insulation',
'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41,
'notes': None}, {'id': 1174, 'type': 'suspended_floor_insulation',
'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation',
'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907),
'is_active': True, 'prime_material_cost': None, 'material_cost': 19.17, 'labour_cost': 4.06,
'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 23.23, 'notes': None},
{'id': 1175, 'type': 'suspended_floor_insulation',
'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 125.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 26.59, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 30.65,
'notes': None}, {'id': 1176, 'type': 'suspended_floor_insulation',
'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907),
'is_active': True, 'prime_material_cost': None, 'material_cost': 31.13, 'labour_cost': 4.64,
'labour_hours_per_unit': 0.2, 'plant_cost': 0.0, 'total_cost': 35.77, 'notes': None},
{'id': 1177, 'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 1.54, 'labour_cost': 24.98, 'labour_hours_per_unit': 0.74,
'plant_cost': 0.0, 'total_cost': 26.52, 'notes': None},
{'id': 1178, 'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37,
'plant_cost': 0.0, 'total_cost': 6.59,
'notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; Gradus woven '
'polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, therefore we need just '
'labour rates'},
{'id': 1179, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
'plant_cost': 0.0, 'total_cost': 3.32,
'notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and therefore '
'there is no need for a skip'},
{'id': 1180, 'type': 'solid_floor_preparation',
'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0.0, 'depth_unit': None,
'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None,
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36,
'notes': None}, {'id': 1181, 'type': 'solid_floor_preparation',
'description': 'Clean out crack to form a 20mm×20mm groove and fill with cement: mortar mixed '
'with bonding agent',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': None,
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 6.91, 'labour_cost': 18.99,
'labour_hours_per_unit': 0.61, 'plant_cost': 0.16, 'total_cost': 26.06,
'notes': 'This step is the assessment and repair of any damage to the concrete floor such as '
'filling cracks or levelling uneven areas'},
{'id': 1182, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02,
'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None},
{'id': 1183, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 25.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12,
'notes': None},
{'id': 1184, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 50.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33,
'notes': None},
{'id': 1185, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47,
'notes': None}, {'id': 1186, 'type': 'solid_floor_insulation',
'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 50.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 10.36, 'labour_cost': 4.06,
'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 14.42, 'notes': None},
{'id': 1187, 'type': 'solid_floor_insulation',
'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41,
'notes': None}, {'id': 1188, 'type': 'solid_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 30.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': 6.16, 'material_cost': 16.73, 'labour_cost': 28.34,
'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07, 'notes': None},
{'id': 1189, 'type': 'solid_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.0, 'depth_unit': 'mm',
'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46,
'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44,
'notes': None}, {'id': 1190, 'type': 'solid_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 60.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://londonbuildingsupplies.co.uk/products/60mm--ecotherm-eco-versal-general'
'-purpose-pir-insulation-board---2.4m-x-1.2m-x-60mm.html',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 24.081198, 'labour_cost': 28.34,
'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 52.421196,
'notes': "This material isn't in SPONs but checking online, is around 92% of the cost of the "
"100mm"},
{'id': 1191, 'type': 'solid_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 70.0, 'depth_unit': 'mm',
'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://londonbuildingsupplies.co.uk/products/70mm--ecotherm-eco-versal-general-purpose-pir-insulation'
'-board---2.4m-x-1.2m-x-70mm.html',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 27.089088, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0,
'total_cost': 55.42909,
'notes': "This material isn't in SPONs but checking online, is around 104% of the cost of the 100mm (more "
"expensive than 100mm)"},
{'id': 1192, 'type': 'solid_floor_insulation',
'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 100.0, 'depth_unit': 'mm',
'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12,
'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66,
'notes': None},
{'id': 1193, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam',
'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.032258064,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.031,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 11.07, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0,
'total_cost': 21.73,
'notes': "In Spons, the thermal conductivity is 0.033 however the datasheet indicates it's 0.32: "
"https://ravagobuildingsolutions.com/uk/wp-content/uploads/sites/30/2022/08/ravatherm-xps-x-500-sl-tds"
"-version-1-20210901.pdf"},
{'id': 1194, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam',
'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 16.28, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0,
'total_cost': 26.94, 'notes': None}, {'id': 1195, 'type': 'solid_floor_redecoration',
'description': 'Screeded beds; protection to compressible formwork '
'exceeding 600mm wide',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None,
'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907),
'is_active': True, 'prime_material_cost': 9.6, 'material_cost': 9.89,
'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0,
'total_cost': 12.56,
'notes': 'This is the screed layer, placed on top of the insulation'},
{'id': 1196, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None,
'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, 'plant_cost': 0.0, 'total_cost': 6.59,
'notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; Gradus woven '
'polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, therefore we need just '
'labour rates'},
{'id': 1197, 'type': 'solid_floor_redecoration',
'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12,
'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None}, {'id': 1198, 'type': 'ewi_wall_demolition',
'description': 'Solid & Dry Lined walls: Hack of wall '
'finishes with chipping hammer; plaster '
'to walls.',
'depth': 0.0, 'depth_unit': None, 'cost': None,
'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0,
'labour_cost': 10.27, 'labour_hours_per_unit': 0.33,
'plant_cost': 1.28, 'total_cost': 11.55, 'notes': None},
{'id': 1199, 'type': 'ewi_wall_demolition',
'description': 'Stud walls: Remove wall linings including battening behind; plasterboard and skim', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.23, 'labour_hours_per_unit': 0.2,
'plant_cost': 1.25, 'total_cost': 7.48, 'notes': None}, {'id': 1200, 'type': 'ewi_wall_demolition',
'description': 'Lathe and Plaster walls: Remove wall '
'linings including battening behind; '
'wood lath and plaster',
'depth': 0.0, 'depth_unit': None, 'cost': None,
'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
244907),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 6.85,
'labour_hours_per_unit': 0.22, 'plant_cost': 2.09,
'total_cost': 8.94, 'notes': None},
{'id': 1201, 'type': 'ewi_wall_preparation',
'description': 'Clean and prepare surfaces, one coat Keim dilution, one coat primer and two coats of Keim Ecosil '
'paint; Brick or block walls; over 300 mm girth',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': None, 'material_cost': 7.3, 'labour_cost': 5.62, 'labour_hours_per_unit': 0.3,
'plant_cost': 0.0, 'total_cost': 12.92,
'notes': 'This work covers the preparation and priming of the wall before insulating'},
{'id': 1202, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 30.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16,
'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07,
'notes': None},
{'id': 1203, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46,
'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44,
'notes': None},
{'id': 1204, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12,
'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66,
'notes': None},
{'id': 1205, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 23.53,
'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0.0, 'total_cost': 67.68,
'notes': None},
{'id': 1206, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
'depth': 60.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 41.69,
'material_cost': 53.33, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0,
'total_cost': 82.85, 'notes': None},
{'id': 1207, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 86.86,
'material_cost': 99.85, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0,
'total_cost': 129.37, 'notes': None},
{'id': 1208, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
'prime_material_cost': 130.29, 'material_cost': 144.58, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25,
'plant_cost': 0.0, 'total_cost': 174.1, 'notes': None}, {'id': 1209, 'type': 'ewi_wall_redecoration',
'description': 'EPS insulation fixed with adhesive to '
'SFS structure (measured separately) '
'with horizontal PVC intermediate track '
'and vertical T-spines; with glassfibre '
'mesh reinforcement embedded in Sto '
'Armat Classic Basecoat Render and '
'Stolit K 1.5 Decorative Topcoat Render '
'(white)',
'depth': 0.0, 'depth_unit': None, 'cost': None,
'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'SPONs',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
244907),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.0, 'plant_cost': 0.0,
'total_cost': 69.94,
'notes': 'This material in SPONs is for 70mm EPS '
'insulation, which comes in at a cost of 99.17 '
'per meter square. This includes the cost of '
'insulation. To get the costing for just the '
'works and not the insulation, we subtract the '
'cost of EPS insulation, using Ravathem 75mm '
'insulation as an example, which costs £29.23 '
'per meter square, giving us the cost of the '
'remaining works without insulation. This '
'material gives us a cost for basecoat, '
'mesh application and a render finish'},
{'id': 1210, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub',
'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': 'https://www.checkatrade.com/blog/cost-guides/cost-install-downlights/ '
'https://www.hamuch.com/cost/led-spot-light#:~:text=It%20costs%20an%20average%20of,'
'will%20drive%20up%20the%20cost.',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
'material_cost': 20.0, 'labour_cost': 46.0, 'labour_hours_per_unit': 0.8, 'plant_cost': 0.0, 'total_cost': 66.0,
'notes': 'We estimate the unit economics from the checkatrade article. We assume that the average job consists '
'of installing 6 lights based on the hamuch article. We use the median value of 400 for a job of 6 '
'lights'}]

View file

@ -37,7 +37,7 @@ class TestFirepaceRecommendations:
assert recommender.recommendation
assert recommender.recommendation[0]["type"] == "sealing_open_fireplace"
assert recommender.recommendation[0]["cost"] == 300
assert recommender.recommendation[0]["total"] == 300
def test_multiple_fireplaces(self):
property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
@ -55,4 +55,4 @@ class TestFirepaceRecommendations:
assert recommender.recommendation
assert recommender.recommendation[0]["type"] == "sealing_open_fireplace"
assert recommender.recommendation[0]["cost"] == 900
assert recommender.recommendation[0]["total"] == 900

View file

@ -3,90 +3,15 @@ import pytest
import os
from unittest.mock import Mock
from recommendations.FloorRecommendations import FloorRecommendations
from recommendations.tests.test_data.materials import materials
from backend.Property import Property
# with open(
# os.path.abspath(os.path.dirname(__file__)) + "/recommendations/tests/test_data/input_properties.pkl", "rb"
# ) as f:
# input_properties = pickle.load(f)
suspended_floor_insulation_parts = [
{
# Example product
# https://www.insulationsuperstore.co.uk/product/recticel-eurothane-general-purpose-pir-insulation-board-2400
# -x-1200-x-100mm.html
# All product data_types here:
# https://www.insulationsuperstore.co.uk/browse/insulation/brand/recticel/filterby/application/floors.html
"type": "suspended_floor_insulation",
"description": "Rigid Insulation Foam Boards",
"depths": [25, 30, 40, 50, 60, 70, 75, 80, 90, 100, 110, 120, 130, 140, 150],
"depth_unit": "mm",
"cost": [25, 30, 40, 50, 60, 70, 75, 80, 90, 100, 110, 120, 130, 140, 150],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.04545454545454546,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.022,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.insulationsuperstore.co.uk/product/rockwool-rwa45-acoustic-insulation-slab-100mm-2-88m2-pack.html
# All product data_types here:
# https://www.insulationsuperstore.co.uk/browse/insulation/brand/rockwool/filterby/application/floors
# /material/mineral-wool.html
"type": "suspended_floor_insulation",
"description": "Mineral Wool Floor Insulation",
"depths": [25, 40, 50, 60, 75, 100],
"depth_unit": "mm",
"cost": [25, 40, 50, 60, 75, 100],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.02857142857142857,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.035,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
]
solid_floor_insulation_parts = [
{
# Example product
# https://www.insulationexpress.co.uk/floor-insulation/solid-floor-insulation/k103-100mm
# All product data_types here:
# https://www.insulationexpress.co.uk/floor-insulation/solid-floor-insulation?brand=7015&p=1
# Example screed https://www.screwfix.com/p/mapei-ultraplan-3240-self-levelling-compound-25kg/4959f
"type": "solid_floor_insulation",
"description": "Rigid Insulation Foam Boards with floor screed",
"depths": [25, 50, 70, 75, 100],
"depth_unit": "mm",
"cost": [25, 40, 50, 60, 75, 100],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.04545454545454546,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.052631578947368425,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
]
exposed_floor_insulation_parts = [
{
"type": "exposed_floor_insulation",
"description": "Rockwool Stone Wool insulation",
"depths": [50, 100, 140],
"depth_unit": "mm",
"cost": [8, 11, 15],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.026315789473684213,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.038,
"thermal_conductivity_unit": "watt_per_meter_kelvin",
"link": "https://insulation4less.co.uk/products/rockwool-flexi-slab-all-sizes?variant=33409590853685"
},
]
parts = suspended_floor_insulation_parts + solid_floor_insulation_parts + exposed_floor_insulation_parts
class TestFloorRecommendations:
@pytest.fixture
@ -100,26 +25,29 @@ class TestFloorRecommendations:
def mock_floor_rec_instance(self):
# Creating a mock instance of WallRecommendations with the necessary attributes
property_mock = Mock()
property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"} # or any date you want
property_mock.data = {"construction-age-band": "1950"} # or any other data that fits your tests
property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"}
property_mock.data = {"county": "York"}
mock_wall_rec_instance = FloorRecommendations(property_mock, parts)
mock_wall_rec_instance = FloorRecommendations(property_mock, materials)
return mock_wall_rec_instance
def test_init(self, input_properties):
input_properties[0].insulation_floor_area = 50
input_properties[0].insulation_wall_area = 90
obj = FloorRecommendations(
property_instance=input_properties[0],
materials=parts
materials=materials
)
assert obj
assert obj.property
def test_other_premises_below(self, input_properties):
input_properties[0].floor_area = 100
input_properties[0].insulation_floor_area = 100
input_properties[0].insulation_wall_area = 999
input_properties[0].number_of_floors = 1
recommender = FloorRecommendations(
property_instance=input_properties[0],
materials=parts
materials=materials
)
recommender.recommend()
assert recommender.property.floor["another_property_below"]
@ -132,7 +60,8 @@ class TestFloorRecommendations:
:return:
"""
input_properties[2].floor_area = 50
input_properties[2].insulation_floor_area = 50
input_properties[2].insulation_wall_area = 50
input_properties[2].walls["is_park_home"] = False
input_properties[2].age_band = "A"
input_properties[2].perimeter = 20
@ -140,10 +69,7 @@ class TestFloorRecommendations:
input_properties[2].floor_type = "suspended"
input_properties[2].number_of_floors = 1
recommender = FloorRecommendations(
property_instance=input_properties[2],
materials=parts
)
recommender = FloorRecommendations(property_instance=input_properties[2], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
assert recommender.property.floor["is_suspended"]
@ -154,18 +80,20 @@ class TestFloorRecommendations:
assert types == {"suspended_floor_insulation"}
assert len(recommender.recommendations) == 6
assert recommender.recommendations[0]["total"] == 4596.858
assert recommender.recommendations[0]["new_u_value"] == 0.21
def test_uvalue_0_12(self, input_properties):
"""
This is a home that doesn't have a property below but it's highly performant already and therefore
does not need floor insulation
:return:
"""
input_properties[3].floor_area = 100
input_properties[3].insulation_floor_area = 100
input_properties[3].insulation_wall_area = 100
input_properties[3].number_of_floors = 1
recommender = FloorRecommendations(
property_instance=input_properties[3],
materials=parts
)
recommender = FloorRecommendations(property_instance=input_properties[3], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
assert not recommender.property.floor["is_suspended"]
@ -178,7 +106,8 @@ class TestFloorRecommendations:
:return:
"""
input_properties[4].floor_area = 100
input_properties[4].insulation_floor_area = 100
input_properties[4].insulation_wall_area = 100
input_properties[4].walls["is_park_home"] = False
input_properties[4].age_band = "B"
input_properties[4].perimeter = 50
@ -186,10 +115,9 @@ class TestFloorRecommendations:
input_properties[4].floor_type = "solid"
input_properties[4].number_of_floors = 1
recommender = FloorRecommendations(
property_instance=input_properties[4],
materials=parts
)
# In this case, we have no county, so in this case, it should yse the local-authority-label if possible
input_properties[4].data["county"] = ""
recommender = FloorRecommendations(property_instance=input_properties[4], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
assert not recommender.property.floor["is_suspended"]
@ -201,17 +129,22 @@ class TestFloorRecommendations:
assert types == {"solid_floor_insulation"}
assert len(recommender.recommendations) == 3
assert recommender.recommendations[2]["total"] == 14604.660000000002
assert recommender.recommendations[2]["new_u_value"] == 0.21
assert recommender.recommendations[2]["parts"][0]["depth"] == 75
assert recommender.recommendations[2]["parts"][0]["depth"] == 75
def test_another_dwelling_below(self, input_properties):
"""
This is another description we see when there is a property below
"""
input_properties[6].floor_area = 100
input_properties[6].insulation_floor_area = 100
input_properties[6].insulation_wall_area = 1
input_properties[6].number_of_floors = 1
recommender = FloorRecommendations(
property_instance=input_properties[6],
materials=parts
)
recommender = FloorRecommendations(property_instance=input_properties[6], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
assert not recommender.property.floor["is_suspended"]
@ -219,123 +152,123 @@ class TestFloorRecommendations:
assert recommender.estimated_u_value is None
assert not recommender.recommendations
def test_exposed_floor_no_insulation(self):
input_property = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
input_property.floor = {
'original_description': 'To unheated space, no insulation (assumed)',
'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
'insulation_thickness': 'none'
}
input_property.age_band = "L"
input_property.set_floor_type()
input_property.data = {"floor-level": 0, "property-type": "House"}
input_property.floor_area = 100
input_property.number_of_floors = 1
recommender = FloorRecommendations(
property_instance=input_property,
materials=exposed_floor_insulation_parts
)
assert not recommender.recommendations
recommender.recommend()
# Because of age band L, this should have a u-value of 0.22 to begin with and no recommendation
assert not len(recommender.recommendations)
assert recommender.estimated_u_value == 0.22
# Now with an older age band
input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
input_property2.floor = {
'original_description': 'To unheated space, no insulation (assumed)',
'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
'insulation_thickness': 'none'
}
input_property2.age_band = "D"
input_property2.set_floor_type()
input_property2.data = {"floor-level": 0, "property-type": "House"}
input_property2.floor_area = 100
input_property2.number_of_floors = 1
recommender2 = FloorRecommendations(
property_instance=input_property2,
materials=exposed_floor_insulation_parts
)
assert not recommender2.recommendations
recommender2.recommend()
assert len(recommender2.recommendations) == 1
assert recommender2.recommendations[0]["new_u_value"] == 0.23
assert recommender2.recommendations[0]["starting_u_value"] == 1.2
assert recommender2.recommendations[0]["cost"] == 1500
def test_exposed_floor_below_average_insulated(self):
input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
input_property3.floor = {
'original_description': 'To unheated space, below average insulation (assumed)',
'clean_description': 'To unheated space, below average insulation', 'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
'insulation_thickness': 'below average'
}
input_property3.age_band = "C"
input_property3.set_floor_type()
input_property3.data = {"floor-level": 0, "property-type": "House"}
input_property3.floor_area = 100
input_property3.number_of_floors = 1
recommender3 = FloorRecommendations(
property_instance=input_property3,
materials=exposed_floor_insulation_parts
)
assert not recommender3.recommendations
recommender3.recommend()
assert recommender3.estimated_u_value == 0.5
assert len(recommender3.recommendations) == 1
assert recommender3.recommendations[0]["new_u_value"] == 0.22
assert recommender3.recommendations[0]["starting_u_value"] == 0.5
assert recommender3.recommendations[0]["cost"] == 1100
assert recommender3.recommendations[0]["parts"][0]["depths"] == [100]
# With average insulation, no recommendations
input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
input_property4.floor = {
'original_description': 'To unheated space, insulated (assumed)',
'clean_description': 'To unheated space, insulated', 'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
'insulation_thickness': 'average'
}
input_property4.age_band = "C"
input_property4.set_floor_type()
input_property4.data = {"floor-level": 0, "property-type": "House"}
input_property4.floor_area = 100
input_property4.number_of_floors = 1
recommender4 = FloorRecommendations(
property_instance=input_property4,
materials=exposed_floor_insulation_parts
)
assert not recommender4.recommendations
recommender4.recommend()
assert recommender4.estimated_u_value is None
assert len(recommender4.recommendations) == 0
# def test_exposed_floor_no_insulation(self):
# input_property = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
# input_property.floor = {
# 'original_description': 'To unheated space, no insulation (assumed)',
# 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None,
# 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
# 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
# 'insulation_thickness': 'none'
# }
# input_property.age_band = "L"
# input_property.set_floor_type()
# input_property.data = {"floor-level": 0, "property-type": "House"}
# input_property.floor_area = 100
# input_property.number_of_floors = 1
#
# recommender = FloorRecommendations(
# property_instance=input_property,
# materials=materials
# )
#
# assert not recommender.recommendations
#
# recommender.recommend()
#
# # Because of age band L, this should have a u-value of 0.22 to begin with and no recommendation
# assert not len(recommender.recommendations)
# assert recommender.estimated_u_value == 0.22
#
# # Now with an older age band
#
# input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
# input_property2.floor = {
# 'original_description': 'To unheated space, no insulation (assumed)',
# 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None,
# 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
# 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
# 'insulation_thickness': 'none'
# }
# input_property2.age_band = "D"
# input_property2.set_floor_type()
# input_property2.data = {"floor-level": 0, "property-type": "House"}
# input_property2.floor_area = 100
# input_property2.number_of_floors = 1
#
# recommender2 = FloorRecommendations(
# property_instance=input_property2,
# materials=materials
# )
#
# assert not recommender2.recommendations
#
# recommender2.recommend()
#
# assert len(recommender2.recommendations) == 1
#
# assert recommender2.recommendations[0]["new_u_value"] == 0.23
# assert recommender2.recommendations[0]["starting_u_value"] == 1.2
# assert recommender2.recommendations[0]["cost"] == 1500
#
# def test_exposed_floor_below_average_insulated(self):
# input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
# input_property3.floor = {
# 'original_description': 'To unheated space, below average insulation (assumed)',
# 'clean_description': 'To unheated space, below average insulation', 'thermal_transmittance': None,
# 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
# 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
# 'insulation_thickness': 'below average'
# }
# input_property3.age_band = "C"
# input_property3.set_floor_type()
# input_property3.data = {"floor-level": 0, "property-type": "House"}
# input_property3.floor_area = 100
# input_property3.number_of_floors = 1
#
# recommender3 = FloorRecommendations(
# property_instance=input_property3,
# materials=materials
# )
#
# assert not recommender3.recommendations
#
# recommender3.recommend()
#
# assert recommender3.estimated_u_value == 0.5
#
# assert len(recommender3.recommendations) == 1
#
# assert recommender3.recommendations[0]["new_u_value"] == 0.22
# assert recommender3.recommendations[0]["starting_u_value"] == 0.5
# assert recommender3.recommendations[0]["cost"] == 1100
# assert recommender3.recommendations[0]["parts"][0]["depths"] == [100]
#
# # With average insulation, no recommendations
#
# input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
# input_property4.floor = {
# 'original_description': 'To unheated space, insulated (assumed)',
# 'clean_description': 'To unheated space, insulated', 'thermal_transmittance': None,
# 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
# 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
# 'insulation_thickness': 'average'
# }
# input_property4.age_band = "C"
# input_property4.set_floor_type()
# input_property4.data = {"floor-level": 0, "property-type": "House"}
# input_property4.floor_area = 100
# input_property4.number_of_floors = 1
#
# recommender4 = FloorRecommendations(
# property_instance=input_property4,
# materials=materials
# )
#
# assert not recommender4.recommendations
#
# recommender4.recommend()
#
# assert recommender4.estimated_u_value is None
#
# assert len(recommender4.recommendations) == 0

View file

@ -0,0 +1,47 @@
import pytest
from unittest.mock import Mock
from backend.Property import Property
from recommendations.LightingRecommendations import LightingRecommendations
from recommendations.tests.test_data.materials import materials
class TestLightingRecommendations:
def test_init_invalid_materials(self):
input_property0 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock())
input_property0.lighting = {"low_energy_proportion": 0}
input_property0.data = {"county": "Greater London Authority"}
# Test for invalid materials
with pytest.raises(ValueError):
LightingRecommendations(input_property0, [])
def test_recommend_no_action_needed(self):
# Case where no recommendation is needed
input_property1 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock())
input_property1.lighting = {"low_energy_proportion": 100}
input_property1.data = {"county": "Greater London Authority"}
lr = LightingRecommendations(input_property1, materials)
lr.recommend()
assert lr.recommendation == []
def test_recommend_action_needed(self):
# Case where recommendation is needed
input_property1 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock())
input_property1.lighting = {"low_energy_proportion": 100}
input_property1.data = {"county": "Greater London Authority"}
input_property1.lighting = {"low_energy_proportion": 0.80}
input_property1.number_lighting_outlets = 20
lr = LightingRecommendations(input_property1, materials)
lr.recommend()
assert len(lr.recommendation) == 1
assert lr.recommendation == [
{'parts': [], 'type': 'low_energy_lighting', 'description': 'Install low energy lighting in 4 outlets',
'starting_u_value': None, 'new_u_value': None, 'sap_points': 0.4, 'total': 458.976, 'subtotal': 382.48,
'vat': 76.49600000000001, 'contingency': 27.320000000000007, 'preliminaries': 27.320000000000007,
'material': 80.0, 'profit': 54.640000000000015, 'labour_hours': 3.2, 'labour_days': 0.4,
'labour_cost': 193.20000000000002}
]

View file

@ -42,10 +42,12 @@ class TestRecommendationUtils:
assert recommendation_utils.update_lowest_selected_u_value(1, 0.5) == 0.5
def test_get_recommended_part(self):
part = {'depths': [1, 2, 3]}
part = {'description': "some insulation material"}
assert recommendation_utils.get_recommended_part(
part=part, selected_depth=1, selected_total_cost=50, quantity=99, quantity_unit="m2"
) == {'depths': [1], 'estimated_cost': 50, 'quantity': 99, 'quantity_unit': QuantityUnits.m2.value}
part=part, cost_result={"cost_result": 123}, quantity=99, quantity_unit="m2"
) == {'description': "some insulation material", 'quantity': 99, 'quantity_unit': QuantityUnits.m2.value,
"cost_result": 123}
def test_get_roof_u_value(self):
# Test case 1: Insulation thickness is known and is_loft is True

View file

@ -1,65 +1,7 @@
from backend.Property import Property
from unittest.mock import Mock
from recommendations.RoofRecommendations import RoofRecommendations
loft_insulation_materials = [
{
'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation',
'depths': [270, 300], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter',
'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/',
'is_active': True
}
]
loft_insulation_materials_50mm_existing = [
{
'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation',
'depths': [220, 210], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter',
'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/',
'is_active': True
}
]
loft_insulation_materials_150mm_existing = [
{
'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation',
'depths': [130, 119], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter',
'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/',
'is_active': True
}
]
room_roof_insulation_materials = [
{
'id': 18,
'type': 'room_roof_insulation',
'description': 'Example room roof insulation',
'depths': [50, 150, 220, 270, 300], 'depth_unit': 'mm', 'cost': [9, 10, 11, 12, 13],
'cost_unit': 'gbp_sq_meter',
'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': None, 'is_active': True
}
]
flat_roof_insulation_materials = [
{
'id': 18,
'type': 'flat_roof_insulation',
'description': 'Example flat roof insulation',
'depths': [50, 150, 220, 270, 300], 'depth_unit': 'mm', 'cost': [9, 10, 11, 12, 13],
'cost_unit': 'gbp_sq_meter',
'r_value_per_mm': 0.032727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': None, 'is_active': True
}
]
from recommendations.tests.test_data.materials import materials
class TestRoofRecommendations:
@ -67,7 +9,7 @@ class TestRoofRecommendations:
def test_loft_insulation_recommendation_no_insulation(self):
property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance.age_band = "F"
property_instance.floor_area = 100
property_instance.insulation_floor_area = 100
property_instance.roof = {
'original_description': 'Pitched, no insulation (assumed)',
'clean_description': 'Pitched, no insulation',
@ -77,8 +19,11 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'none', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance.data = {
"county": "Cambridgeshire",
}
roof_recommender = RoofRecommendations(property_instance=property_instance, materials=loft_insulation_materials)
roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
assert not roof_recommender.recommendations
@ -89,7 +34,7 @@ class TestRoofRecommendations:
def test_loft_insulation_recommendation_50mm_insulation(self):
property_instance2 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance2.age_band = "F"
property_instance2.floor_area = 100
property_instance2.insulation_floor_area = 100
property_instance2.roof = {
'original_description': 'Pitched, 50mm loft insulation (assumed)',
'clean_description': 'Pitched, 50mm loft insulation',
@ -99,10 +44,9 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance2.data = {"county": "Kent"}
roof_recommender2 = RoofRecommendations(
property_instance=property_instance2, materials=loft_insulation_materials
)
roof_recommender2 = RoofRecommendations(property_instance=property_instance2, materials=materials)
assert not roof_recommender2.recommendations
@ -110,13 +54,13 @@ class TestRoofRecommendations:
assert len(roof_recommender2.recommendations) == 1
assert roof_recommender2.recommendations[0]["cost"] == 900
assert roof_recommender2.recommendations[0]["total"] == 1310.56464
assert roof_recommender2.recommendations[0]["new_u_value"] == 0.14
assert roof_recommender2.recommendations[0]["starting_u_value"] == 0.68
property_instance3 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance3.age_band = "F"
property_instance3.floor_area = 100
property_instance3.insulation_floor_area = 100
property_instance3.roof = {
'original_description': 'Pitched, 50mm loft insulation (assumed)',
'clean_description': 'Pitched, 50mm loft insulation',
@ -126,24 +70,22 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance3.data = {"county": "Greater London Authority"}
roof_recommender3 = RoofRecommendations(
property_instance=property_instance3, materials=loft_insulation_materials_50mm_existing
)
roof_recommender3 = RoofRecommendations(property_instance=property_instance3, materials=materials)
assert not roof_recommender3.recommendations
roof_recommender3.recommend()
# The 220mm insulation should be selected, not the 210
assert roof_recommender3.recommendations
assert len(roof_recommender3.recommendations) == 1
assert roof_recommender3.recommendations[0]["parts"][0]["depths"] == [220]
assert roof_recommender3.recommendations[0]["parts"][0]["depth"] == 270
def test_loft_insulation_recommendation_150mm_insulation(self):
property_instance4 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance4.age_band = "F"
property_instance4.floor_area = 100
property_instance4.insulation_floor_area = 100
property_instance4.roof = {
'original_description': 'Pitched, 150mm loft insulation (assumed)',
'clean_description': 'Pitched, 150mm loft insulation',
@ -153,24 +95,24 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance4.data = {"county": "North East Lincolnshire"}
roof_recommender4 = RoofRecommendations(
property_instance=property_instance4, materials=loft_insulation_materials
)
roof_recommender4 = RoofRecommendations(property_instance=property_instance4, materials=materials)
assert not roof_recommender4.recommendations
roof_recommender4.recommend()
assert len(roof_recommender4.recommendations) == 1
assert len(roof_recommender4.recommendations) == 4
assert roof_recommender4.recommendations[0]["cost"] == 900
assert roof_recommender4.recommendations[0]["new_u_value"] == 0.11
assert roof_recommender4.recommendations[0]["total"] == 788.0544
assert roof_recommender4.recommendations[0]["new_u_value"] == 0.15
assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3
assert roof_recommender4.recommendations[0]["parts"][0]["depth"] == 150
property_instance5 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance5.age_band = "F"
property_instance5.floor_area = 100
property_instance5.insulation_floor_area = 100
property_instance5.roof = {
'original_description': 'Pitched, 150mm loft insulation (assumed)',
'clean_description': 'Pitched, 150mm loft insulation',
@ -180,25 +122,24 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance5.data = {"county": "Somerset"}
roof_recommender5 = RoofRecommendations(
property_instance=property_instance5, materials=loft_insulation_materials_150mm_existing
)
roof_recommender5 = RoofRecommendations(property_instance=property_instance5, materials=materials)
assert not roof_recommender5.recommendations
roof_recommender5.recommend()
# The 130mm insulation should be selected, not the 110
# The 150mm insulation should be selected, since there it already 150mm
assert roof_recommender5.recommendations
assert len(roof_recommender5.recommendations) == 1
assert roof_recommender5.recommendations[0]["parts"][0]["depths"] == [130]
assert len(roof_recommender5.recommendations) == 4
assert roof_recommender5.recommendations[0]["parts"][0]["depth"] == 150
def test_loft_insulation_recommendation_270mm_insulation(self):
# We shouldn't recommend anything in this case
property_instance6 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance6.age_band = "F"
property_instance6.floor_area = 100
property_instance6.insulation_floor_area = 100
property_instance6.roof = {
'original_description': 'Pitched, 270mm loft insulation (assumed)',
'clean_description': 'Pitched, 270mm loft insulation',
@ -208,10 +149,9 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '270', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance6.data = {"county": "Portsmouth"}
roof_recommender6 = RoofRecommendations(
property_instance=property_instance6, materials=loft_insulation_materials
)
roof_recommender6 = RoofRecommendations(property_instance=property_instance6, materials=materials)
assert not roof_recommender6.recommendations
@ -219,219 +159,211 @@ class TestRoofRecommendations:
assert len(roof_recommender6.recommendations) == 0
def test_uninsulated_room_in_roof(self):
property_instance7 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance7.age_band = "F"
property_instance7.floor_area = 100
property_instance7.roof = {
'original_description': 'Roof room(s), no insulation (assumed)',
'clean_description': 'Roof room(s), no insulation',
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
}
property_instance7.pitched_roof_area = 110
roof_recommender7 = RoofRecommendations(
property_instance=property_instance7, materials=room_roof_insulation_materials
)
assert not roof_recommender7.recommendations
roof_recommender7.recommend()
# Even though we have 3 depths, we only end with 1 due to diminishin returns
assert len(roof_recommender7.recommendations) == 1
assert roof_recommender7.recommendations[0]["parts"][0]["depths"] == [270]
assert roof_recommender7.recommendations[0]["new_u_value"] == 0.14
assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8
assert roof_recommender7.recommendations[0]["description"] == \
"Insulate your room roof with 270mm of Example room roof insulation"
def test_ceiling_insulated_room_in_roof(self):
property_instance8 = Property(id=8, address1="fake", postcode="fake", epc_client=Mock())
property_instance8.age_band = "F"
property_instance8.floor_area = 100
property_instance8.roof = {
'original_description': 'Roof room(s), ceiling insulated',
'clean_description': 'Roof room(s), ceiling insulated',
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
'is_at_rafters': False,
'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'average'
}
property_instance8.pitched_roof_area = 110
roof_recommender8 = RoofRecommendations(
property_instance=property_instance8, materials=room_roof_insulation_materials
)
assert not roof_recommender8.recommendations
roof_recommender8.recommend()
# No recommendations in this case
assert not roof_recommender8.recommendations
def test_insulated_room_in_roof(self):
property_instance9 = Property(id=9, address1="fake", postcode="fake", epc_client=Mock())
property_instance9.age_band = "F"
property_instance9.floor_area = 100
property_instance9.roof = {
'original_description': 'Roof room(s), insulated (assumed)',
'clean_description': 'Roof room(s), insulated',
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
}
property_instance9.pitched_roof_area = 110
roof_recommender9 = RoofRecommendations(
property_instance=property_instance9, materials=room_roof_insulation_materials
)
assert not roof_recommender9.recommendations
roof_recommender9.recommend()
# No recommendations in this case
assert not roof_recommender9.recommendations
def test_limited_insulated_room_in_roof(self):
property_instance10 = Property(id=10, address1="fake", postcode="fake", epc_client=Mock())
property_instance10.age_band = "F"
property_instance10.floor_area = 100
property_instance10.roof = {
'original_description': 'Roof room(s), limited insulation (assumed)',
'clean_description': 'Roof room(s), limited insulation',
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'below average'
}
property_instance10.pitched_roof_area = 110
roof_recommender10 = RoofRecommendations(
property_instance=property_instance10, materials=room_roof_insulation_materials
)
assert not roof_recommender10.recommendations
roof_recommender10.recommend()
assert len(roof_recommender10.recommendations) == 2
assert roof_recommender10.recommendations[0]["parts"][0]["depths"] == [220]
assert roof_recommender10.recommendations[1]["parts"][0]["depths"] == [270]
assert roof_recommender10.recommendations[0]["new_u_value"] == 0.16
assert roof_recommender10.recommendations[1]["new_u_value"] == 0.14
assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.8
assert roof_recommender10.recommendations[1]["starting_u_value"] == 0.8
assert roof_recommender10.recommendations[0]["description"] == \
"Insulate your room roof with 220mm of Example room roof insulation"
assert roof_recommender10.recommendations[1]["description"] == \
"Insulate your room roof with 270mm of Example room roof insulation"
def test_flat_no_insulation(self):
property_instance11 = Property(id=11, address1="fake", postcode="fake", epc_client=Mock())
property_instance11.age_band = "D"
property_instance11.floor_area = 150
property_instance11.roof = {
'original_description': 'Flat, no insulation (assumed)',
'clean_description': 'Flat, no insulation',
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False,
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
}
roof_recommender11 = RoofRecommendations(
property_instance=property_instance11, materials=flat_roof_insulation_materials
)
assert not roof_recommender11.recommendations
roof_recommender11.recommend()
assert len(roof_recommender11.recommendations) == 1
assert roof_recommender11.recommendations[0]["parts"][0]["depths"] == [270]
assert roof_recommender11.recommendations[0]["new_u_value"] == 0.11
assert roof_recommender11.recommendations[0]["starting_u_value"] == 2.3
assert roof_recommender11.recommendations[0]["description"] == \
"Insulate the home's flat roof with 270mm of Example flat roof insulation"
def test_flat_insulated(self):
property_instance12 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock())
property_instance12.age_band = "D"
property_instance12.floor_area = 150
property_instance12.roof = {
'original_description': 'Flat, insulated (assumed)',
'clean_description': 'Flat, insulated',
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
'is_roof_room': False,
'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
}
roof_recommender12 = RoofRecommendations(
property_instance=property_instance12, materials=flat_roof_insulation_materials
)
assert not roof_recommender12.recommendations
roof_recommender12.recommend()
assert not roof_recommender12.recommendations
def test_flat_limited_insulation(self):
property_instance13 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock())
property_instance13.age_band = "D"
property_instance13.floor_area = 150
property_instance13.roof = {
'original_description': 'Flat, limited insulation (assumed)',
'clean_description': 'Flat, limited insulation',
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
'is_roof_room': False,
'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average'
}
roof_recommender13 = RoofRecommendations(
property_instance=property_instance13, materials=flat_roof_insulation_materials
)
assert not roof_recommender13.recommendations
roof_recommender13.recommend()
assert len(roof_recommender13.recommendations) == 1
assert roof_recommender13.recommendations[0]["parts"][0]["depths"] == [220]
assert roof_recommender13.recommendations[0]["new_u_value"] == 0.14
assert roof_recommender13.recommendations[0]["starting_u_value"] == 2.3
assert roof_recommender13.recommendations[0]["description"] == \
"Insulate the home's flat roof with 220mm of Example flat roof insulation"
# def test_uninsulated_room_in_roof(self):
# property_instance7 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
# property_instance7.age_band = "F"
# property_instance7.insulation_floor_area = 100
# property_instance7.roof = {
# 'original_description': 'Roof room(s), no insulation (assumed)',
# 'clean_description': 'Roof room(s), no insulation',
# 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
# 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
# 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
# }
#
# property_instance7.pitched_roof_area = 110
# property_instance7.data = {"county": "Southampton"}
#
# roof_recommender7 = RoofRecommendations(property_instance=property_instance7, materials=materials)
#
# assert not roof_recommender7.recommendations
#
# roof_recommender7.recommend()
#
# # Even though we have 3 depths, we only end with 1 due to diminishin returns
# assert len(roof_recommender7.recommendations) == 1
#
# assert roof_recommender7.recommendations[0]["parts"][0]["depths"] == [270]
#
# assert roof_recommender7.recommendations[0]["new_u_value"] == 0.14
# assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8
# assert roof_recommender7.recommendations[0]["description"] == \
# "Insulate your room roof with 270mm of Example room roof insulation"
#
# def test_ceiling_insulated_room_in_roof(self):
# property_instance8 = Property(id=8, address1="fake", postcode="fake", epc_client=Mock())
# property_instance8.age_band = "F"
# property_instance8.insulation_floor_area = 100
# property_instance8.roof = {
# 'original_description': 'Roof room(s), ceiling insulated',
# 'clean_description': 'Roof room(s), ceiling insulated',
# 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
# 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
# 'is_at_rafters': False,
# 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
# 'insulation_thickness': 'average'
# }
#
# property_instance8.pitched_roof_area = 110
#
# roof_recommender8 = RoofRecommendations(property_instance=property_instance8, materials=materials)
#
# assert not roof_recommender8.recommendations
#
# roof_recommender8.recommend()
#
# # No recommendations in this case
# assert not roof_recommender8.recommendations
#
# def test_insulated_room_in_roof(self):
# property_instance9 = Property(id=9, address1="fake", postcode="fake", epc_client=Mock())
# property_instance9.age_band = "F"
# property_instance9.insulation_floor_area = 100
# property_instance9.roof = {
# 'original_description': 'Roof room(s), insulated (assumed)',
# 'clean_description': 'Roof room(s), insulated',
# 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
# 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
# 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
# }
#
# property_instance9.pitched_roof_area = 110
# property_instance9.data = {"county": "Rutland"}
#
# roof_recommender9 = RoofRecommendations(property_instance=property_instance9, materials=materials)
#
# assert not roof_recommender9.recommendations
#
# roof_recommender9.recommend()
#
# # No recommendations in this case
# assert not roof_recommender9.recommendations
#
# def test_limited_insulated_room_in_roof(self):
# property_instance10 = Property(id=10, address1="fake", postcode="fake", epc_client=Mock())
# property_instance10.age_band = "F"
# property_instance10.insulation_floor_area = 100
# property_instance10.roof = {
# 'original_description': 'Roof room(s), limited insulation (assumed)',
# 'clean_description': 'Roof room(s), limited insulation',
# 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
# 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
# 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
# 'insulation_thickness': 'below average'
# }
#
# property_instance10.pitched_roof_area = 110
# property_instance10.data = {"county": "Westmorland"}
#
# roof_recommender10 = RoofRecommendations(property_instance=property_instance10, materials=materials)
#
# assert not roof_recommender10.recommendations
#
# roof_recommender10.recommend()
#
# assert len(roof_recommender10.recommendations) == 2
#
# assert roof_recommender10.recommendations[0]["parts"][0]["depths"] == [220]
# assert roof_recommender10.recommendations[1]["parts"][0]["depths"] == [270]
#
# assert roof_recommender10.recommendations[0]["new_u_value"] == 0.16
# assert roof_recommender10.recommendations[1]["new_u_value"] == 0.14
#
# assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.8
# assert roof_recommender10.recommendations[1]["starting_u_value"] == 0.8
#
# assert roof_recommender10.recommendations[0]["description"] == \
# "Insulate your room roof with 220mm of Example room roof insulation"
# assert roof_recommender10.recommendations[1]["description"] == \
# "Insulate your room roof with 270mm of Example room roof insulation"
#
# def test_flat_no_insulation(self):
# property_instance11 = Property(id=11, address1="fake", postcode="fake", epc_client=Mock())
# property_instance11.age_band = "D"
# property_instance11.insulation_floor_area = 150
# property_instance11.roof = {
# 'original_description': 'Flat, no insulation (assumed)',
# 'clean_description': 'Flat, no insulation',
# 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
# 'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False,
# 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
# }
# property_instance11.data = {"county": "Swindon"}
#
# roof_recommender11 = RoofRecommendations(property_instance=property_instance11, materials=materials)
#
# assert not roof_recommender11.recommendations
#
# roof_recommender11.recommend()
#
# assert len(roof_recommender11.recommendations) == 1
#
# assert roof_recommender11.recommendations[0]["parts"][0]["depths"] == [270]
#
# assert roof_recommender11.recommendations[0]["new_u_value"] == 0.11
#
# assert roof_recommender11.recommendations[0]["starting_u_value"] == 2.3
#
# assert roof_recommender11.recommendations[0]["description"] == \
# "Insulate the home's flat roof with 270mm of Example flat roof insulation"
#
# def test_flat_insulated(self):
# property_instance12 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock())
# property_instance12.age_band = "D"
# property_instance12.insulation_floor_area = 150
# property_instance12.roof = {
# 'original_description': 'Flat, insulated (assumed)',
# 'clean_description': 'Flat, insulated',
# 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
# 'is_roof_room': False,
# 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
# 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
# }
# property_instance12.data = {"county": "Thurrock"}
#
# roof_recommender12 = RoofRecommendations(property_instance=property_instance12, materials=materials)
#
# assert not roof_recommender12.recommendations
#
# roof_recommender12.recommend()
#
# assert not roof_recommender12.recommendations
#
# def test_flat_limited_insulation(self):
# property_instance13 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock())
# property_instance13.age_band = "D"
# property_instance13.insulation_floor_area = 150
# property_instance13.roof = {
# 'original_description': 'Flat, limited insulation (assumed)',
# 'clean_description': 'Flat, limited insulation',
# 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
# 'is_roof_room': False,
# 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
# 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average'
# }
# property_instance13.data = {"county": "Tyne and Wear"}
#
# roof_recommender13 = RoofRecommendations(property_instance=property_instance13, materials=materials)
#
# assert not roof_recommender13.recommendations
#
# roof_recommender13.recommend()
#
# assert len(roof_recommender13.recommendations) == 1
#
# assert roof_recommender13.recommendations[0]["parts"][0]["depths"] == [220]
#
# assert roof_recommender13.recommendations[0]["new_u_value"] == 0.14
#
# assert roof_recommender13.recommendations[0]["starting_u_value"] == 2.3
#
# assert roof_recommender13.recommendations[0]["description"] == \
# "Insulate the home's flat roof with 220mm of Example flat roof insulation"
def test_property_above(self):
property_instance14 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance14.age_band = "F"
property_instance14.floor_area = 100
property_instance14.insulation_floor_area = 100
property_instance14.roof = {
'original_description': '(other premises above)',
'clean_description': '(other premises above)', 'thermal_transmittance': 0,
@ -440,10 +372,9 @@ class TestRoofRecommendations:
'is_assumed': False, 'has_dwelling_above': True, 'is_valid': True,
'insulation_thickness': None
}
property_instance14.data = {"county": "Suffolk"}
roof_recommender14 = RoofRecommendations(
property_instance=property_instance14, materials=loft_insulation_materials
)
roof_recommender14 = RoofRecommendations(property_instance=property_instance14, materials=materials)
assert not roof_recommender14.recommendations

View file

@ -1,15 +1,7 @@
from backend.Property import Property
from unittest.mock import Mock
from recommendations.VentilationRecommendations import VentilationRecommendations
ventilation_materials = [
{
'id': 17, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation',
'depths': None, 'depth_unit': None, 'cost': 500, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None,
'r_value_unit': None, 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
'link': None, 'is_active': True, 'estimated_cost': 1000, 'quantity': 2, 'quantity_unit': None
}
]
from recommendations.tests.test_data.materials import materials
class TestVentilationRecommendations:
@ -20,7 +12,7 @@ class TestVentilationRecommendations:
recommender = VentilationRecommendations(
property_instance=input_property1,
materials=ventilation_materials
materials=materials
)
assert not recommender.recommendation
@ -29,7 +21,7 @@ class TestVentilationRecommendations:
assert len(recommender.recommendation) == 1
assert recommender.recommendation[0]["cost"] == 1000
assert recommender.recommendation[0]["total"] == 1000
assert recommender.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender.recommendation[0]["parts"]) == 1
assert recommender.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
@ -41,7 +33,7 @@ class TestVentilationRecommendations:
recommender2 = VentilationRecommendations(
property_instance=input_property2,
materials=ventilation_materials
materials=materials
)
assert not recommender2.recommendation
@ -50,7 +42,7 @@ class TestVentilationRecommendations:
assert len(recommender2.recommendation) == 1
assert recommender2.recommendation[0]["cost"] == 1000
assert recommender2.recommendation[0]["total"] == 1000
assert recommender2.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender2.recommendation[0]["parts"]) == 1
assert recommender2.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
@ -62,7 +54,7 @@ class TestVentilationRecommendations:
recommender3 = VentilationRecommendations(
property_instance=input_property3,
materials=ventilation_materials
materials=materials
)
assert not recommender3.recommendation
@ -71,7 +63,7 @@ class TestVentilationRecommendations:
assert len(recommender3.recommendation) == 1
assert recommender3.recommendation[0]["cost"] == 1000
assert recommender3.recommendation[0]["total"] == 1000
assert recommender3.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender3.recommendation[0]["parts"]) == 1
assert recommender3.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
@ -83,7 +75,7 @@ class TestVentilationRecommendations:
recommender4 = VentilationRecommendations(
property_instance=input_property4,
materials=ventilation_materials
materials=materials
)
assert not recommender4.recommendation
@ -99,7 +91,7 @@ class TestVentilationRecommendations:
recommender5 = VentilationRecommendations(
property_instance=input_property5,
materials=ventilation_materials
materials=materials
)
assert not recommender5.recommendation

View file

@ -6,202 +6,14 @@ from unittest.mock import Mock, MagicMock
from recommendations.WallRecommendations import WallRecommendations
from backend.Property import Property
from recommendations.recommendation_utils import is_diminishing_returns
from recommendations.tests.test_data.materials import materials
# with open(
# os.path.abspath(os.path.dirname(__file__)) + "/recommendations/tests/test_data/input_properties.pkl", "rb"
# ) as f:
# input_properties = pickle.load(f)
external_wall_insulation_parts = [
{
# Example product
# https://insulationgo.co.uk/100mm-rockwool-external-wall-insulation-dual-density-slabs-a1-non-combustible
# -slab-ewi-render-fire/
"type": "external_wall_insulation",
"description": "Mineral Wool External Wall Insulation",
"depths": [30, 50, 70, 80, 90, 100, 150, 200],
"depth_unit": "mm",
"cost": [30, 50, 70, 80, 90, 100, 150, 200],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.0278,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.036,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.insulationking.co.uk/products/polystyrene-eps70?variant=44156186558759
"type": "external_wall_insulation",
"description": "Expanded Polystyrene External Wall Insulation",
"depths": [25, 50, 100, 125],
"depth_unit": "mm",
"cost": [25, 50, 100, 125],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.02703,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.037,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.insulationshop.co/20mm_kooltherm_k5_external_wall_kingspan.html
"type": "external_wall_insulation",
"description": "Phenolic Foam External Wall Insulation",
"depths": [20, 50, 100],
"depth_unit": "mm",
"cost": [20, 50, 100],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.043478260869565216,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.023,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
"type": "external_wall_insulation",
"description": "Polyisocyanurate/Polyurethane Foam External Wall Insulation",
"depths": [],
"depth_unit": "mm",
"cost": [],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": None,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": None,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.mikewye.co.uk/product/steico-duo-dry/
"type": "external_wall_insulation",
"description": "Wood Fiber External Wall Insulation",
"depths": [40, 60],
"depth_unit": "mm",
"cost": [40, 60],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.023255813953488375,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.043,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.thermablok.co.uk/site/wp-content/uploads/2022/09/Thermablok-Aerogel-Insulation-Blanket-TDS-AIS
# -and-Steel-Related-Details.pdf
"type": "external_wall_insulation",
"description": "Aerogel External Wall Insulation",
"depths": [10, 20, 30, 40, 50, 60, 70],
"depth_unit": "mm",
"cost": [10, 20, 30, 40, 50, 60, 70],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.06666666666666667,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.015,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
"type": "external_wall_insulation",
"description": "Vacuum Insulation Panels External Wall Insulation",
"depths": [45, 60],
"depth_unit": "mm",
"cost": [45, 60],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.16666666666666666,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.006,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
}
]
internal_wall_insulation_parts = [
{
# Example product
# https://www.insulationshop.co/25mm_polystyrene_insulation_eps_70jablite.html
"type": "internal_wall_insulation",
"description": "Rigid Insulation Boards Internal Wall Insulation",
"depths": [25, 40, 50, 75, 100],
"depth_unit": "mm",
"cost": [25, 40, 50, 75, 100],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.026315789473684213,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.038,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.rockwool.com/siteassets/rw-uk/downloads/datasheets/flexi.pdf
"type": "internal_wall_insulation",
"description": "Mineral Wool Internal Wall Insulation",
"depths": [140],
"depth_unit": "mm",
"cost": [140],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.02857142857142857,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.035,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.kingspan.com/gb/en/products/insulation-boards/wall-insulation-boards/kooltherm-k118-insulated
# -plasterboard/
"type": "internal_wall_insulation",
"description": "Insulated Plasterboard Internal Wall Insulation",
"depths": [25, 80],
"depth_unit": "mm",
"cost": [25, 80],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.02857142857142857,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.019,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
"type": "internal_wall_insulation",
"description": "Reflective Internal Wall Insulation",
"depths": [],
"depth_unit": "mm",
"cost": [],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": None,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": None,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.insulationsuperstore.co.uk/product/vacutherm-vacupor-nt-b2-vacuum-insulated-panel-1m-x-600mm-x
# -30mm.html
"type": "internal_wall_insulation",
"description": "Vacuum Insulation Panels Wall Insulation",
"depths": [20, 30],
"depth_unit": "mm",
"cost": [20, 30],
"cost_unit": "gbp_sq_meter",
"r_value_per_mm": 0.125,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.008,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
]
cavity_wall_insulation_parts = [
{'id': 4, 'type': 'cavity_wall_insulation', 'description': 'Example Material 1',
'depths': None,
'depth_unit': None, 'cost': 20,
'cost_unit': 'gbp_sq_meter', 'r_value_per_mm': 0.0278, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.036, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': None, 'created_at': None, 'is_active': True},
{'id': 10, 'type': "cavity_wall_insulation", 'description': 'Example Material 2',
'depths': None, 'depth_unit': None, 'cost': 25, 'cost_unit': 'gbp_sq_meter',
'r_value_per_mm': 0.02631579, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': None,
'created_at': None, 'is_active': True}
]
wall_parts = external_wall_insulation_parts + internal_wall_insulation_parts + cavity_wall_insulation_parts
class TestWallRecommendations:
@ -217,17 +29,20 @@ class TestWallRecommendations:
# Creating a mock instance of WallRecommendations with the necessary attributes
property_mock = Mock()
property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"} # or any date you want
property_mock.data = {"construction-age-band": "1950"} # or any other data that fits your tests
property_mock.data = {"construction-age-band": "1950",
"county": "Derbyshire"} # or any other data that fits your tests
mock_wall_rec_instance = WallRecommendations(
property_mock, materials=wall_parts
property_mock, materials=materials
)
return mock_wall_rec_instance
def test_init(self, input_properties):
input_properties[0].insulation_wall_area = 100
obj = WallRecommendations(
property_instance=input_properties[0],
materials=wall_parts
materials=materials
)
assert obj
assert obj.property
@ -244,10 +59,11 @@ class TestWallRecommendations:
input_properties[0].year_built = 2014
input_properties[0].in_conservation_area = None
input_properties[0].restricted_measures = False
input_properties[0].insulation_wall_area = 100
recommender = WallRecommendations(
property_instance=input_properties[0],
materials=wall_parts
materials=materials
)
assert recommender.property.walls["original_description"] == "Average thermal transmittance 0.16 W/m-¦K"
recommender.recommend()
@ -272,7 +88,7 @@ class TestWallRecommendations:
recommender = WallRecommendations(
property_instance=input_properties[1],
materials=wall_parts
materials=materials
)
assert recommender.property.walls["original_description"] == "Solid brick, as built, no insulation (assumed)"
assert not recommender.ewi_valid
@ -306,9 +122,11 @@ class TestWallRecommendations:
input_properties[6].year_built = 1991
input_properties[6].restricted_measures = False
input_properties[6].insulation_wall_area = 100
recommender = WallRecommendations(
property_instance=input_properties[6],
materials=wall_parts
materials=materials
)
assert recommender.property.walls["original_description"] == "Solid brick, as built, insulated (assumed)"
@ -383,12 +201,14 @@ class TestWallRecommendationsBase:
property_mock.full_sap_epc = {"lodgement-date": "1999-12-31"}
property_mock.in_conservation_area = "not_in_conservation_area"
property_mock.restricted_measures = False
property_mock.insulation_wall_area = 100
property_mock.data = {"county": "Derbyshire"}
return property_mock
@pytest.fixture
def wall_recommendations_instance(self, property_mock):
wall_recommendations_instance = WallRecommendations(
property_mock, materials=wall_parts
property_mock, materials=materials
)
return wall_recommendations_instance
@ -425,10 +245,11 @@ class TestCavityWallRecommensations:
}
input_property.age_band = "C"
input_property.insulation_wall_area = 50
input_property.data = {"county": "Derbyshire"}
recommender = WallRecommendations(
property_instance=input_property,
materials=cavity_wall_insulation_parts
materials=materials
)
assert not recommender.recommendations
@ -437,11 +258,11 @@ class TestCavityWallRecommensations:
assert recommender.recommendations
assert recommender.estimated_u_value == 1.5
assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.37)
assert np.isclose(recommender.recommendations[0]["cost"], 1000)
assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.35)
assert np.isclose(recommender.recommendations[0]["total"], 1668.6600000000003)
assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.38)
assert np.isclose(recommender.recommendations[1]["cost"], 1250)
assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.35)
assert np.isclose(recommender.recommendations[1]["total"], 2004.6600000000003)
def test_fill_partial_filled_cavity(self):
input_property = Property(id=1, postcode="F4k3", address1="123 fake street", epc_client=Mock())
@ -458,10 +279,11 @@ class TestCavityWallRecommensations:
}
input_property.age_band = "C"
input_property.insulation_wall_area = 50
input_property.data = {"county": "County Durham"}
recommender = WallRecommendations(
property_instance=input_property,
materials=cavity_wall_insulation_parts
materials=materials
)
assert not recommender.recommendations
@ -470,11 +292,11 @@ class TestCavityWallRecommensations:
assert recommender.recommendations
assert recommender.estimated_u_value == 1.3
assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.43)
assert np.isclose(recommender.recommendations[0]["cost"], 1000)
assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.41)
assert np.isclose(recommender.recommendations[0]["total"], 1663.9350000000002)
assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.45)
assert np.isclose(recommender.recommendations[1]["cost"], 1250)
assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.41)
assert np.isclose(recommender.recommendations[1]["total"], 1999.9350000000002)
def test_system_built_wall(self):
input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
@ -492,13 +314,13 @@ class TestCavityWallRecommensations:
input_property2.age_band = "F"
input_property2.insulation_wall_area = 120
input_property2.restricted_measures = False
input_property2.data = {"property-type": "house"}
input_property2.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Detached"}
assert input_property2.walls["is_system_built"]
recommender2 = WallRecommendations(
property_instance=input_property2,
materials=internal_wall_insulation_parts + external_wall_insulation_parts
materials=materials
)
assert not recommender2.recommendations
@ -506,22 +328,22 @@ class TestCavityWallRecommensations:
recommender2.recommend()
assert recommender2.recommendations
assert len(recommender2.recommendations) == 6
assert len(recommender2.recommendations) == 9
assert recommender2.estimated_u_value == 1
assert np.isclose(recommender2.recommendations[0]["new_u_value"], 0.29)
assert np.isclose(recommender2.recommendations[0]["cost"], 10800)
assert np.isclose(recommender2.recommendations[0]["new_u_value"], 0.19)
assert np.isclose(recommender2.recommendations[0]["total"], 15899.9616)
assert recommender2.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
assert recommender2.recommendations[0]["parts"][0]["depths"] == [90]
assert recommender2.recommendations[0]["parts"][0]["depth"] == 100
assert np.isclose(recommender2.recommendations[5]["new_u_value"], 0.29)
assert np.isclose(recommender2.recommendations[5]["cost"], 2400)
assert recommender2.recommendations[5]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender2.recommendations[5]["parts"][0]["depths"] == [20]
assert np.isclose(recommender2.recommendations[8]["new_u_value"], 0.23)
assert np.isclose(recommender2.recommendations[8]["total"], 10916.3424)
assert recommender2.recommendations[8]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender2.recommendations[8]["parts"][0]["depth"] == 72.5
assert np.isclose(recommender2.recommendations[3]["new_u_value"], 0.28)
assert np.isclose(recommender2.recommendations[3]["cost"], 4800)
assert recommender2.recommendations[3]["parts"][0]["type"] == "external_wall_insulation"
assert recommender2.recommendations[3]["parts"][0]["depths"] == [40]
assert np.isclose(recommender2.recommendations[6]["new_u_value"], 0.29)
assert np.isclose(recommender2.recommendations[6]["total"], 10621.934399999998)
assert recommender2.recommendations[6]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender2.recommendations[6]["parts"][0]["depth"] == 52.5
def test_timber_frame_wall(self):
input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
@ -539,13 +361,13 @@ class TestCavityWallRecommensations:
input_property3.age_band = "B"
input_property3.insulation_wall_area = 99
input_property3.restricted_measures = False
input_property3.data = {"property-type": "house"}
input_property3.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Semi-Detached"}
assert input_property3.walls["is_timber_frame"]
recommender3 = WallRecommendations(
property_instance=input_property3,
materials=internal_wall_insulation_parts + external_wall_insulation_parts
materials=materials
)
assert not recommender3.recommendations
@ -553,17 +375,17 @@ class TestCavityWallRecommensations:
recommender3.recommend()
assert recommender3.recommendations
assert len(recommender3.recommendations) == 2
assert len(recommender3.recommendations) == 6
assert recommender3.estimated_u_value == 1.9
assert np.isclose(recommender3.recommendations[0]["new_u_value"], 0.26)
assert np.isclose(recommender3.recommendations[0]["cost"], 12375)
assert np.isclose(recommender3.recommendations[0]["new_u_value"], 0.2)
assert np.isclose(recommender3.recommendations[0]["total"], 13117.46832)
assert recommender3.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
assert recommender3.recommendations[0]["parts"][0]["depths"] == [125]
assert recommender3.recommendations[0]["parts"][0]["depth"] == 100.0
assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.26)
assert np.isclose(recommender3.recommendations[1]["cost"], 4950)
assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.23)
assert np.isclose(recommender3.recommendations[1]["total"], 34070.50944)
assert recommender3.recommendations[1]["parts"][0]["type"] == "external_wall_insulation"
assert recommender3.recommendations[1]["parts"][0]["depths"] == [50]
assert recommender3.recommendations[1]["parts"][0]["depth"] == 150.0
def test_granite_or_whinstone_wall(self):
input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
@ -581,13 +403,13 @@ class TestCavityWallRecommensations:
input_property4.age_band = "A"
input_property4.insulation_wall_area = 223
input_property4.restricted_measures = False
input_property4.data = {"property-type": "Bungalow"}
input_property4.data = {"property-type": "Bungalow", "county": "Derbyshire", "built-form": "Detached"}
assert input_property4.walls["is_granite_or_whinstone"]
recommender4 = WallRecommendations(
property_instance=input_property4,
materials=internal_wall_insulation_parts + external_wall_insulation_parts
materials=materials
)
assert not recommender4.recommendations
@ -595,17 +417,17 @@ class TestCavityWallRecommensations:
recommender4.recommend()
assert recommender4.recommendations
assert len(recommender4.recommendations) == 2
assert len(recommender4.recommendations) == 6
assert recommender4.estimated_u_value == 2.3
assert np.isclose(recommender4.recommendations[0]["new_u_value"], 0.27)
assert np.isclose(recommender4.recommendations[0]["cost"], 27875)
assert np.isclose(recommender4.recommendations[0]["new_u_value"], 0.21)
assert np.isclose(recommender4.recommendations[0]["total"], 28562.514352)
assert recommender4.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
assert recommender4.recommendations[0]["parts"][0]["depths"] == [125]
assert recommender4.recommendations[0]["parts"][0]["depth"] == 100
assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.27)
assert np.isclose(recommender4.recommendations[1]["cost"], 11150)
assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.23)
assert np.isclose(recommender4.recommendations[1]["total"], 74186.52678400002)
assert recommender4.recommendations[1]["parts"][0]["type"] == "external_wall_insulation"
assert recommender4.recommendations[1]["parts"][0]["depths"] == [50]
assert recommender4.recommendations[1]["parts"][0]["depth"] == 150
def test_cob_wall(self):
input_property5 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
@ -623,13 +445,13 @@ class TestCavityWallRecommensations:
input_property5.age_band = "E"
input_property5.insulation_wall_area = 77
input_property5.restricted_measures = False
input_property5.data = {"property-type": "Bungalow"}
input_property5.data = {"property-type": "Bungalow", "county": "Derbyshire", "built-form": "Detached"}
assert input_property5.walls["is_cob"]
recommender5 = WallRecommendations(
property_instance=input_property5,
materials=internal_wall_insulation_parts + external_wall_insulation_parts
materials=materials
)
assert not recommender5.recommendations
@ -637,22 +459,17 @@ class TestCavityWallRecommensations:
recommender5.recommend()
assert recommender5.recommendations
assert len(recommender5.recommendations) == 9
assert len(recommender5.recommendations) == 5
assert recommender5.estimated_u_value == 0.8
assert np.isclose(recommender5.recommendations[0]["new_u_value"], 0.29)
assert np.isclose(recommender5.recommendations[0]["cost"], 6160)
assert np.isclose(recommender5.recommendations[0]["total"], 8665.040384000002)
assert recommender5.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
assert recommender5.recommendations[0]["parts"][0]["depths"] == [80]
assert recommender5.recommendations[0]["parts"][0]["depth"] == 50
assert np.isclose(recommender5.recommendations[3]["new_u_value"], 0.26)
assert np.isclose(recommender5.recommendations[3]["cost"], 7700)
assert recommender5.recommendations[3]["parts"][0]["type"] == "external_wall_insulation"
assert recommender5.recommendations[3]["parts"][0]["depths"] == [100]
assert np.isclose(recommender5.recommendations[6]["new_u_value"], 0.26)
assert np.isclose(recommender5.recommendations[6]["cost"], 7700)
assert recommender5.recommendations[6]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender5.recommendations[6]["parts"][0]["depths"] == [100]
assert np.isclose(recommender5.recommendations[3]["total"], 20078.742992)
assert recommender5.recommendations[3]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender5.recommendations[3]["parts"][0]["depth"] == 100
def test_sandstone_or_limestone_wall(self):
input_property6 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock())
@ -670,13 +487,13 @@ class TestCavityWallRecommensations:
input_property6.age_band = "F"
input_property6.insulation_wall_area = 350
input_property6.restricted_measures = False
input_property6.data = {"property-type": "House"}
input_property6.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Mid-Terrace"}
assert input_property6.walls["is_sandstone_or_limestone"]
recommender6 = WallRecommendations(
property_instance=input_property6,
materials=internal_wall_insulation_parts + external_wall_insulation_parts
materials=materials
)
assert not recommender6.recommendations
@ -684,19 +501,19 @@ class TestCavityWallRecommensations:
recommender6.recommend()
assert recommender6.recommendations
assert len(recommender6.recommendations) == 6
assert len(recommender6.recommendations) == 9
assert recommender6.estimated_u_value == 1
assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.29)
assert np.isclose(recommender6.recommendations[0]["cost"], 31500)
assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.19)
assert np.isclose(recommender6.recommendations[0]["total"], 44829.0584)
assert recommender6.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
assert recommender6.recommendations[0]["parts"][0]["depths"] == [90]
assert recommender6.recommendations[0]["parts"][0]["depth"] == 100
assert np.isclose(recommender6.recommendations[2]["new_u_value"], 0.28)
assert np.isclose(recommender6.recommendations[2]["cost"], 35000)
assert np.isclose(recommender6.recommendations[2]["new_u_value"], 0.21)
assert np.isclose(recommender6.recommendations[2]["total"], 116436.25280000002)
assert recommender6.recommendations[2]["parts"][0]["type"] == "external_wall_insulation"
assert recommender6.recommendations[2]["parts"][0]["depths"] == [100]
assert recommender6.recommendations[2]["parts"][0]["depth"] == 150
assert np.isclose(recommender6.recommendations[4]["new_u_value"], 0.28)
assert np.isclose(recommender6.recommendations[4]["cost"], 35000)
assert np.isclose(recommender6.recommendations[4]["total"], 91267.0136)
assert recommender6.recommendations[4]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender6.recommendations[4]["parts"][0]["depths"] == [100]
assert recommender6.recommendations[4]["parts"][0]["depth"] == 100