diff --git a/.idea/Model.iml b/.idea/Model.iml
index 4413bb06..b0f9c00d 100644
--- a/.idea/Model.iml
+++ b/.idea/Model.iml
@@ -7,7 +7,7 @@
-
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 6f308057..1122b380 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -3,7 +3,7 @@
-
+
diff --git a/backend/Property.py b/backend/Property.py
index a3328156..c04a3ed9 100644
--- a/backend/Property.py
+++ b/backend/Property.py
@@ -4,7 +4,7 @@ import os
import pandas as pd
from etl.epc.DataProcessor import DataProcessor
-from etl.epc.settings import POTENTIAL_COLUMNS, EFFICIENCY_FEATURES
+from etl.epc.settings import POTENTIAL_COLUMNS, EFFICIENCY_FEATURES, BUILT_FORM_REMAP
from etl.epc_clean.epc_attributes.all_cleaners import all_cleaner_map
from utils.logger import setup_logger
from utils.s3 import read_dataframe_from_s3_parquet
@@ -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
diff --git a/backend/app/config.py b/backend/app/config.py
index 40aef822..22621972 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -8,7 +8,9 @@ class Settings(BaseSettings):
SECRET_KEY: str
ENVIRONMENT: str
DATA_BUCKET: str
- PREDICTIONS_BUCKET: str
+ SAP_PREDICTIONS_BUCKET: str
+ CARBON_PREDICTIONS_BUCKET: str
+ HEAT_PREDICTIONS_BUCKET: str
PLAN_TRIGGER_BUCKET: str
EPC_AUTH_TOKEN: str
DB_HOST: str
diff --git a/backend/app/db/functions/portfolio_functions.py b/backend/app/db/functions/portfolio_functions.py
index 08e15a32..a8a882bd 100644
--- a/backend/app/db/functions/portfolio_functions.py
+++ b/backend/app/db/functions/portfolio_functions.py
@@ -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()
diff --git a/backend/app/db/functions/property_functions.py b/backend/app/db/functions/property_functions.py
index ecad3ab7..93dc0c49 100644
--- a/backend/app/db/functions/property_functions.py
+++ b/backend/app/db/functions/property_functions.py
@@ -3,13 +3,15 @@
###
import datetime
import pytz
+from sqlalchemy.orm import Session
from backend.app.db.models.portfolio import (
- PropertyModel, PropertyCreationStatus, PortfolioStatus, PropertyTargetsModel, PropertyDetailsEpcModel
+ PropertyModel, PropertyCreationStatus, PortfolioStatus, PropertyTargetsModel, PropertyDetailsEpcModel,
+ PropertyDetailsSpatial
)
from sqlalchemy.orm.exc import NoResultFound
-def create_property(session, portfolio_id: int, address: str, postcode: str) -> (int, bool):
+def create_property(session: Session, portfolio_id: int, address: str, postcode: str) -> (int, bool):
"""
This function will create a record for the property in the database if it does not exist.
If it does exist, it will just update the updated_at field.
@@ -55,7 +57,9 @@ def create_property(session, portfolio_id: int, address: str, postcode: str) ->
return new_property.id, True
-def create_property_targets(session, property_id: int, portfolio_id: int, epc_target=None, heat_demand_target=None):
+def create_property_targets(
+ session: Session, property_id: int, portfolio_id: int, epc_target=None, heat_demand_target=None
+):
"""
This function will create a record for the property targets in the database if it does not exist.
:param session: The database session
@@ -78,7 +82,9 @@ def create_property_targets(session, property_id: int, portfolio_id: int, epc_ta
return True
-def update_property_data(session, property_id: int, portfolio_id: int, property_data: dict):
+def update_property_data(
+ session: Session, property_id: int, portfolio_id: int, property_data: dict
+):
now = datetime.datetime.now(pytz.utc)
try:
@@ -103,7 +109,9 @@ def update_property_data(session, property_id: int, portfolio_id: int, property_
return True
-def create_property_details_epc(session, property_details_epc: dict):
+def create_property_details_epc(
+ session: Session, property_details_epc: dict
+):
"""
This function will create or update a record for the property details EPC in the database.
:param session: The database session
@@ -128,3 +136,36 @@ def create_property_details_epc(session, property_details_epc: dict):
session.flush()
return True
+
+
+def update_or_create_property_spatial_details(session: Session, uprn: int, property_details_spatial: dict):
+ """
+ Update an existing property details record or create a new one based on the UPRN.
+
+ :param session: The SQLAlchemy session for database interaction.
+ :param uprn: The unique property reference number (UPRN) of the property.
+ :param property_details_spatial: A dictionary containing the spatial property details to store or update.
+ :return: True if the operation is successful, otherwise raises an exception.
+ """
+
+ try:
+ # Attempt to fetch the existing property details
+ existing_property_details = session.query(PropertyDetailsSpatial).filter_by(
+ uprn=uprn
+ ).one()
+
+ # Update the fields with the data in property_details
+ for key, value in property_details_spatial.items():
+ setattr(existing_property_details, key, value)
+
+ # Merge the updated property details back into the session and flush
+ session.merge(existing_property_details)
+ session.flush()
+
+ except NoResultFound:
+ # Create a new record if not found
+ new_property_details = PropertyDetailsSpatial(uprn=uprn, **property_details_spatial)
+ session.add(new_property_details)
+ session.flush()
+
+ return True
diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py
index 34c4ef96..f7fcb7a3 100644
--- a/backend/app/db/functions/recommendations_functions.py
+++ b/backend/app/db/functions/recommendations_functions.py
@@ -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
]
diff --git a/backend/app/db/models/materials.py b/backend/app/db/models/materials.py
index e191c5ee..64c5e166 100644
--- a/backend/app/db/models/materials.py
+++ b/backend/app/db/models/materials.py
@@ -32,6 +32,7 @@ class MaterialType(enum.Enum):
ewi_wall_demolition = "ewi_wall_demolition"
ewi_wall_preparation = "ewi_wall_preparation"
ewi_wall_redecoration = "ewi_wall_redecoration"
+ low_energy_lighting_installation = "low_energy_lighting_installation"
class DepthUnit(enum.Enum):
diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py
index 8279a978..6f865381 100644
--- a/backend/app/db/models/portfolio.py
+++ b/backend/app/db/models/portfolio.py
@@ -42,6 +42,7 @@ class Portfolio(Base):
property_valuation_increase = Column(Float) # Unit is always £ so we don't need to store the unit for the moment
rental_yield_increase = Column(Float) # Unit is always £ so we don't need to store the unit for the moment
total_work_hours = Column(Float)
+ labour_days = Column(Float)
created_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
updated_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
@@ -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):
diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py
index 5515b90d..ff7aa642 100644
--- a/backend/app/db/models/recommendations.py
+++ b/backend/app/db/models/recommendations.py
@@ -28,6 +28,7 @@ class Recommendation(Base):
property_valuation_increase = Column(Float)
rental_yield_increase = Column(Float)
total_work_hours = Column(Float)
+ labour_days = Column(Float)
class RecommendationMaterials(Base):
diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py
index a20369cc..42014bb3 100644
--- a/backend/app/plan/router.py
+++ b/backend/app/plan/router.py
@@ -1,5 +1,6 @@
from datetime import datetime
+import numpy as np
import pandas as pd
from epc_api.client import EpcClient
from fastapi import APIRouter, Depends
@@ -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()
diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py
index 20b5db5b..7aba99c9 100644
--- a/backend/app/plan/utils.py
+++ b/backend/app/plan/utils.py
@@ -8,25 +8,6 @@ from backend.app.config import get_settings
import msgpack
-def insert_temp_recommendation_id(property_recommendations):
- """
- Creates a temporary recommendation id which is needed for
- filtering recommendations between default and no, after the optimiser has been
- run
- :param property_recommendations: nested list of recommendations, grouped by data_types
- :return: Updated recommendations_to_upload, where where recommendation has a "recommendation_id"
- integer inserted
- """
- idx = 0
-
- for recs in property_recommendations:
- for rec in recs:
- rec["recommendation_id"] = idx
- idx += 1
-
- return property_recommendations
-
-
def get_cleaned():
"""
This function will retrieve the cleaned dataset from s3 which has the cleaned
@@ -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")
diff --git a/backend/app/utils.py b/backend/app/utils.py
index b4ba1bb9..d912a94a 100644
--- a/backend/app/utils.py
+++ b/backend/app/utils.py
@@ -69,10 +69,10 @@ def generate_api_key():
return api_key
-def sap_to_epc(sap_points: int):
+def sap_to_epc(sap_points: int | float):
"""
Simple utility function to convert SAP points to EPC rating.
- :param sapPoints: numerical value of SAP points, typically between 0 and 100
+ :param sap_points: numerical value of SAP points, typically between 0 and 100
:return:
"""
diff --git a/backend/ml_models/AnnualBillSavings.py b/backend/ml_models/AnnualBillSavings.py
new file mode 100644
index 00000000..1519a866
--- /dev/null
+++ b/backend/ml_models/AnnualBillSavings.py
@@ -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
diff --git a/backend/ml_models/Valuation.py b/backend/ml_models/Valuation.py
new file mode 100644
index 00000000..ad296409
--- /dev/null
+++ b/backend/ml_models/Valuation.py
@@ -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
diff --git a/backend/ml_models/sap_change_model/__init__.py b/backend/ml_models/__init__.py
similarity index 100%
rename from backend/ml_models/sap_change_model/__init__.py
rename to backend/ml_models/__init__.py
diff --git a/backend/ml_models/api.py b/backend/ml_models/api.py
new file mode 100644
index 00000000..e6947906
--- /dev/null
+++ b/backend/ml_models/api.py
@@ -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
diff --git a/backend/ml_models/sap_change_model/api.py b/backend/ml_models/sap_change_model/api.py
deleted file mode 100644
index 2eb7d706..00000000
--- a/backend/ml_models/sap_change_model/api.py
+++ /dev/null
@@ -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
diff --git a/backend/tests/test_property.py b/backend/tests/test_property.py
index b376db9e..d8519b6b 100644
--- a/backend/tests/test_property.py
+++ b/backend/tests/test_property.py
@@ -12,6 +12,7 @@ mock_epc_response = {
"uprn": 1,
"number-habitable-rooms": 5,
"property-type": "House",
+ "built-form": "Detached",
"inspection-date": "2023-06-01",
'lodgement-datetime': '2023-06-01 20:29:01',
"some-other-key": "some-value",
@@ -42,6 +43,7 @@ mock_epc_response = {
"uprn": 2,
"number-habitable-rooms": 5,
"property-type": "House",
+ "built-form": "Detached",
"inspection-date": "2023-05-01",
'lodgement-datetime': '2023-05-01 20:29:01',
"some-other-key": "some-other-value",
diff --git a/backend/tests/test_sap_model_prep.py b/backend/tests/test_sap_model_prep.py
index 887e8e6e..f20e4993 100644
--- a/backend/tests/test_sap_model_prep.py
+++ b/backend/tests/test_sap_model_prep.py
@@ -204,9 +204,9 @@ class TestSapModelPrep:
'external_insulation': False, 'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 0.7,
'is_park_home_ENDING': False, 'walls_insulation_thickness_ENDING': 'average',
'external_insulation_ENDING': False, 'internal_insulation_ENDING': False,
- 'floor_thermal_transmittance': 0.64, 'is_to_unheated_space': False, 'is_to_external_air': False,
+ 'floor_thermal_transmittance': 0.52, 'is_to_unheated_space': False, 'is_to_external_air': False,
'is_suspended': True, 'is_solid': False, 'another_property_below': False,
- 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.64,
+ 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.52,
'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 1.5, 'is_pitched': True,
'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'has_dwelling_above': False, 'roof_insulation_thickness': 'below average',
@@ -260,7 +260,7 @@ class TestSapModelPrep:
'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown',
'fuel_type_ENDING': 'oil', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False,
'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown',
- 'estimated_perimeter_STARTING': 44.77882152472145, 'estimated_perimeter_ENDING': 44.77882152472145,
+ 'estimated_perimeter_STARTING': 30.531014675946444, 'estimated_perimeter_ENDING': 30.531014675946444,
'HOT_WATER_ENERGY_EFF_STARTING': "Good",
"FLOOR_ENERGY_EFF_STARTING": "Unknown",
"WINDOWS_ENERGY_EFF_STARTING": "Good",
@@ -310,7 +310,7 @@ class TestSapModelPrep:
recommendation = {
"recommendation_id": 0,
"new_u_value": 0.7,
- "type": "wall_insulation"
+ "type": "cavity_wall_insulation"
}
test_record = create_recommendation_scoring_data(
@@ -356,7 +356,7 @@ class TestSapModelPrep:
assert test_record[c].values[0] == row[c]
- def test_solid_wall_insulation(self, cleaned, cleaning_data):
+ def test_internal_wall_insulation(self, cleaned, cleaning_data):
starting_epc2 = {
'low-energy-fixed-light-count': '2', 'address': 'FLAT 12, WAREHOUSE W, 3 WESTERN GATEWAY',
@@ -513,6 +513,7 @@ class TestSapModelPrep:
data=starting_epc2
)
home2.get_components(cleaned)
+ home2.set_number_lighting_outlets(None)
data_processor2 = DataProcessor(None, newdata=True)
data_processor2.insert_data(pd.DataFrame([home2.get_model_data()]))
@@ -530,7 +531,7 @@ class TestSapModelPrep:
recommendation2 = {
"recommendation_id": 0,
"new_u_value": 0.21,
- "type": "wall_insulation"
+ "type": "internal_wall_insulation"
}
test_record2 = create_recommendation_scoring_data(
@@ -644,9 +645,9 @@ class TestSapModelPrep:
'is_park_home': False, 'walls_insulation_thickness': 'none', 'external_insulation': False,
'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 2.0, 'is_park_home_ENDING': False,
'walls_insulation_thickness_ENDING': 'none', 'external_insulation_ENDING': False,
- 'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.62, 'is_to_unheated_space': False,
+ 'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.51, 'is_to_unheated_space': False,
'is_to_external_air': False, 'is_suspended': True, 'is_solid': False, 'another_property_below': False,
- 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.62,
+ 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.51,
'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 2.3, 'is_pitched': True,
'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'has_dwelling_above': False, 'roof_insulation_thickness': 'none', 'roof_thermal_transmittance_ENDING': 2.3,
@@ -699,7 +700,7 @@ class TestSapModelPrep:
'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown',
'fuel_type_ENDING': 'mains gas', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False,
'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown',
- 'estimated_perimeter_STARTING': 41.634120622393354, 'estimated_perimeter_ENDING': 41.634120622393354,
+ 'estimated_perimeter_STARTING': 30.06908711617298, 'estimated_perimeter_ENDING': 30.06908711617298,
'HOT_WATER_ENERGY_EFF_STARTING': "Good",
"FLOOR_ENERGY_EFF_STARTING": "Unknown",
"WINDOWS_ENERGY_EFF_STARTING": "Average",
@@ -732,6 +733,7 @@ class TestSapModelPrep:
data=starting_epc3
)
home3.get_components(cleaned)
+ home3.set_number_lighting_outlets(None)
data_processor3 = DataProcessor(None, newdata=True)
data_processor3.insert_data(pd.DataFrame([home3.get_model_data()]))
@@ -851,9 +853,9 @@ class TestSapModelPrep:
'is_park_home': False, 'walls_insulation_thickness': 'none', 'external_insulation': False,
'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 1.7, 'is_park_home_ENDING': False,
'walls_insulation_thickness_ENDING': 'none', 'external_insulation_ENDING': False,
- 'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.66, 'is_to_unheated_space': False,
+ 'internal_insulation_ENDING': False, 'floor_thermal_transmittance': 0.53, 'is_to_unheated_space': False,
'is_to_external_air': False, 'is_suspended': False, 'is_solid': True, 'another_property_below': False,
- 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.66,
+ 'floor_insulation_thickness': 'none', 'floor_thermal_transmittance_ENDING': 0.53,
'floor_insulation_thickness_ENDING': 'none', 'roof_thermal_transmittance': 0.21, 'is_pitched': True,
'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'has_dwelling_above': False, 'roof_insulation_thickness': '200', 'roof_thermal_transmittance_ENDING': 0.21,
@@ -907,7 +909,7 @@ class TestSapModelPrep:
'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown',
'fuel_type_ENDING': 'mains gas', 'main-fuel_tariff_type_ENDING': 'Unknown', 'is_community_ENDING': False,
'no_individual_heating_or_community_network_ENDING': False, 'complex_fuel_type_ENDING': 'Unknown',
- 'estimated_perimeter_STARTING': 37.54197650630557, 'estimated_perimeter_ENDING': 37.54197650630557,
+ 'estimated_perimeter_STARTING': 27.113649698998472, 'estimated_perimeter_ENDING': 27.113649698998472,
'HOT_WATER_ENERGY_EFF_STARTING': "Good",
"FLOOR_ENERGY_EFF_STARTING": "Unknown",
"WINDOWS_ENERGY_EFF_STARTING": "Average",
@@ -940,6 +942,7 @@ class TestSapModelPrep:
data=starting_epc4
)
home4.get_components(cleaned)
+ home4.set_number_lighting_outlets(None)
data_processor4 = DataProcessor(None, newdata=True)
data_processor4.insert_data(pd.DataFrame([home4.get_model_data()]))
diff --git a/etl/costs/app.py b/etl/costs/app.py
index 1ecbbb5f..98a324bc 100644
--- a/etl/costs/app.py
+++ b/etl/costs/app.py
@@ -73,6 +73,7 @@ def app():
suspended_floor_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="suspended_floor_insulation", header=0)
solid_floor_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="solid_floor_insulation", header=0)
ewi_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="external_wall_insulation", header=0)
+ lel_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="low_energy_lighting", header=0)
# Form a single table to be uploaded
costs = pd.concat(
@@ -83,6 +84,7 @@ def app():
suspended_floor_costs,
solid_floor_costs,
ewi_costs,
+ lel_costs
]
)
diff --git a/etl/testing_data/birmingham_pilot.py b/etl/testing_data/birmingham_pilot.py
new file mode 100644
index 00000000..ab39df7e
--- /dev/null
+++ b/etl/testing_data/birmingham_pilot.py
@@ -0,0 +1,79 @@
+"""
+This script will create an input csv for the recommendation engine and upload it to S3, which can be used for
+testing
+"""
+import os
+
+import numpy as np
+import pandas as pd
+from epc_api.client import EpcClient
+from utils.s3 import save_csv_to_s3
+
+FILE_SIZE = 5
+EPC_AUTH_TOKEN = os.getenv("EPC_AUTH_TOKEN", None)
+USER_ID = 8
+PORTFOLIO_ID = 54
+
+
+def app():
+ # For this dataset, we want 3 properties, all hourses. A mid-terrace, and end-terrace and a semi-detached
+
+ epc_client = EpcClient(auth_token=EPC_AUTH_TOKEN)
+
+ # Birmingham has a Local Authority Code of E08000025
+
+ # Let's take an EPC D property
+ example_1_reponse = epc_client.domestic.search(
+ params={
+ "local-authority": "E08000025",
+ "property-type": "house",
+ }
+ )
+
+ g_data = epc_client.domestic.search(params={"energy-band": "g"}, size=n_g)
+ f_data = epc_client.domestic.search(params={"energy-band": "f"}, size=n_f)
+ e_data = epc_client.domestic.search(params={"energy-band": "e"}, size=n_e)
+ d_data = epc_client.domestic.search(params={"energy-band": "d"}, size=n_d)
+ c_data = epc_client.domestic.search(params={"energy-band": "c"}, size=n_c)
+ b_data = epc_client.domestic.search(params={"energy-band": "b"}, size=n_b)
+ a_data = epc_client.domestic.search(params={"energy-band": "a"}, size=n_a)
+
+ # Combine the final data
+ final_data = (
+ g_data["rows"] + f_data["rows"] + e_data["rows"] + d_data["rows"] + c_data["rows"] + b_data["rows"]
+ + a_data["rows"]
+ )
+
+ # TODO: We also take homes with just a specific type of wall
+
+ final_data = [
+ x for x in final_data if ("cavity wall" in x["walls-description"].lower()) or (
+ "solid brick" in x["walls-description"].lower()
+ ) or ("average thermal transmittance" in x["walls-description"].lower())
+ ]
+
+ # TODO: For the moment, don't use park homes
+ final_csv_data = pd.DataFrame(
+ [{"address": x["address"], "postcode": x["postcode"], "Notes": None} for x
+ in final_data if
+ x["property-type"] not in ["Park home"]]
+ )
+
+ final_csv_data = pd.concat([starting_csv, final_csv_data]).reset_index(drop=True)
+
+ # Store the data in s3
+ filename = f"{USER_ID}/{PORTFOLIO_ID}/test_inputs.csv"
+ save_csv_to_s3(
+ dataframe=final_csv_data,
+ bucket_name="retrofit-plan-inputs-dev",
+ file_name=filename
+ )
+
+ body = {
+ "portfolio_id": str(PORTFOLIO_ID),
+ "housing_type": "Social",
+ "goal": "Increase EPC",
+ "goal_value": "B",
+ "trigger_file_path": filename
+ }
+ print(body)
diff --git a/recommendations/Costs.py b/recommendations/Costs.py
index a96e1215..02d26c14 100644
--- a/recommendations/Costs.py
+++ b/recommendations/Costs.py
@@ -1,27 +1,23 @@
import numpy as np
+from recommendations.county_to_region import county_to_region_map
-# This data comes from SPONs
+# This data comes from SPONs 2023
regional_labour_variations = [
- {"Region": "Outer London (Spon’s 2023)", "Adjustment_Factor": 1.00},
+ {"Region": "Outer London", "Adjustment_Factor": 1.00},
{"Region": "Inner London", "Adjustment_Factor": 1.05},
- {"Region": "South East", "Adjustment_Factor": 0.96},
- {"Region": "South West", "Adjustment_Factor": 0.90},
+ {"Region": "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
+ }
diff --git a/recommendations/FireplaceRecommendations.py b/recommendations/FireplaceRecommendations.py
index 30ab1ad2..c193b7ce 100644
--- a/recommendations/FireplaceRecommendations.py
+++ b/recommendations/FireplaceRecommendations.py
@@ -45,6 +45,7 @@ class FireplaceRecommendations(Definitions):
"sap_points": None,
"total": estimated_cost,
# Take a very basic estimate of 6 hours, multipled by the number of open fireplaces to seal
- "labour_hours": 6 * number_open_fireplaces
+ "labour_hours": 6 * number_open_fireplaces,
+ "labour_days": 6 * number_open_fireplaces / 8, # Assume 8 hour day
}
]
diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py
index 96b1356c..48245554 100644
--- a/recommendations/FloorRecommendations.py
+++ b/recommendations/FloorRecommendations.py
@@ -51,8 +51,9 @@ class FloorRecommendations(Definitions):
]
]
+ # For solid floor, we don't use materials that are too thick
self.solid_floor_insulation_materials = [
- part for part in materials if part["type"] == "solid_floor_insulation"
+ part for part in materials if part["type"] == "solid_floor_insulation" if float(part["depth"]) <= 75
]
self.solid_floor_non_insulation_materials = [
@@ -142,7 +143,20 @@ class FloorRecommendations(Definitions):
@staticmethod
def _make_floor_description(material):
- return f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation"
+
+ if material["type"] == "suspended_floor_insulation":
+ return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation in "
+ f"suspended floor")
+
+ if material["type"] == "solid_floor_insulation":
+ return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation on "
+ f"solid floor")
+
+ if material["type"] == "exposed_floor_insulation":
+ return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation in "
+ f"exposed floor")
+
+ raise ValueError("Invalid material type - implement me!")
def recommend_floor_insulation(self, u_value, insulation_materials, non_insulation_materials):
"""
@@ -194,7 +208,7 @@ class FloorRecommendations(Definitions):
cost_result=cost_result
),
],
- "type": "floor_insulation",
+ "type": material["type"],
"description": self._make_floor_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py
new file mode 100644
index 00000000..cd52bea9
--- /dev/null
+++ b/recommendations/LightingRecommendations.py
@@ -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
+ }
+ ]
diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py
new file mode 100644
index 00000000..a169b788
--- /dev/null
+++ b/recommendations/Recommendations.py
@@ -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
diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py
index 1bee1e8e..07eeb1e5 100644
--- a/recommendations/RoofRecommendations.py
+++ b/recommendations/RoofRecommendations.py
@@ -88,17 +88,20 @@ class RoofRecommendations:
raise NotImplementedError("Implement me")
@staticmethod
- def make_loft_insulation_description(material):
- return f"Install {int(material['depth'])}{material['depth_unit']} of {material['description']} in your loft"
+ def make_roof_insulation_description(material):
+ if material["type"] == "loft_insulation":
+ return f"Install {int(material['depth'])}{material['depth_unit']} of {material['description']} in your loft"
- @staticmethod
- def make_room_roof_insulation_description(material, depth):
- return f"Insulate your room roof with {depth}{material['depth_unit']} of {material['description']}"
+ if material["type"] == "flat_roof_insulation":
+ return (
+ f"Insulate the home's flat roof with {int(material['depth'])}{material['depth_unit']} of "
+ f"{material['description']}"
+ )
+ if material["type"] == "room_roof_insulation":
+ return (f"Insulate your room roof with {int(material['depth'])}{material['depth_unit']} of "
+ f"{material['description']}")
- @staticmethod
- def make_flat_roof_insulation_description(material):
- return (f"Insulate the home's flat roof "
- f"with {int(material['depth'])}{material['depth_unit']} of {material['description']}")
+ raise ValueError("Invalid material type")
def recommend_roof_insulation(
self, u_value, insulation_thickness, roof
@@ -182,9 +185,7 @@ class RoofRecommendations:
floor_area=self.property.insulation_floor_area,
material=material
)
- description = self.make_loft_insulation_description(material)
elif material["type"] == "flat_roof_insulation":
- description = self.make_flat_roof_insulation_description(material)
raise ValueError("COMPLETE ME")
else:
raise ValueError("Invalid material type")
@@ -199,8 +200,8 @@ class RoofRecommendations:
cost_result=cost_result
)
],
- "type": "roof_insulation",
- "description": description,
+ "type": material["type"],
+ "description": self.make_roof_insulation_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
@@ -297,7 +298,7 @@ class RoofRecommendations:
selected_total_cost=estimated_cost
)
],
- "type": "roof_insulation",
+ "type": "room_roof_insulation",
"description": self.make_room_roof_insulation_description(material, depth),
"starting_u_value": u_value,
"new_u_value": new_u_value,
diff --git a/recommendations/VentilationRecommendations.py b/recommendations/VentilationRecommendations.py
index 419029a3..6c61f27c 100644
--- a/recommendations/VentilationRecommendations.py
+++ b/recommendations/VentilationRecommendations.py
@@ -15,6 +15,9 @@ class VentilationRecommendations(Definitions):
'mechanical, supply and extract'
]
+ # We introduce a SAP limit, to prevent over-predicting the SAP impact of mechanical ventilation
+ SAP_LIMIT = 2
+
def __init__(
self,
property_instance: Property,
@@ -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
}
]
diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py
index acc74ead..6e2d64ec 100644
--- a/recommendations/WallRecommendations.py
+++ b/recommendations/WallRecommendations.py
@@ -218,8 +218,8 @@ class WallRecommendations(Definitions):
cost_result=cost_result
)
],
- "type": "wall_insulation",
- "description": f"Fill cavity with {material['description']}",
+ "type": "cavity_wall_insulation",
+ "description": self._make_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": None,
@@ -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:
diff --git a/recommendations/county_to_region.py b/recommendations/county_to_region.py
new file mode 100644
index 00000000..a881ea01
--- /dev/null
+++ b/recommendations/county_to_region.py
@@ -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',
+}
diff --git a/recommendations/optimiser/CostOptimiser.py b/recommendations/optimiser/CostOptimiser.py
index de5a9e11..622d5b47 100644
--- a/recommendations/optimiser/CostOptimiser.py
+++ b/recommendations/optimiser/CostOptimiser.py
@@ -9,6 +9,9 @@ class CostOptimiser:
This class is used to minimise cost, given a constrained minimum gain
"""
+ # We add an optional buffer to the minimum gain to allow for slack in the optimisation
+ BUFFER = 0.2
+
def __init__(self, components, min_gain):
self.components = components
self.min_gain = min_gain
@@ -20,6 +23,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")
diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py
index 1ba601a8..2854b298 100644
--- a/recommendations/tests/test_costs.py
+++ b/recommendations/tests/test_costs.py
@@ -19,7 +19,7 @@ class TestCosts:
"prime_cost": 5.17,
"material_cost": 5.62,
"labour_cost": 1.125,
- "labour_hours": 0.065
+ "labour_hours_per_unit": 0.065,
}
cwi_results = costs.cavity_wall_insulation(
@@ -27,10 +27,12 @@ class TestCosts:
material=cwi_material,
)
- assert cwi_results == {'total': 1027.0280465530302, 'subtotal': 855.8567054608585, 'vat': 171.1713410921717,
- 'contingency': 63.396792997100626, 'preliminaries': 63.396792997100626,
- 'material': 539.0166061175574, 'profit': 95.09518949565093,
- 'labour_hours': 6.234177828761786, 'labour_cost': 94.95132385344874}
+ assert cwi_results == {
+ 'total': 1065.0661223512907, 'subtotal': 887.5551019594088, 'vat': 177.51102039188177,
+ 'contingency': 63.396792997100626, 'preliminaries': 63.396792997100626, 'material': 539.0166061175574,
+ 'profit': 126.79358599420125, 'labour_hours': 6.234177828761786, 'labour_cost': 94.95132385344874,
+ 'labour_days': 0.38963611429761164
+ }
def test_loft_insulation(self):
mock_property = Mock()
@@ -46,7 +48,7 @@ class TestCosts:
"prime_cost": None,
"material_cost": 5.91938,
"labour_cost": 1.96,
- "labour_hours": 0.11
+ "labour_hours_per_unit": 0.11
}
loft_results = costs.loft_insulation(
@@ -54,10 +56,11 @@ class TestCosts:
material=loft_material,
)
- assert loft_results == {'total': 414.8496486, 'subtotal': 345.70804050000004, 'vat': 69.14160810000001,
- 'contingency': 25.608003000000004, 'preliminaries': 25.608003000000004,
- 'material': 198.29923000000002, 'profit': 38.4120045, 'labour_hours': 3.685,
- 'labour_cost': 57.7808}
+ assert loft_results == {
+ 'total': 430.21445040000003, 'subtotal': 358.512042, 'vat': 71.70240840000001,
+ 'contingency': 25.608003000000004, 'preliminaries': 25.608003000000004, 'material': 198.29923000000002,
+ 'profit': 51.21600600000001, 'labour_hours': 3.685, 'labour_cost': 57.7808, 'labour_days': 0.460625
+ }
def test_internal_wall_insulation(self):
mock_property = Mock()
@@ -171,11 +174,14 @@ class TestCosts:
non_insulation_materials=iwi_non_insulation_materials
)
- assert iwi_results == {'total': 6421.5484411659245, 'subtotal': 5351.29036763827, 'vat': 1070.258073527654,
- 'contingency': 573.3525393898148, 'preliminaries': 382.2350262598765,
- 'material': 1747.488000615996, 'profit': 573.3525393898148,
- 'labour_hours': 88.23759388401297, 'labour_days': 2.757424808875405,
- 'labour_cost': 1927.1602026551818}
+ assert iwi_results == {
+ 'total': 6650.889456921851, 'subtotal': 5542.407880768209, 'vat': 1108.4815761536418,
+ 'contingency': 573.3525393898148, 'preliminaries': 382.2350262598765,
+ 'material': 1747.488000615996,
+ 'profit': 764.470052519753, 'labour_hours': 88.23759388401297,
+ 'labour_days': 2.757424808875405,
+ 'labour_cost': 1927.1602026551818
+ }
def test_suspended_floor_insulation(self):
mock_property = Mock()
@@ -185,16 +191,18 @@ class TestCosts:
costs = Costs(mock_property)
- sus_floor_material = {'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll',
- 'depth': 140.0,
- 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.039,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0,
- 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1,
- 'plant_cost': 0,
- 'total_cost': 13.46, 'link': 'SPONs',
- 'Notes': 'Spons did not contain labour costs so we use values for similar insulations. '
- 'We use the '
- 'same values as in Crown loft roll 44, since it is also an insulation roll'}
+ sus_floor_material = {
+ 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll',
+ 'depth': 140.0,
+ 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0,
+ 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1,
+ 'plant_cost': 0,
+ 'total_cost': 13.46, 'link': 'SPONs',
+ 'Notes': 'Spons did not contain labour costs so we use values for similar insulations. '
+ 'We use the '
+ 'same values as in Crown loft roll 44, since it is also an insulation roll'
+ }
sus_floor_non_insulation_materials = [
{'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0,
@@ -231,9 +239,8 @@ class TestCosts:
)
assert sus_floor_results == {
- 'total': 3003.366924, 'subtotal': 2502.80577, 'vat': 500.561154,
- 'contingency': 185.39302, 'preliminaries': 185.39302, 'material': 483.405,
- 'profit': 278.08952999999997, 'labour_hours': 54.940000000000005,
+ 'total': 3114.6027360000003, 'subtotal': 2595.50228, 'vat': 519.100456, 'contingency': 185.39302,
+ 'preliminaries': 185.39302, 'material': 483.405, 'profit': 370.78604, 'labour_hours': 54.940000000000005,
'labour_days': 2.289166666666667, 'labour_cost': 1370.5252
}
@@ -263,28 +270,29 @@ class TestCosts:
'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14,
- 'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, {'type': 'solid_floor_preparation',
- 'description': 'Clean out crack to '
- 'form a 20mm×20mm '
- 'groove and fill with '
- 'cement: mortar mixed '
- 'with bonding agent',
- 'depth': 0, 'depth_unit': 0,
- 'cost_unit': 0,
- 'thermal_conductivity': 0,
- 'thermal_conductivity_unit': 0,
- 'prime_material_cost': 0,
- 'material_cost': 6.91,
- 'labour_cost': 18.99,
- 'labour_hours_per_unit': 0.61,
- 'plant_cost': 0.16,
- 'total_cost': 26.06, 'link': 0,
- 'Notes': 'This step is the '
- 'assessment and repair of '
- 'any damage to the concrete '
- 'floor such as filling '
- 'cracks or levelling uneven '
- 'areas'},
+ 'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, {
+ 'type': 'solid_floor_preparation',
+ 'description': 'Clean out crack to '
+ 'form a 20mm×20mm '
+ 'groove and fill with '
+ 'cement: mortar mixed '
+ 'with bonding agent',
+ 'depth': 0, 'depth_unit': 0,
+ 'cost_unit': 0,
+ 'thermal_conductivity': 0,
+ 'thermal_conductivity_unit': 0,
+ 'prime_material_cost': 0,
+ 'material_cost': 6.91,
+ 'labour_cost': 18.99,
+ 'labour_hours_per_unit': 0.61,
+ 'plant_cost': 0.16,
+ 'total_cost': 26.06, 'link': 0,
+ 'Notes': 'This step is the '
+ 'assessment and repair of '
+ 'any damage to the concrete '
+ 'floor such as filling '
+ 'cracks or levelling uneven '
+ 'areas'},
{'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48,
@@ -316,8 +324,8 @@ class TestCosts:
)
assert sol_floor_results == {
- 'total': 3962.021952, 'subtotal': 3301.68496, 'vat': 660.336992, 'contingency': 353.75196,
- 'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 353.75196, 'labour_hours': 57.285,
+ 'total': 4245.023520000001, 'subtotal': 3537.5196, 'vat': 707.5039200000001, 'contingency': 471.66928,
+ 'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 471.66928, 'labour_hours': 57.285,
'labour_days': 2.386875, 'labour_cost': 1346.6464
}
@@ -331,11 +339,13 @@ class TestCosts:
costs = Costs(mock_property)
- ewi_material = {'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
- 'depth': 150.0, 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.022,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 23.53,
- 'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0,
- 'total_cost': 67.68, 'link': 'SPONs', 'Notes': 0}
+ ewi_material = {
+ 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
+ 'depth': 150.0, 'depth_unit': 'mm', 'cost_unit': 'gbp_per_m2', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 23.53,
+ 'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0,
+ 'total_cost': 67.68, 'link': 'SPONs', 'Notes': 0
+ }
ewi_non_insulation_materials = [
{'type': 'ewi_wall_demolition',
'description': 'Solid & Dry Lined walls: Hack of wall finishes with chipping '
@@ -403,9 +413,8 @@ class TestCosts:
)
assert ewi_results == {
- 'total': 13590.909723215433, 'subtotal': 11325.758102679527, 'vat': 2265.1516205359053,
- 'contingency': 808.9827216199662, 'preliminaries': 1213.4740824299492,
- 'material': 4020.565147410677, 'profit': 1213.4740824299492,
- 'labour_hours': 187.02533486285358, 'labour_days': 5.8445417144641745,
+ 'total': 14561.688989159393, 'subtotal': 12134.740824299493, 'vat': 2426.948164859899,
+ 'contingency': 808.9827216199662, 'preliminaries': 1617.9654432399325, 'material': 4020.565147410677,
+ 'profit': 1617.9654432399325, 'labour_hours': 187.02533486285358, 'labour_days': 5.8445417144641745,
'labour_cost': 3921.5600094613983
}
diff --git a/recommendations/tests/test_data/materials.py b/recommendations/tests/test_data/materials.py
new file mode 100644
index 00000000..c0f434a5
--- /dev/null
+++ b/recommendations/tests/test_data/materials.py
@@ -0,0 +1,835 @@
+import datetime
+
+materials = [
+ {'id': 17, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': None,
+ 'depth_unit': None, 'cost': 500, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': None,
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None,
+ 'created_at': datetime.datetime(2023, 10, 18, 16, 39, 9, 827188), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': None, 'labour_cost': None, 'labour_hours_per_unit': None, 'plant_cost': None, 'total_cost': None,
+ 'notes': None},
+ {'id': 1109, 'type': 'cavity_wall_insulation', 'description': 'Expanded Polystyrene Beads cavity wall insulation',
+ 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
+ 'link': 'https://www.styrene.co.uk/downloads/Datasheets/Stylite_Cavity_Loose_Fill_Insulation_Datasheet_v20211.pdf',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 18.875, 'labour_cost': 1.125, 'labour_hours_per_unit': 0.065, 'plant_cost': 0.0,
+ 'total_cost': 20.0,
+ 'notes': "It is hard to find materials online. To price this, we've used this article: "
+ "https://www.greenmatch.co.uk/blog/cavity-wall-insulation-cost It puts EPS beads at around £22 per "
+ "meter squared, blowing wool insulation at £18 per meter squared and Polyurethane Foam at £26 per meter "
+ "squared, when taking the most pessimistic prices. These rates have been used to adjust the price of "
+ "the mineral wool insulation to give us the other forms of insulation"},
+ {'id': 1110, 'type': 'cavity_wall_insulation', 'description': 'Injected Polyurthane Foam cavity wall insulation',
+ 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
+ 'link': 'https://www.foaminstall.co.uk/wp-content/uploads/2017/04/Lapolla-Cavity-Fill-BBA-certificate-sheet1.pdf',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 22.875, 'labour_cost': 1.125, 'labour_hours_per_unit': 0.065, 'plant_cost': 0.0,
+ 'total_cost': 24.0, 'notes': None},
+ {'id': 1111, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 100.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.03,
+ 'material_cost': 2.1, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.66,
+ 'notes': None},
+ {'id': 1112, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 150.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 3.06,
+ 'material_cost': 3.16, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.94,
+ 'notes': None},
+ {'id': 1113, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 170.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
+ 'link': 'https://insulation4less.co.uk/products/knauf-170mm-combi-cut?variant=31671561257013&dfw_tracker=77750'
+ '-31671561257013&utm_source=google&utm_medium=shopping&utm_campaign=shoptimised&gad_source=1&gclid'
+ '=CjwKCAiAx_GqBhBQEiwAlDNAZi1LiTWKVn0W1vktOYAPPQU3hss5Tq2qNn6GNhodCQoRD_tvqCLdxhoCKnIQAvD_BwE',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 3.81938, 'labour_cost': 1.71304, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0,
+ 'total_cost': 5.53242,
+ 'notes': "We don't have a 170mm in SPONs so the material cost is based on the fact that the 170mm insulation is "
+ "87.4% of the cost of the 200mm insulation"},
+ {'id': 1114, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 200.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.25,
+ 'material_cost': 4.37, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 6.33,
+ 'notes': None},
+ {'id': 1115, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 270.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 5.91938, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0,
+ 'total_cost': 7.87938, 'notes': 'This is the 100mm product + the 170mm product'},
+ {'id': 1116, 'type': 'loft_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll', 'depth': 300.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 6.47, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 8.43,
+ 'notes': 'This is the 100mm product + the 200mm product'},
+ {'id': 1117, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 100.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.99,
+ 'material_cost': 2.05, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.65,
+ 'notes': None},
+ {'id': 1118, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 150.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.96,
+ 'material_cost': 3.05, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.83,
+ 'notes': None},
+ {'id': 1119, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 170.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
+ 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-170mm-x-1160mm-x-7-03m-8-15m2/',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 3.8706238, 'labour_cost': 2.281361, 'labour_hours_per_unit': 0.12816635, 'plant_cost': 0.0,
+ 'total_cost': 6.1519847,
+ 'notes': "We don't have a 170mm in SPONs so the material cost is based on the fact that the 170mm insulation is "
+ "85.4% of the cost of the 200mm insulation"},
+ {'id': 1120, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 200.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.4,
+ 'material_cost': 4.53, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 7.2,
+ 'notes': None},
+ {'id': 1121, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 270.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 5.920624, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0,
+ 'total_cost': 8.590624, 'notes': 'This is the 100mm product + the 170mm product'},
+ {'id': 1122, 'type': 'loft_insulation', 'description': 'Isover Mineral Wool Modular Roll', 'depth': 300.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 6.58, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.25,
+ 'notes': 'This is the 100mm product + the 200mm product'},
+ {'id': 1123, 'type': 'loft_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 100.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 5.93,
+ 'material_cost': 6.4, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.07,
+ 'notes': 'This provides acoustic insulation as well'},
+ {'id': 1124, 'type': 'loft_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 300.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 17.79,
+ 'material_cost': 19.2, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 21.87,
+ 'notes': 'This provides acoustic insulation as well'},
+ {'id': 1125, 'type': 'loft_insulation', 'description': 'Thermafleece EcoRoll Insulation', 'depth': 300.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 24.78, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 27.45,
+ 'notes': 'This material is based on installing 3 layers of the 100mm product'},
+ {'id': 1126, 'type': 'loft_insulation', 'description': 'Thermafleece EcoRoll Insulation', 'depth': 280.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 23.36, 'labour_cost': 3.12, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 26.48,
+ 'notes': 'This material is based on installed 2 layers of the 140mm product'},
+ {'id': 1127, 'type': 'iwi_wall_demolition',
+ 'description': 'Solid & Dry Lined walls: Hack of wall finishes with chipping hammer; plaster to walls.',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 10.27, 'labour_hours_per_unit': 0.33,
+ 'plant_cost': 1.28, 'total_cost': 11.55, 'notes': None}, {'id': 1128, 'type': 'iwi_wall_demolition',
+ 'description': 'Stud walls: Remove wall linings '
+ 'including battening behind; '
+ 'plasterboard and skim',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None,
+ 'thermal_conductivity_unit': None, 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
+ 244907),
+ 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 0.0, 'labour_cost': 6.23,
+ 'labour_hours_per_unit': 0.2, 'plant_cost': 1.25,
+ 'total_cost': 7.48, 'notes': None},
+ {'id': 1129, 'type': 'iwi_wall_demolition',
+ 'description': 'Lathe and Plaster walls: Remove wall linings including battening behind; wood lath and plaster',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.85, 'labour_hours_per_unit': 0.22,
+ 'plant_cost': 2.09, 'total_cost': 8.94, 'notes': None},
+ {'id': 1130, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
+ 'depth': 60.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 41.69,
+ 'material_cost': 53.33, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0,
+ 'total_cost': 82.85, 'notes': None},
+ {'id': 1131, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 86.86,
+ 'material_cost': 99.85, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0,
+ 'total_cost': 129.37, 'notes': None},
+ {'id': 1132, 'type': 'internal_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
+ 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': 130.29, 'material_cost': 144.58, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25,
+ 'plant_cost': 0.0, 'total_cost': 174.1, 'notes': None},
+ {'id': 1133, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
+ 'depth': 30.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16,
+ 'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07,
+ 'notes': None},
+ {'id': 1134, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
+ 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46,
+ 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44,
+ 'notes': None},
+ {'id': 1135, 'type': 'internal_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12,
+ 'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66,
+ 'notes': None},
+ {'id': 1136, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
+ 'depth': 37.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 26.86, 'labour_cost': 5.21, 'labour_hours_per_unit': 0.23, 'plant_cost': 0.0, 'total_cost': 32.07,
+ 'notes': None},
+ {'id': 1137, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
+ 'depth': 42.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 17.37, 'labour_cost': 5.21, 'labour_hours_per_unit': 0.23, 'plant_cost': 0.0, 'total_cost': 22.58,
+ 'notes': None},
+ {'id': 1138, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
+ 'depth': 52.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 21.74, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 27.53,
+ 'notes': None},
+ {'id': 1139, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
+ 'depth': 62.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 19.3, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 25.09,
+ 'notes': None},
+ {'id': 1140, 'type': 'internal_wall_insulation', 'description': 'Kingspan Kooltherm K18 insulated plasterboard',
+ 'depth': 72.5, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04761905,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.021,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 23.15, 'labour_cost': 5.79, 'labour_hours_per_unit': 0.25, 'plant_cost': 0.0, 'total_cost': 28.94,
+ 'notes': None},
+ {'id': 1141, 'type': 'iwi_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02,
+ 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None}, {'id': 1142, 'type': 'iwi_redecoration',
+ 'description': 'Plaster; one coat Thistle board finish '
+ 'or other equal; steel trowelled; 3 mm '
+ 'thick work to walls or ceilings; one '
+ 'coat; to plasterboard base; over 600mm '
+ 'wide',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None,
+ 'thermal_conductivity_unit': None, 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
+ 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.06,
+ 'labour_cost': 6.58, 'labour_hours_per_unit': 0.25,
+ 'plant_cost': 0.0, 'total_cost': 6.64, 'notes': None},
+ {'id': 1143, 'type': 'iwi_redecoration',
+ 'description': 'Two coats emulsion paint on plaster, over 40mm girth; 3.5m - 5m high', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.41, 'labour_cost': 3.93, 'labour_hours_per_unit': 0.21,
+ 'plant_cost': 0.0, 'total_cost': 4.34, 'notes': None}, {'id': 1144, 'type': 'iwi_redecoration',
+ 'description': 'Fitting existing softwood skirting or '
+ 'architrave to new frames; 150mm high',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None,
+ 'thermal_conductivity_unit': None, 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
+ 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.01,
+ 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12,
+ 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None},
+ {'id': 1145, 'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
+ 'plant_cost': 0.0, 'total_cost': 3.32,
+ 'notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and therefore '
+ 'there is no need for a skip'},
+ {'id': 1146, 'type': 'suspended_floor_demolition',
+ 'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3,
+ 'plant_cost': 0.0, 'total_cost': 9.34, 'notes': None},
+ {'id': 1147, 'type': 'suspended_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02,
+ 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None},
+ {'id': 1148, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 50.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 4.24, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 5.8,
+ 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
+ 'in Crown loft roll 44, since it is also an insulation roll'},
+ {'id': 1149, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 6.31, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 7.87,
+ 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
+ 'in Crown loft roll 44, since it is also an insulation roll'},
+ {'id': 1150, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 100.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 8.26, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 9.82,
+ 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
+ 'in Crown loft roll 44, since it is also an insulation roll'},
+ {'id': 1151, 'type': 'suspended_floor_insulation', 'description': 'Thermafleece CosyWool Roll', 'depth': 140.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025641026,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 13.46,
+ 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
+ 'in Crown loft roll 44, since it is also an insulation roll'},
+ {'id': 1152, 'type': 'suspended_floor_insulation',
+ 'description': 'Thermafleece TF35 high density wool insulating batts', 'depth': 50.0, 'depth_unit': 'mm',
+ 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.028571429,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.035,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 6.63, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 8.19,
+ 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
+ 'in Crown loft roll 44, since it is also an insulation roll'},
+ {'id': 1153, 'type': 'suspended_floor_insulation',
+ 'description': 'Thermafleece TF35 high density wool insulating batts', 'depth': 75.0, 'depth_unit': 'mm',
+ 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.028571429,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.035,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 10.31, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 11.87,
+ 'notes': 'Spons did not contain labour costs so we use values for similar insulations. We use the same values as '
+ 'in Crown loft roll 44, since it is also an insulation roll'},
+ {'id': 1154, 'type': 'suspended_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 30.0, 'depth_unit': 'mm',
+ 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16,
+ 'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07,
+ 'notes': None}, {'id': 1155, 'type': 'suspended_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': 8.46, 'material_cost': 19.1, 'labour_cost': 28.34,
+ 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44, 'notes': None},
+ {'id': 1156, 'type': 'suspended_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 100.0, 'depth_unit': 'mm',
+ 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12,
+ 'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66,
+ 'notes': None}, {'id': 1157, 'type': 'suspended_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 150.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': 23.53, 'material_cost': 34.62, 'labour_cost': 33.06,
+ 'labour_hours_per_unit': 1.4, 'plant_cost': 0.0, 'total_cost': 67.68, 'notes': None},
+ {'id': 1158, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.03,
+ 'material_cost': 2.1, 'labour_cost': 1.56, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.66,
+ 'notes': None},
+ {'id': 1159, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll',
+ 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 3.06,
+ 'material_cost': 3.16, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.94,
+ 'notes': None},
+ {'id': 1160, 'type': 'suspended_floor_insulation', 'description': 'Crown Loft Roll 44 glass fibre roll',
+ 'depth': 200.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.022727273,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.25,
+ 'material_cost': 4.37, 'labour_cost': 1.96, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 6.33,
+ 'notes': None},
+ {'id': 1161, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.99,
+ 'material_cost': 2.05, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 3.65,
+ 'notes': None},
+ {'id': 1162, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll',
+ 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.96,
+ 'material_cost': 3.05, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 4.83,
+ 'notes': None},
+ {'id': 1163, 'type': 'suspended_floor_insulation', 'description': 'Isover Mineral Wool Modular Roll',
+ 'depth': 200.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.4,
+ 'material_cost': 4.53, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 7.2,
+ 'notes': None},
+ {'id': 1164, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 25.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.025641026,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 1.67,
+ 'material_cost': 2.01, 'labour_cost': 1.43, 'labour_hours_per_unit': 0.08, 'plant_cost': 0.0, 'total_cost': 3.44,
+ 'notes': None},
+ {'id': 1165, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 50.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.025641026,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.039,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 2.74,
+ 'material_cost': 3.11, 'labour_cost': 1.6, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 4.71,
+ 'notes': None},
+ {'id': 1166, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 4.57,
+ 'material_cost': 5.01, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0.0, 'total_cost': 6.79,
+ 'notes': None},
+ {'id': 1167, 'type': 'suspended_floor_insulation', 'description': 'Isover Acoustic Partition Roll', 'depth': 100.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': 0.023255814,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.043,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 5.93,
+ 'material_cost': 6.4, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0, 'total_cost': 9.07,
+ 'notes': None},
+ {'id': 1168, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
+ 'depth': 25.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12,
+ 'notes': None},
+ {'id': 1169, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
+ 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33,
+ 'notes': None},
+ {'id': 1170, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
+ 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47,
+ 'notes': None},
+ {'id': 1171, 'type': 'suspended_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 12.02, 'labour_cost': 4.4, 'labour_hours_per_unit': 0.19, 'plant_cost': 0.0, 'total_cost': 16.42,
+ 'notes': None}, {'id': 1172, 'type': 'suspended_floor_insulation',
+ 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 50.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 10.36, 'labour_cost': 4.06,
+ 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 14.42, 'notes': None},
+ {'id': 1173, 'type': 'suspended_floor_insulation',
+ 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41,
+ 'notes': None}, {'id': 1174, 'type': 'suspended_floor_insulation',
+ 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
+ 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907),
+ 'is_active': True, 'prime_material_cost': None, 'material_cost': 19.17, 'labour_cost': 4.06,
+ 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 23.23, 'notes': None},
+ {'id': 1175, 'type': 'suspended_floor_insulation',
+ 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 125.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 26.59, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 30.65,
+ 'notes': None}, {'id': 1176, 'type': 'suspended_floor_insulation',
+ 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation',
+ 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
+ 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907),
+ 'is_active': True, 'prime_material_cost': None, 'material_cost': 31.13, 'labour_cost': 4.64,
+ 'labour_hours_per_unit': 0.2, 'plant_cost': 0.0, 'total_cost': 35.77, 'notes': None},
+ {'id': 1177, 'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 1.54, 'labour_cost': 24.98, 'labour_hours_per_unit': 0.74,
+ 'plant_cost': 0.0, 'total_cost': 26.52, 'notes': None},
+ {'id': 1178, 'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37,
+ 'plant_cost': 0.0, 'total_cost': 6.59,
+ 'notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; Gradus woven '
+ 'polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, therefore we need just '
+ 'labour rates'},
+ {'id': 1179, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
+ 'plant_cost': 0.0, 'total_cost': 3.32,
+ 'notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and therefore '
+ 'there is no need for a skip'},
+ {'id': 1180, 'type': 'solid_floor_preparation',
+ 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None,
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 0.0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36,
+ 'notes': None}, {'id': 1181, 'type': 'solid_floor_preparation',
+ 'description': 'Clean out crack to form a 20mm×20mm groove and fill with cement: mortar mixed '
+ 'with bonding agent',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
+ 'thermal_conductivity_unit': None, 'link': None,
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 6.91, 'labour_cost': 18.99,
+ 'labour_hours_per_unit': 0.61, 'plant_cost': 0.16, 'total_cost': 26.06,
+ 'notes': 'This step is the assessment and repair of any damage to the concrete floor such as '
+ 'filling cracks or levelling uneven areas'},
+ {'id': 1182, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02,
+ 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None},
+ {'id': 1183, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 25.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12,
+ 'notes': None},
+ {'id': 1184, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 50.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33,
+ 'notes': None},
+ {'id': 1185, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47,
+ 'notes': None}, {'id': 1186, 'type': 'solid_floor_insulation',
+ 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 50.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 10.36, 'labour_cost': 4.06,
+ 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 14.42, 'notes': None},
+ {'id': 1187, 'type': 'solid_floor_insulation',
+ 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41,
+ 'notes': None}, {'id': 1188, 'type': 'solid_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 30.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': 6.16, 'material_cost': 16.73, 'labour_cost': 28.34,
+ 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07, 'notes': None},
+ {'id': 1189, 'type': 'solid_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.0, 'depth_unit': 'mm',
+ 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46,
+ 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44,
+ 'notes': None}, {'id': 1190, 'type': 'solid_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 60.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
+ 'link': 'https://londonbuildingsupplies.co.uk/products/60mm--ecotherm-eco-versal-general'
+ '-purpose-pir-insulation-board---2.4m-x-1.2m-x-60mm.html',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 24.081198, 'labour_cost': 28.34,
+ 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 52.421196,
+ 'notes': "This material isn't in SPONs but checking online, is around 92% of the cost of the "
+ "100mm"},
+ {'id': 1191, 'type': 'solid_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 70.0, 'depth_unit': 'mm',
+ 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
+ 'link': 'https://londonbuildingsupplies.co.uk/products/70mm--ecotherm-eco-versal-general-purpose-pir-insulation'
+ '-board---2.4m-x-1.2m-x-70mm.html',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 27.089088, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0,
+ 'total_cost': 55.42909,
+ 'notes': "This material isn't in SPONs but checking online, is around 104% of the cost of the 100mm (more "
+ "expensive than 100mm)"},
+ {'id': 1192, 'type': 'solid_floor_insulation',
+ 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 100.0, 'depth_unit': 'mm',
+ 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12,
+ 'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66,
+ 'notes': None},
+ {'id': 1193, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam',
+ 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.032258064,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.031,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 11.07, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0,
+ 'total_cost': 21.73,
+ 'notes': "In Spons, the thermal conductivity is 0.033 however the datasheet indicates it's 0.32: "
+ "https://ravagobuildingsolutions.com/uk/wp-content/uploads/sites/30/2022/08/ravatherm-xps-x-500-sl-tds"
+ "-version-1-20210901.pdf"},
+ {'id': 1194, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam',
+ 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 16.28, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0,
+ 'total_cost': 26.94, 'notes': None}, {'id': 1195, 'type': 'solid_floor_redecoration',
+ 'description': 'Screeded beds; protection to compressible formwork '
+ 'exceeding 600mm wide',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None,
+ 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907),
+ 'is_active': True, 'prime_material_cost': 9.6, 'material_cost': 9.89,
+ 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, 'plant_cost': 0.0,
+ 'total_cost': 12.56,
+ 'notes': 'This is the screed layer, placed on top of the insulation'},
+ {'id': 1196, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, 'plant_cost': 0.0, 'total_cost': 6.59,
+ 'notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; Gradus woven '
+ 'polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, therefore we need just '
+ 'labour rates'},
+ {'id': 1197, 'type': 'solid_floor_redecoration',
+ 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12,
+ 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None}, {'id': 1198, 'type': 'ewi_wall_demolition',
+ 'description': 'Solid & Dry Lined walls: Hack of wall '
+ 'finishes with chipping hammer; plaster '
+ 'to walls.',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None,
+ 'thermal_conductivity_unit': None, 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
+ 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.0,
+ 'labour_cost': 10.27, 'labour_hours_per_unit': 0.33,
+ 'plant_cost': 1.28, 'total_cost': 11.55, 'notes': None},
+ {'id': 1199, 'type': 'ewi_wall_demolition',
+ 'description': 'Stud walls: Remove wall linings including battening behind; plasterboard and skim', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.23, 'labour_hours_per_unit': 0.2,
+ 'plant_cost': 1.25, 'total_cost': 7.48, 'notes': None}, {'id': 1200, 'type': 'ewi_wall_demolition',
+ 'description': 'Lathe and Plaster walls: Remove wall '
+ 'linings including battening behind; '
+ 'wood lath and plaster',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None,
+ 'thermal_conductivity_unit': None, 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
+ 244907),
+ 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 0.0, 'labour_cost': 6.85,
+ 'labour_hours_per_unit': 0.22, 'plant_cost': 2.09,
+ 'total_cost': 8.94, 'notes': None},
+ {'id': 1201, 'type': 'ewi_wall_preparation',
+ 'description': 'Clean and prepare surfaces, one coat Keim dilution, one coat primer and two coats of Keim Ecosil '
+ 'paint; Brick or block walls; over 300 mm girth',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 7.3, 'labour_cost': 5.62, 'labour_hours_per_unit': 0.3,
+ 'plant_cost': 0.0, 'total_cost': 12.92,
+ 'notes': 'This work covers the preparation and priming of the wall before insulating'},
+ {'id': 1202, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
+ 'depth': 30.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 6.16,
+ 'material_cost': 16.73, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07,
+ 'notes': None},
+ {'id': 1203, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
+ 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 8.46,
+ 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44,
+ 'notes': None},
+ {'id': 1204, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 15.12,
+ 'material_cost': 25.96, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, 'total_cost': 56.66,
+ 'notes': None},
+ {'id': 1205, 'type': 'external_wall_insulation', 'description': 'Ecotherm Eco-Versal PIR Insulation Board',
+ 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 23.53,
+ 'material_cost': 34.62, 'labour_cost': 33.06, 'labour_hours_per_unit': 1.4, 'plant_cost': 0.0, 'total_cost': 67.68,
+ 'notes': None},
+ {'id': 1206, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
+ 'depth': 60.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 41.69,
+ 'material_cost': 53.33, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0,
+ 'total_cost': 82.85, 'notes': None},
+ {'id': 1207, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
+ 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': 86.86,
+ 'material_cost': 99.85, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25, 'plant_cost': 0.0,
+ 'total_cost': 129.37, 'notes': None},
+ {'id': 1208, 'type': 'external_wall_insulation', 'description': 'Foamglas Grade F Wall Insulation Slabs',
+ 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True,
+ 'prime_material_cost': 130.29, 'material_cost': 144.58, 'labour_cost': 29.52, 'labour_hours_per_unit': 1.25,
+ 'plant_cost': 0.0, 'total_cost': 174.1, 'notes': None}, {'id': 1209, 'type': 'ewi_wall_redecoration',
+ 'description': 'EPS insulation fixed with adhesive to '
+ 'SFS structure (measured separately) '
+ 'with horizontal PVC intermediate track '
+ 'and vertical T-spines; with glassfibre '
+ 'mesh reinforcement embedded in Sto '
+ 'Armat Classic Basecoat Render and '
+ 'Stolit K 1.5 Decorative Topcoat Render '
+ '(white)',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None,
+ 'thermal_conductivity_unit': None, 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12,
+ 244907),
+ 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 0.0, 'labour_cost': 0.0,
+ 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0,
+ 'total_cost': 69.94,
+ 'notes': 'This material in SPONs is for 70mm EPS '
+ 'insulation, which comes in at a cost of 99.17 '
+ 'per meter square. This includes the cost of '
+ 'insulation. To get the costing for just the '
+ 'works and not the insulation, we subtract the '
+ 'cost of EPS insulation, using Ravathem 75mm '
+ 'insulation as an example, which costs £29.23 '
+ 'per meter square, giving us the cost of the '
+ 'remaining works without insulation. This '
+ 'material gives us a cost for basecoat, '
+ 'mesh application and a render finish'},
+ {'id': 1210, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'https://www.checkatrade.com/blog/cost-guides/cost-install-downlights/ '
+ 'https://www.hamuch.com/cost/led-spot-light#:~:text=It%20costs%20an%20average%20of,'
+ 'will%20drive%20up%20the%20cost.',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 20.0, 'labour_cost': 46.0, 'labour_hours_per_unit': 0.8, 'plant_cost': 0.0, 'total_cost': 66.0,
+ 'notes': 'We estimate the unit economics from the checkatrade article. We assume that the average job consists '
+ 'of installing 6 lights based on the hamuch article. We use the median value of 400 for a job of 6 '
+ 'lights'}]
diff --git a/recommendations/tests/test_fireplace_recommendations.py b/recommendations/tests/test_fireplace_recommendations.py
index a1e0c1c6..570fbb5c 100644
--- a/recommendations/tests/test_fireplace_recommendations.py
+++ b/recommendations/tests/test_fireplace_recommendations.py
@@ -37,7 +37,7 @@ class TestFirepaceRecommendations:
assert recommender.recommendation
assert recommender.recommendation[0]["type"] == "sealing_open_fireplace"
- assert recommender.recommendation[0]["cost"] == 300
+ assert recommender.recommendation[0]["total"] == 300
def test_multiple_fireplaces(self):
property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
@@ -55,4 +55,4 @@ class TestFirepaceRecommendations:
assert recommender.recommendation
assert recommender.recommendation[0]["type"] == "sealing_open_fireplace"
- assert recommender.recommendation[0]["cost"] == 900
+ assert recommender.recommendation[0]["total"] == 900
diff --git a/recommendations/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py
index 82ba7cf4..01bd308e 100644
--- a/recommendations/tests/test_floor_recommendations.py
+++ b/recommendations/tests/test_floor_recommendations.py
@@ -3,90 +3,15 @@ import pytest
import os
from unittest.mock import Mock
from recommendations.FloorRecommendations import FloorRecommendations
+from recommendations.tests.test_data.materials import materials
from backend.Property import Property
+
# with open(
# os.path.abspath(os.path.dirname(__file__)) + "/recommendations/tests/test_data/input_properties.pkl", "rb"
# ) as f:
# input_properties = pickle.load(f)
-suspended_floor_insulation_parts = [
- {
- # Example product
- # https://www.insulationsuperstore.co.uk/product/recticel-eurothane-general-purpose-pir-insulation-board-2400
- # -x-1200-x-100mm.html
- # All product data_types here:
- # https://www.insulationsuperstore.co.uk/browse/insulation/brand/recticel/filterby/application/floors.html
- "type": "suspended_floor_insulation",
- "description": "Rigid Insulation Foam Boards",
- "depths": [25, 30, 40, 50, 60, 70, 75, 80, 90, 100, 110, 120, 130, 140, 150],
- "depth_unit": "mm",
- "cost": [25, 30, 40, 50, 60, 70, 75, 80, 90, 100, 110, 120, 130, 140, 150],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.04545454545454546,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.022,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- # Example product
- # https://www.insulationsuperstore.co.uk/product/rockwool-rwa45-acoustic-insulation-slab-100mm-2-88m2-pack.html
- # All product data_types here:
- # https://www.insulationsuperstore.co.uk/browse/insulation/brand/rockwool/filterby/application/floors
- # /material/mineral-wool.html
- "type": "suspended_floor_insulation",
- "description": "Mineral Wool Floor Insulation",
- "depths": [25, 40, 50, 60, 75, 100],
- "depth_unit": "mm",
- "cost": [25, 40, 50, 60, 75, 100],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.02857142857142857,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.035,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
-]
-
-solid_floor_insulation_parts = [
- {
- # Example product
- # https://www.insulationexpress.co.uk/floor-insulation/solid-floor-insulation/k103-100mm
- # All product data_types here:
- # https://www.insulationexpress.co.uk/floor-insulation/solid-floor-insulation?brand=7015&p=1
- # Example screed https://www.screwfix.com/p/mapei-ultraplan-3240-self-levelling-compound-25kg/4959f
- "type": "solid_floor_insulation",
- "description": "Rigid Insulation Foam Boards with floor screed",
- "depths": [25, 50, 70, 75, 100],
- "depth_unit": "mm",
- "cost": [25, 40, 50, 60, 75, 100],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.04545454545454546,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.052631578947368425,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
-
-]
-
-exposed_floor_insulation_parts = [
- {
- "type": "exposed_floor_insulation",
- "description": "Rockwool Stone Wool insulation",
- "depths": [50, 100, 140],
- "depth_unit": "mm",
- "cost": [8, 11, 15],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.026315789473684213,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.038,
- "thermal_conductivity_unit": "watt_per_meter_kelvin",
- "link": "https://insulation4less.co.uk/products/rockwool-flexi-slab-all-sizes?variant=33409590853685"
- },
-]
-
-parts = suspended_floor_insulation_parts + solid_floor_insulation_parts + exposed_floor_insulation_parts
-
-
class TestFloorRecommendations:
@pytest.fixture
@@ -100,26 +25,29 @@ class TestFloorRecommendations:
def mock_floor_rec_instance(self):
# Creating a mock instance of WallRecommendations with the necessary attributes
property_mock = Mock()
- property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"} # or any date you want
- property_mock.data = {"construction-age-band": "1950"} # or any other data that fits your tests
+ property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"}
+ property_mock.data = {"county": "York"}
- mock_wall_rec_instance = FloorRecommendations(property_mock, parts)
+ mock_wall_rec_instance = FloorRecommendations(property_mock, materials)
return mock_wall_rec_instance
def test_init(self, input_properties):
+ input_properties[0].insulation_floor_area = 50
+ input_properties[0].insulation_wall_area = 90
obj = FloorRecommendations(
property_instance=input_properties[0],
- materials=parts
+ materials=materials
)
assert obj
assert obj.property
def test_other_premises_below(self, input_properties):
- input_properties[0].floor_area = 100
+ input_properties[0].insulation_floor_area = 100
+ input_properties[0].insulation_wall_area = 999
input_properties[0].number_of_floors = 1
recommender = FloorRecommendations(
property_instance=input_properties[0],
- materials=parts
+ materials=materials
)
recommender.recommend()
assert recommender.property.floor["another_property_below"]
@@ -132,7 +60,8 @@ class TestFloorRecommendations:
:return:
"""
- input_properties[2].floor_area = 50
+ input_properties[2].insulation_floor_area = 50
+ input_properties[2].insulation_wall_area = 50
input_properties[2].walls["is_park_home"] = False
input_properties[2].age_band = "A"
input_properties[2].perimeter = 20
@@ -140,10 +69,7 @@ class TestFloorRecommendations:
input_properties[2].floor_type = "suspended"
input_properties[2].number_of_floors = 1
- recommender = FloorRecommendations(
- property_instance=input_properties[2],
- materials=parts
- )
+ recommender = FloorRecommendations(property_instance=input_properties[2], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
assert recommender.property.floor["is_suspended"]
@@ -154,18 +80,20 @@ class TestFloorRecommendations:
assert types == {"suspended_floor_insulation"}
+ assert len(recommender.recommendations) == 6
+ assert recommender.recommendations[0]["total"] == 4596.858
+ assert recommender.recommendations[0]["new_u_value"] == 0.21
+
def test_uvalue_0_12(self, input_properties):
"""
This is a home that doesn't have a property below but it's highly performant already and therefore
does not need floor insulation
:return:
"""
- input_properties[3].floor_area = 100
+ input_properties[3].insulation_floor_area = 100
+ input_properties[3].insulation_wall_area = 100
input_properties[3].number_of_floors = 1
- recommender = FloorRecommendations(
- property_instance=input_properties[3],
- materials=parts
- )
+ recommender = FloorRecommendations(property_instance=input_properties[3], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
assert not recommender.property.floor["is_suspended"]
@@ -178,7 +106,8 @@ class TestFloorRecommendations:
:return:
"""
- input_properties[4].floor_area = 100
+ input_properties[4].insulation_floor_area = 100
+ input_properties[4].insulation_wall_area = 100
input_properties[4].walls["is_park_home"] = False
input_properties[4].age_band = "B"
input_properties[4].perimeter = 50
@@ -186,10 +115,9 @@ class TestFloorRecommendations:
input_properties[4].floor_type = "solid"
input_properties[4].number_of_floors = 1
- recommender = FloorRecommendations(
- property_instance=input_properties[4],
- materials=parts
- )
+ # In this case, we have no county, so in this case, it should yse the local-authority-label if possible
+ input_properties[4].data["county"] = ""
+ recommender = FloorRecommendations(property_instance=input_properties[4], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
assert not recommender.property.floor["is_suspended"]
@@ -201,17 +129,22 @@ class TestFloorRecommendations:
assert types == {"solid_floor_insulation"}
+ assert len(recommender.recommendations) == 3
+ assert recommender.recommendations[2]["total"] == 14604.660000000002
+ assert recommender.recommendations[2]["new_u_value"] == 0.21
+ assert recommender.recommendations[2]["parts"][0]["depth"] == 75
+ assert recommender.recommendations[2]["parts"][0]["depth"] == 75
+
def test_another_dwelling_below(self, input_properties):
"""
This is another description we see when there is a property below
"""
- input_properties[6].floor_area = 100
+ input_properties[6].insulation_floor_area = 100
+ input_properties[6].insulation_wall_area = 1
+
input_properties[6].number_of_floors = 1
- recommender = FloorRecommendations(
- property_instance=input_properties[6],
- materials=parts
- )
+ recommender = FloorRecommendations(property_instance=input_properties[6], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
assert not recommender.property.floor["is_suspended"]
@@ -219,123 +152,123 @@ class TestFloorRecommendations:
assert recommender.estimated_u_value is None
assert not recommender.recommendations
- def test_exposed_floor_no_insulation(self):
- input_property = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
- input_property.floor = {
- 'original_description': 'To unheated space, no insulation (assumed)',
- 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None,
- 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
- 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
- 'insulation_thickness': 'none'
- }
- input_property.age_band = "L"
- input_property.set_floor_type()
- input_property.data = {"floor-level": 0, "property-type": "House"}
- input_property.floor_area = 100
- input_property.number_of_floors = 1
-
- recommender = FloorRecommendations(
- property_instance=input_property,
- materials=exposed_floor_insulation_parts
- )
-
- assert not recommender.recommendations
-
- recommender.recommend()
-
- # Because of age band L, this should have a u-value of 0.22 to begin with and no recommendation
- assert not len(recommender.recommendations)
- assert recommender.estimated_u_value == 0.22
-
- # Now with an older age band
-
- input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
- input_property2.floor = {
- 'original_description': 'To unheated space, no insulation (assumed)',
- 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None,
- 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
- 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
- 'insulation_thickness': 'none'
- }
- input_property2.age_band = "D"
- input_property2.set_floor_type()
- input_property2.data = {"floor-level": 0, "property-type": "House"}
- input_property2.floor_area = 100
- input_property2.number_of_floors = 1
-
- recommender2 = FloorRecommendations(
- property_instance=input_property2,
- materials=exposed_floor_insulation_parts
- )
-
- assert not recommender2.recommendations
-
- recommender2.recommend()
-
- assert len(recommender2.recommendations) == 1
-
- assert recommender2.recommendations[0]["new_u_value"] == 0.23
- assert recommender2.recommendations[0]["starting_u_value"] == 1.2
- assert recommender2.recommendations[0]["cost"] == 1500
-
- def test_exposed_floor_below_average_insulated(self):
- input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
- input_property3.floor = {
- 'original_description': 'To unheated space, below average insulation (assumed)',
- 'clean_description': 'To unheated space, below average insulation', 'thermal_transmittance': None,
- 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
- 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
- 'insulation_thickness': 'below average'
- }
- input_property3.age_band = "C"
- input_property3.set_floor_type()
- input_property3.data = {"floor-level": 0, "property-type": "House"}
- input_property3.floor_area = 100
- input_property3.number_of_floors = 1
-
- recommender3 = FloorRecommendations(
- property_instance=input_property3,
- materials=exposed_floor_insulation_parts
- )
-
- assert not recommender3.recommendations
-
- recommender3.recommend()
-
- assert recommender3.estimated_u_value == 0.5
-
- assert len(recommender3.recommendations) == 1
-
- assert recommender3.recommendations[0]["new_u_value"] == 0.22
- assert recommender3.recommendations[0]["starting_u_value"] == 0.5
- assert recommender3.recommendations[0]["cost"] == 1100
- assert recommender3.recommendations[0]["parts"][0]["depths"] == [100]
-
- # With average insulation, no recommendations
-
- input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
- input_property4.floor = {
- 'original_description': 'To unheated space, insulated (assumed)',
- 'clean_description': 'To unheated space, insulated', 'thermal_transmittance': None,
- 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
- 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
- 'insulation_thickness': 'average'
- }
- input_property4.age_band = "C"
- input_property4.set_floor_type()
- input_property4.data = {"floor-level": 0, "property-type": "House"}
- input_property4.floor_area = 100
- input_property4.number_of_floors = 1
-
- recommender4 = FloorRecommendations(
- property_instance=input_property4,
- materials=exposed_floor_insulation_parts
- )
-
- assert not recommender4.recommendations
-
- recommender4.recommend()
-
- assert recommender4.estimated_u_value is None
-
- assert len(recommender4.recommendations) == 0
+ # def test_exposed_floor_no_insulation(self):
+ # input_property = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
+ # input_property.floor = {
+ # 'original_description': 'To unheated space, no insulation (assumed)',
+ # 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None,
+ # 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
+ # 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
+ # 'insulation_thickness': 'none'
+ # }
+ # input_property.age_band = "L"
+ # input_property.set_floor_type()
+ # input_property.data = {"floor-level": 0, "property-type": "House"}
+ # input_property.floor_area = 100
+ # input_property.number_of_floors = 1
+ #
+ # recommender = FloorRecommendations(
+ # property_instance=input_property,
+ # materials=materials
+ # )
+ #
+ # assert not recommender.recommendations
+ #
+ # recommender.recommend()
+ #
+ # # Because of age band L, this should have a u-value of 0.22 to begin with and no recommendation
+ # assert not len(recommender.recommendations)
+ # assert recommender.estimated_u_value == 0.22
+ #
+ # # Now with an older age band
+ #
+ # input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
+ # input_property2.floor = {
+ # 'original_description': 'To unheated space, no insulation (assumed)',
+ # 'clean_description': 'To unheated space, no insulation', 'thermal_transmittance': None,
+ # 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
+ # 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
+ # 'insulation_thickness': 'none'
+ # }
+ # input_property2.age_band = "D"
+ # input_property2.set_floor_type()
+ # input_property2.data = {"floor-level": 0, "property-type": "House"}
+ # input_property2.floor_area = 100
+ # input_property2.number_of_floors = 1
+ #
+ # recommender2 = FloorRecommendations(
+ # property_instance=input_property2,
+ # materials=materials
+ # )
+ #
+ # assert not recommender2.recommendations
+ #
+ # recommender2.recommend()
+ #
+ # assert len(recommender2.recommendations) == 1
+ #
+ # assert recommender2.recommendations[0]["new_u_value"] == 0.23
+ # assert recommender2.recommendations[0]["starting_u_value"] == 1.2
+ # assert recommender2.recommendations[0]["cost"] == 1500
+ #
+ # def test_exposed_floor_below_average_insulated(self):
+ # input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
+ # input_property3.floor = {
+ # 'original_description': 'To unheated space, below average insulation (assumed)',
+ # 'clean_description': 'To unheated space, below average insulation', 'thermal_transmittance': None,
+ # 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
+ # 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
+ # 'insulation_thickness': 'below average'
+ # }
+ # input_property3.age_band = "C"
+ # input_property3.set_floor_type()
+ # input_property3.data = {"floor-level": 0, "property-type": "House"}
+ # input_property3.floor_area = 100
+ # input_property3.number_of_floors = 1
+ #
+ # recommender3 = FloorRecommendations(
+ # property_instance=input_property3,
+ # materials=materials
+ # )
+ #
+ # assert not recommender3.recommendations
+ #
+ # recommender3.recommend()
+ #
+ # assert recommender3.estimated_u_value == 0.5
+ #
+ # assert len(recommender3.recommendations) == 1
+ #
+ # assert recommender3.recommendations[0]["new_u_value"] == 0.22
+ # assert recommender3.recommendations[0]["starting_u_value"] == 0.5
+ # assert recommender3.recommendations[0]["cost"] == 1100
+ # assert recommender3.recommendations[0]["parts"][0]["depths"] == [100]
+ #
+ # # With average insulation, no recommendations
+ #
+ # input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
+ # input_property4.floor = {
+ # 'original_description': 'To unheated space, insulated (assumed)',
+ # 'clean_description': 'To unheated space, insulated', 'thermal_transmittance': None,
+ # 'thermal_transmittance_unit': None, 'is_assumed': True, 'is_to_unheated_space': True,
+ # 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False,
+ # 'insulation_thickness': 'average'
+ # }
+ # input_property4.age_band = "C"
+ # input_property4.set_floor_type()
+ # input_property4.data = {"floor-level": 0, "property-type": "House"}
+ # input_property4.floor_area = 100
+ # input_property4.number_of_floors = 1
+ #
+ # recommender4 = FloorRecommendations(
+ # property_instance=input_property4,
+ # materials=materials
+ # )
+ #
+ # assert not recommender4.recommendations
+ #
+ # recommender4.recommend()
+ #
+ # assert recommender4.estimated_u_value is None
+ #
+ # assert len(recommender4.recommendations) == 0
diff --git a/recommendations/tests/test_lighting_recommendations.py b/recommendations/tests/test_lighting_recommendations.py
new file mode 100644
index 00000000..06d1163f
--- /dev/null
+++ b/recommendations/tests/test_lighting_recommendations.py
@@ -0,0 +1,47 @@
+import pytest
+from unittest.mock import Mock
+from backend.Property import Property
+from recommendations.LightingRecommendations import LightingRecommendations
+
+from recommendations.tests.test_data.materials import materials
+
+
+class TestLightingRecommendations:
+
+ def test_init_invalid_materials(self):
+ input_property0 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock())
+ input_property0.lighting = {"low_energy_proportion": 0}
+ input_property0.data = {"county": "Greater London Authority"}
+ # Test for invalid materials
+ with pytest.raises(ValueError):
+ LightingRecommendations(input_property0, [])
+
+ def test_recommend_no_action_needed(self):
+ # Case where no recommendation is needed
+ input_property1 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock())
+ input_property1.lighting = {"low_energy_proportion": 100}
+ input_property1.data = {"county": "Greater London Authority"}
+
+ lr = LightingRecommendations(input_property1, materials)
+ lr.recommend()
+ assert lr.recommendation == []
+
+ def test_recommend_action_needed(self):
+ # Case where recommendation is needed
+ input_property1 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock())
+ input_property1.lighting = {"low_energy_proportion": 100}
+ input_property1.data = {"county": "Greater London Authority"}
+ input_property1.lighting = {"low_energy_proportion": 0.80}
+ input_property1.number_lighting_outlets = 20
+
+ lr = LightingRecommendations(input_property1, materials)
+ lr.recommend()
+ assert len(lr.recommendation) == 1
+
+ assert lr.recommendation == [
+ {'parts': [], 'type': 'low_energy_lighting', 'description': 'Install low energy lighting in 4 outlets',
+ 'starting_u_value': None, 'new_u_value': None, 'sap_points': 0.4, 'total': 458.976, 'subtotal': 382.48,
+ 'vat': 76.49600000000001, 'contingency': 27.320000000000007, 'preliminaries': 27.320000000000007,
+ 'material': 80.0, 'profit': 54.640000000000015, 'labour_hours': 3.2, 'labour_days': 0.4,
+ 'labour_cost': 193.20000000000002}
+ ]
diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py
index 73796979..69f0d1b6 100644
--- a/recommendations/tests/test_recommendation_utils.py
+++ b/recommendations/tests/test_recommendation_utils.py
@@ -42,10 +42,12 @@ class TestRecommendationUtils:
assert recommendation_utils.update_lowest_selected_u_value(1, 0.5) == 0.5
def test_get_recommended_part(self):
- part = {'depths': [1, 2, 3]}
+ part = {'description': "some insulation material"}
+
assert recommendation_utils.get_recommended_part(
- part=part, selected_depth=1, selected_total_cost=50, quantity=99, quantity_unit="m2"
- ) == {'depths': [1], 'estimated_cost': 50, 'quantity': 99, 'quantity_unit': QuantityUnits.m2.value}
+ part=part, cost_result={"cost_result": 123}, quantity=99, quantity_unit="m2"
+ ) == {'description': "some insulation material", 'quantity': 99, 'quantity_unit': QuantityUnits.m2.value,
+ "cost_result": 123}
def test_get_roof_u_value(self):
# Test case 1: Insulation thickness is known and is_loft is True
diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py
index 551407da..80591970 100644
--- a/recommendations/tests/test_roof_recommendations.py
+++ b/recommendations/tests/test_roof_recommendations.py
@@ -1,65 +1,7 @@
from backend.Property import Property
from unittest.mock import Mock
from recommendations.RoofRecommendations import RoofRecommendations
-
-loft_insulation_materials = [
- {
- 'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation',
- 'depths': [270, 300], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter',
- 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
- 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/',
- 'is_active': True
- }
-]
-
-loft_insulation_materials_50mm_existing = [
- {
- 'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation',
- 'depths': [220, 210], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter',
- 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
- 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/',
- 'is_active': True
- }
-]
-
-loft_insulation_materials_150mm_existing = [
- {
- 'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation',
- 'depths': [130, 119], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter',
- 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
- 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/',
- 'is_active': True
- }
-]
-
-room_roof_insulation_materials = [
- {
- 'id': 18,
- 'type': 'room_roof_insulation',
- 'description': 'Example room roof insulation',
- 'depths': [50, 150, 220, 270, 300], 'depth_unit': 'mm', 'cost': [9, 10, 11, 12, 13],
- 'cost_unit': 'gbp_sq_meter',
- 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
- 'link': None, 'is_active': True
- }
-]
-
-flat_roof_insulation_materials = [
- {
- 'id': 18,
- 'type': 'flat_roof_insulation',
- 'description': 'Example flat roof insulation',
- 'depths': [50, 150, 220, 270, 300], 'depth_unit': 'mm', 'cost': [9, 10, 11, 12, 13],
- 'cost_unit': 'gbp_sq_meter',
- 'r_value_per_mm': 0.032727273, 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
- 'link': None, 'is_active': True
- }
-]
+from recommendations.tests.test_data.materials import materials
class TestRoofRecommendations:
@@ -67,7 +9,7 @@ class TestRoofRecommendations:
def test_loft_insulation_recommendation_no_insulation(self):
property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance.age_band = "F"
- property_instance.floor_area = 100
+ property_instance.insulation_floor_area = 100
property_instance.roof = {
'original_description': 'Pitched, no insulation (assumed)',
'clean_description': 'Pitched, no insulation',
@@ -77,8 +19,11 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'none', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
+ property_instance.data = {
+ "county": "Cambridgeshire",
+ }
- roof_recommender = RoofRecommendations(property_instance=property_instance, materials=loft_insulation_materials)
+ roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
assert not roof_recommender.recommendations
@@ -89,7 +34,7 @@ class TestRoofRecommendations:
def test_loft_insulation_recommendation_50mm_insulation(self):
property_instance2 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance2.age_band = "F"
- property_instance2.floor_area = 100
+ property_instance2.insulation_floor_area = 100
property_instance2.roof = {
'original_description': 'Pitched, 50mm loft insulation (assumed)',
'clean_description': 'Pitched, 50mm loft insulation',
@@ -99,10 +44,9 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
+ property_instance2.data = {"county": "Kent"}
- roof_recommender2 = RoofRecommendations(
- property_instance=property_instance2, materials=loft_insulation_materials
- )
+ roof_recommender2 = RoofRecommendations(property_instance=property_instance2, materials=materials)
assert not roof_recommender2.recommendations
@@ -110,13 +54,13 @@ class TestRoofRecommendations:
assert len(roof_recommender2.recommendations) == 1
- assert roof_recommender2.recommendations[0]["cost"] == 900
+ assert roof_recommender2.recommendations[0]["total"] == 1310.56464
assert roof_recommender2.recommendations[0]["new_u_value"] == 0.14
assert roof_recommender2.recommendations[0]["starting_u_value"] == 0.68
property_instance3 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance3.age_band = "F"
- property_instance3.floor_area = 100
+ property_instance3.insulation_floor_area = 100
property_instance3.roof = {
'original_description': 'Pitched, 50mm loft insulation (assumed)',
'clean_description': 'Pitched, 50mm loft insulation',
@@ -126,24 +70,22 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
+ property_instance3.data = {"county": "Greater London Authority"}
- roof_recommender3 = RoofRecommendations(
- property_instance=property_instance3, materials=loft_insulation_materials_50mm_existing
- )
+ roof_recommender3 = RoofRecommendations(property_instance=property_instance3, materials=materials)
assert not roof_recommender3.recommendations
roof_recommender3.recommend()
- # The 220mm insulation should be selected, not the 210
assert roof_recommender3.recommendations
assert len(roof_recommender3.recommendations) == 1
- assert roof_recommender3.recommendations[0]["parts"][0]["depths"] == [220]
+ assert roof_recommender3.recommendations[0]["parts"][0]["depth"] == 270
def test_loft_insulation_recommendation_150mm_insulation(self):
property_instance4 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance4.age_band = "F"
- property_instance4.floor_area = 100
+ property_instance4.insulation_floor_area = 100
property_instance4.roof = {
'original_description': 'Pitched, 150mm loft insulation (assumed)',
'clean_description': 'Pitched, 150mm loft insulation',
@@ -153,24 +95,24 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
+ property_instance4.data = {"county": "North East Lincolnshire"}
- roof_recommender4 = RoofRecommendations(
- property_instance=property_instance4, materials=loft_insulation_materials
- )
+ roof_recommender4 = RoofRecommendations(property_instance=property_instance4, materials=materials)
assert not roof_recommender4.recommendations
roof_recommender4.recommend()
- assert len(roof_recommender4.recommendations) == 1
+ assert len(roof_recommender4.recommendations) == 4
- assert roof_recommender4.recommendations[0]["cost"] == 900
- assert roof_recommender4.recommendations[0]["new_u_value"] == 0.11
+ assert roof_recommender4.recommendations[0]["total"] == 788.0544
+ assert roof_recommender4.recommendations[0]["new_u_value"] == 0.15
assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3
+ assert roof_recommender4.recommendations[0]["parts"][0]["depth"] == 150
property_instance5 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance5.age_band = "F"
- property_instance5.floor_area = 100
+ property_instance5.insulation_floor_area = 100
property_instance5.roof = {
'original_description': 'Pitched, 150mm loft insulation (assumed)',
'clean_description': 'Pitched, 150mm loft insulation',
@@ -180,25 +122,24 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
+ property_instance5.data = {"county": "Somerset"}
- roof_recommender5 = RoofRecommendations(
- property_instance=property_instance5, materials=loft_insulation_materials_150mm_existing
- )
+ roof_recommender5 = RoofRecommendations(property_instance=property_instance5, materials=materials)
assert not roof_recommender5.recommendations
roof_recommender5.recommend()
- # The 130mm insulation should be selected, not the 110
+ # The 150mm insulation should be selected, since there it already 150mm
assert roof_recommender5.recommendations
- assert len(roof_recommender5.recommendations) == 1
- assert roof_recommender5.recommendations[0]["parts"][0]["depths"] == [130]
+ assert len(roof_recommender5.recommendations) == 4
+ assert roof_recommender5.recommendations[0]["parts"][0]["depth"] == 150
def test_loft_insulation_recommendation_270mm_insulation(self):
# We shouldn't recommend anything in this case
property_instance6 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance6.age_band = "F"
- property_instance6.floor_area = 100
+ property_instance6.insulation_floor_area = 100
property_instance6.roof = {
'original_description': 'Pitched, 270mm loft insulation (assumed)',
'clean_description': 'Pitched, 270mm loft insulation',
@@ -208,10 +149,9 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '270', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
+ property_instance6.data = {"county": "Portsmouth"}
- roof_recommender6 = RoofRecommendations(
- property_instance=property_instance6, materials=loft_insulation_materials
- )
+ roof_recommender6 = RoofRecommendations(property_instance=property_instance6, materials=materials)
assert not roof_recommender6.recommendations
@@ -219,219 +159,211 @@ class TestRoofRecommendations:
assert len(roof_recommender6.recommendations) == 0
- def test_uninsulated_room_in_roof(self):
- property_instance7 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
- property_instance7.age_band = "F"
- property_instance7.floor_area = 100
- property_instance7.roof = {
- 'original_description': 'Roof room(s), no insulation (assumed)',
- 'clean_description': 'Roof room(s), no insulation',
- 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
- 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
- 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
- }
-
- property_instance7.pitched_roof_area = 110
-
- roof_recommender7 = RoofRecommendations(
- property_instance=property_instance7, materials=room_roof_insulation_materials
- )
-
- assert not roof_recommender7.recommendations
-
- roof_recommender7.recommend()
-
- # Even though we have 3 depths, we only end with 1 due to diminishin returns
- assert len(roof_recommender7.recommendations) == 1
-
- assert roof_recommender7.recommendations[0]["parts"][0]["depths"] == [270]
-
- assert roof_recommender7.recommendations[0]["new_u_value"] == 0.14
- assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8
- assert roof_recommender7.recommendations[0]["description"] == \
- "Insulate your room roof with 270mm of Example room roof insulation"
-
- def test_ceiling_insulated_room_in_roof(self):
- property_instance8 = Property(id=8, address1="fake", postcode="fake", epc_client=Mock())
- property_instance8.age_band = "F"
- property_instance8.floor_area = 100
- property_instance8.roof = {
- 'original_description': 'Roof room(s), ceiling insulated',
- 'clean_description': 'Roof room(s), ceiling insulated',
- 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
- 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
- 'is_at_rafters': False,
- 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
- 'insulation_thickness': 'average'
- }
-
- property_instance8.pitched_roof_area = 110
-
- roof_recommender8 = RoofRecommendations(
- property_instance=property_instance8, materials=room_roof_insulation_materials
- )
-
- assert not roof_recommender8.recommendations
-
- roof_recommender8.recommend()
-
- # No recommendations in this case
- assert not roof_recommender8.recommendations
-
- def test_insulated_room_in_roof(self):
- property_instance9 = Property(id=9, address1="fake", postcode="fake", epc_client=Mock())
- property_instance9.age_band = "F"
- property_instance9.floor_area = 100
- property_instance9.roof = {
- 'original_description': 'Roof room(s), insulated (assumed)',
- 'clean_description': 'Roof room(s), insulated',
- 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
- 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
- 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
- }
-
- property_instance9.pitched_roof_area = 110
-
- roof_recommender9 = RoofRecommendations(
- property_instance=property_instance9, materials=room_roof_insulation_materials
- )
-
- assert not roof_recommender9.recommendations
-
- roof_recommender9.recommend()
-
- # No recommendations in this case
- assert not roof_recommender9.recommendations
-
- def test_limited_insulated_room_in_roof(self):
- property_instance10 = Property(id=10, address1="fake", postcode="fake", epc_client=Mock())
- property_instance10.age_band = "F"
- property_instance10.floor_area = 100
- property_instance10.roof = {
- 'original_description': 'Roof room(s), limited insulation (assumed)',
- 'clean_description': 'Roof room(s), limited insulation',
- 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
- 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
- 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
- 'insulation_thickness': 'below average'
- }
-
- property_instance10.pitched_roof_area = 110
-
- roof_recommender10 = RoofRecommendations(
- property_instance=property_instance10, materials=room_roof_insulation_materials
- )
-
- assert not roof_recommender10.recommendations
-
- roof_recommender10.recommend()
-
- assert len(roof_recommender10.recommendations) == 2
-
- assert roof_recommender10.recommendations[0]["parts"][0]["depths"] == [220]
- assert roof_recommender10.recommendations[1]["parts"][0]["depths"] == [270]
-
- assert roof_recommender10.recommendations[0]["new_u_value"] == 0.16
- assert roof_recommender10.recommendations[1]["new_u_value"] == 0.14
-
- assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.8
- assert roof_recommender10.recommendations[1]["starting_u_value"] == 0.8
-
- assert roof_recommender10.recommendations[0]["description"] == \
- "Insulate your room roof with 220mm of Example room roof insulation"
- assert roof_recommender10.recommendations[1]["description"] == \
- "Insulate your room roof with 270mm of Example room roof insulation"
-
- def test_flat_no_insulation(self):
- property_instance11 = Property(id=11, address1="fake", postcode="fake", epc_client=Mock())
- property_instance11.age_band = "D"
- property_instance11.floor_area = 150
- property_instance11.roof = {
- 'original_description': 'Flat, no insulation (assumed)',
- 'clean_description': 'Flat, no insulation',
- 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
- 'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False,
- 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
- }
-
- roof_recommender11 = RoofRecommendations(
- property_instance=property_instance11, materials=flat_roof_insulation_materials
- )
-
- assert not roof_recommender11.recommendations
-
- roof_recommender11.recommend()
-
- assert len(roof_recommender11.recommendations) == 1
-
- assert roof_recommender11.recommendations[0]["parts"][0]["depths"] == [270]
-
- assert roof_recommender11.recommendations[0]["new_u_value"] == 0.11
-
- assert roof_recommender11.recommendations[0]["starting_u_value"] == 2.3
-
- assert roof_recommender11.recommendations[0]["description"] == \
- "Insulate the home's flat roof with 270mm of Example flat roof insulation"
-
- def test_flat_insulated(self):
- property_instance12 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock())
- property_instance12.age_band = "D"
- property_instance12.floor_area = 150
- property_instance12.roof = {
- 'original_description': 'Flat, insulated (assumed)',
- 'clean_description': 'Flat, insulated',
- 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
- 'is_roof_room': False,
- 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
- 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
- }
-
- roof_recommender12 = RoofRecommendations(
- property_instance=property_instance12, materials=flat_roof_insulation_materials
- )
-
- assert not roof_recommender12.recommendations
-
- roof_recommender12.recommend()
-
- assert not roof_recommender12.recommendations
-
- def test_flat_limited_insulation(self):
- property_instance13 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock())
- property_instance13.age_band = "D"
- property_instance13.floor_area = 150
- property_instance13.roof = {
- 'original_description': 'Flat, limited insulation (assumed)',
- 'clean_description': 'Flat, limited insulation',
- 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
- 'is_roof_room': False,
- 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
- 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average'
- }
-
- roof_recommender13 = RoofRecommendations(
- property_instance=property_instance13, materials=flat_roof_insulation_materials
- )
-
- assert not roof_recommender13.recommendations
-
- roof_recommender13.recommend()
-
- assert len(roof_recommender13.recommendations) == 1
-
- assert roof_recommender13.recommendations[0]["parts"][0]["depths"] == [220]
-
- assert roof_recommender13.recommendations[0]["new_u_value"] == 0.14
-
- assert roof_recommender13.recommendations[0]["starting_u_value"] == 2.3
-
- assert roof_recommender13.recommendations[0]["description"] == \
- "Insulate the home's flat roof with 220mm of Example flat roof insulation"
+ # def test_uninsulated_room_in_roof(self):
+ # property_instance7 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
+ # property_instance7.age_band = "F"
+ # property_instance7.insulation_floor_area = 100
+ # property_instance7.roof = {
+ # 'original_description': 'Roof room(s), no insulation (assumed)',
+ # 'clean_description': 'Roof room(s), no insulation',
+ # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
+ # 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
+ # 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
+ # }
+ #
+ # property_instance7.pitched_roof_area = 110
+ # property_instance7.data = {"county": "Southampton"}
+ #
+ # roof_recommender7 = RoofRecommendations(property_instance=property_instance7, materials=materials)
+ #
+ # assert not roof_recommender7.recommendations
+ #
+ # roof_recommender7.recommend()
+ #
+ # # Even though we have 3 depths, we only end with 1 due to diminishin returns
+ # assert len(roof_recommender7.recommendations) == 1
+ #
+ # assert roof_recommender7.recommendations[0]["parts"][0]["depths"] == [270]
+ #
+ # assert roof_recommender7.recommendations[0]["new_u_value"] == 0.14
+ # assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8
+ # assert roof_recommender7.recommendations[0]["description"] == \
+ # "Insulate your room roof with 270mm of Example room roof insulation"
+ #
+ # def test_ceiling_insulated_room_in_roof(self):
+ # property_instance8 = Property(id=8, address1="fake", postcode="fake", epc_client=Mock())
+ # property_instance8.age_band = "F"
+ # property_instance8.insulation_floor_area = 100
+ # property_instance8.roof = {
+ # 'original_description': 'Roof room(s), ceiling insulated',
+ # 'clean_description': 'Roof room(s), ceiling insulated',
+ # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
+ # 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
+ # 'is_at_rafters': False,
+ # 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
+ # 'insulation_thickness': 'average'
+ # }
+ #
+ # property_instance8.pitched_roof_area = 110
+ #
+ # roof_recommender8 = RoofRecommendations(property_instance=property_instance8, materials=materials)
+ #
+ # assert not roof_recommender8.recommendations
+ #
+ # roof_recommender8.recommend()
+ #
+ # # No recommendations in this case
+ # assert not roof_recommender8.recommendations
+ #
+ # def test_insulated_room_in_roof(self):
+ # property_instance9 = Property(id=9, address1="fake", postcode="fake", epc_client=Mock())
+ # property_instance9.age_band = "F"
+ # property_instance9.insulation_floor_area = 100
+ # property_instance9.roof = {
+ # 'original_description': 'Roof room(s), insulated (assumed)',
+ # 'clean_description': 'Roof room(s), insulated',
+ # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
+ # 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
+ # 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
+ # }
+ #
+ # property_instance9.pitched_roof_area = 110
+ # property_instance9.data = {"county": "Rutland"}
+ #
+ # roof_recommender9 = RoofRecommendations(property_instance=property_instance9, materials=materials)
+ #
+ # assert not roof_recommender9.recommendations
+ #
+ # roof_recommender9.recommend()
+ #
+ # # No recommendations in this case
+ # assert not roof_recommender9.recommendations
+ #
+ # def test_limited_insulated_room_in_roof(self):
+ # property_instance10 = Property(id=10, address1="fake", postcode="fake", epc_client=Mock())
+ # property_instance10.age_band = "F"
+ # property_instance10.insulation_floor_area = 100
+ # property_instance10.roof = {
+ # 'original_description': 'Roof room(s), limited insulation (assumed)',
+ # 'clean_description': 'Roof room(s), limited insulation',
+ # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
+ # 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
+ # 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
+ # 'insulation_thickness': 'below average'
+ # }
+ #
+ # property_instance10.pitched_roof_area = 110
+ # property_instance10.data = {"county": "Westmorland"}
+ #
+ # roof_recommender10 = RoofRecommendations(property_instance=property_instance10, materials=materials)
+ #
+ # assert not roof_recommender10.recommendations
+ #
+ # roof_recommender10.recommend()
+ #
+ # assert len(roof_recommender10.recommendations) == 2
+ #
+ # assert roof_recommender10.recommendations[0]["parts"][0]["depths"] == [220]
+ # assert roof_recommender10.recommendations[1]["parts"][0]["depths"] == [270]
+ #
+ # assert roof_recommender10.recommendations[0]["new_u_value"] == 0.16
+ # assert roof_recommender10.recommendations[1]["new_u_value"] == 0.14
+ #
+ # assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.8
+ # assert roof_recommender10.recommendations[1]["starting_u_value"] == 0.8
+ #
+ # assert roof_recommender10.recommendations[0]["description"] == \
+ # "Insulate your room roof with 220mm of Example room roof insulation"
+ # assert roof_recommender10.recommendations[1]["description"] == \
+ # "Insulate your room roof with 270mm of Example room roof insulation"
+ #
+ # def test_flat_no_insulation(self):
+ # property_instance11 = Property(id=11, address1="fake", postcode="fake", epc_client=Mock())
+ # property_instance11.age_band = "D"
+ # property_instance11.insulation_floor_area = 150
+ # property_instance11.roof = {
+ # 'original_description': 'Flat, no insulation (assumed)',
+ # 'clean_description': 'Flat, no insulation',
+ # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
+ # 'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False,
+ # 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
+ # }
+ # property_instance11.data = {"county": "Swindon"}
+ #
+ # roof_recommender11 = RoofRecommendations(property_instance=property_instance11, materials=materials)
+ #
+ # assert not roof_recommender11.recommendations
+ #
+ # roof_recommender11.recommend()
+ #
+ # assert len(roof_recommender11.recommendations) == 1
+ #
+ # assert roof_recommender11.recommendations[0]["parts"][0]["depths"] == [270]
+ #
+ # assert roof_recommender11.recommendations[0]["new_u_value"] == 0.11
+ #
+ # assert roof_recommender11.recommendations[0]["starting_u_value"] == 2.3
+ #
+ # assert roof_recommender11.recommendations[0]["description"] == \
+ # "Insulate the home's flat roof with 270mm of Example flat roof insulation"
+ #
+ # def test_flat_insulated(self):
+ # property_instance12 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock())
+ # property_instance12.age_band = "D"
+ # property_instance12.insulation_floor_area = 150
+ # property_instance12.roof = {
+ # 'original_description': 'Flat, insulated (assumed)',
+ # 'clean_description': 'Flat, insulated',
+ # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
+ # 'is_roof_room': False,
+ # 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
+ # 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
+ # }
+ # property_instance12.data = {"county": "Thurrock"}
+ #
+ # roof_recommender12 = RoofRecommendations(property_instance=property_instance12, materials=materials)
+ #
+ # assert not roof_recommender12.recommendations
+ #
+ # roof_recommender12.recommend()
+ #
+ # assert not roof_recommender12.recommendations
+ #
+ # def test_flat_limited_insulation(self):
+ # property_instance13 = Property(id=12, address1="fake", postcode="fake", epc_client=Mock())
+ # property_instance13.age_band = "D"
+ # property_instance13.insulation_floor_area = 150
+ # property_instance13.roof = {
+ # 'original_description': 'Flat, limited insulation (assumed)',
+ # 'clean_description': 'Flat, limited insulation',
+ # 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': False,
+ # 'is_roof_room': False,
+ # 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
+ # 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average'
+ # }
+ # property_instance13.data = {"county": "Tyne and Wear"}
+ #
+ # roof_recommender13 = RoofRecommendations(property_instance=property_instance13, materials=materials)
+ #
+ # assert not roof_recommender13.recommendations
+ #
+ # roof_recommender13.recommend()
+ #
+ # assert len(roof_recommender13.recommendations) == 1
+ #
+ # assert roof_recommender13.recommendations[0]["parts"][0]["depths"] == [220]
+ #
+ # assert roof_recommender13.recommendations[0]["new_u_value"] == 0.14
+ #
+ # assert roof_recommender13.recommendations[0]["starting_u_value"] == 2.3
+ #
+ # assert roof_recommender13.recommendations[0]["description"] == \
+ # "Insulate the home's flat roof with 220mm of Example flat roof insulation"
def test_property_above(self):
property_instance14 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())
property_instance14.age_band = "F"
- property_instance14.floor_area = 100
+ property_instance14.insulation_floor_area = 100
property_instance14.roof = {
'original_description': '(other premises above)',
'clean_description': '(other premises above)', 'thermal_transmittance': 0,
@@ -440,10 +372,9 @@ class TestRoofRecommendations:
'is_assumed': False, 'has_dwelling_above': True, 'is_valid': True,
'insulation_thickness': None
}
+ property_instance14.data = {"county": "Suffolk"}
- roof_recommender14 = RoofRecommendations(
- property_instance=property_instance14, materials=loft_insulation_materials
- )
+ roof_recommender14 = RoofRecommendations(property_instance=property_instance14, materials=materials)
assert not roof_recommender14.recommendations
diff --git a/recommendations/tests/test_ventilation_recommendations.py b/recommendations/tests/test_ventilation_recommendations.py
index 2dcaba57..893bb01a 100644
--- a/recommendations/tests/test_ventilation_recommendations.py
+++ b/recommendations/tests/test_ventilation_recommendations.py
@@ -1,15 +1,7 @@
from backend.Property import Property
from unittest.mock import Mock
from recommendations.VentilationRecommendations import VentilationRecommendations
-
-ventilation_materials = [
- {
- 'id': 17, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation',
- 'depths': None, 'depth_unit': None, 'cost': 500, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None,
- 'r_value_unit': None, 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
- 'link': None, 'is_active': True, 'estimated_cost': 1000, 'quantity': 2, 'quantity_unit': None
- }
-]
+from recommendations.tests.test_data.materials import materials
class TestVentilationRecommendations:
@@ -20,7 +12,7 @@ class TestVentilationRecommendations:
recommender = VentilationRecommendations(
property_instance=input_property1,
- materials=ventilation_materials
+ materials=materials
)
assert not recommender.recommendation
@@ -29,7 +21,7 @@ class TestVentilationRecommendations:
assert len(recommender.recommendation) == 1
- assert recommender.recommendation[0]["cost"] == 1000
+ assert recommender.recommendation[0]["total"] == 1000
assert recommender.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender.recommendation[0]["parts"]) == 1
assert recommender.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
@@ -41,7 +33,7 @@ class TestVentilationRecommendations:
recommender2 = VentilationRecommendations(
property_instance=input_property2,
- materials=ventilation_materials
+ materials=materials
)
assert not recommender2.recommendation
@@ -50,7 +42,7 @@ class TestVentilationRecommendations:
assert len(recommender2.recommendation) == 1
- assert recommender2.recommendation[0]["cost"] == 1000
+ assert recommender2.recommendation[0]["total"] == 1000
assert recommender2.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender2.recommendation[0]["parts"]) == 1
assert recommender2.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
@@ -62,7 +54,7 @@ class TestVentilationRecommendations:
recommender3 = VentilationRecommendations(
property_instance=input_property3,
- materials=ventilation_materials
+ materials=materials
)
assert not recommender3.recommendation
@@ -71,7 +63,7 @@ class TestVentilationRecommendations:
assert len(recommender3.recommendation) == 1
- assert recommender3.recommendation[0]["cost"] == 1000
+ assert recommender3.recommendation[0]["total"] == 1000
assert recommender3.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender3.recommendation[0]["parts"]) == 1
assert recommender3.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
@@ -83,7 +75,7 @@ class TestVentilationRecommendations:
recommender4 = VentilationRecommendations(
property_instance=input_property4,
- materials=ventilation_materials
+ materials=materials
)
assert not recommender4.recommendation
@@ -99,7 +91,7 @@ class TestVentilationRecommendations:
recommender5 = VentilationRecommendations(
property_instance=input_property5,
- materials=ventilation_materials
+ materials=materials
)
assert not recommender5.recommendation
diff --git a/recommendations/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py
index 3663364c..0258e592 100644
--- a/recommendations/tests/test_wall_recommendations.py
+++ b/recommendations/tests/test_wall_recommendations.py
@@ -6,202 +6,14 @@ from unittest.mock import Mock, MagicMock
from recommendations.WallRecommendations import WallRecommendations
from backend.Property import Property
from recommendations.recommendation_utils import is_diminishing_returns
+from recommendations.tests.test_data.materials import materials
+
# with open(
# os.path.abspath(os.path.dirname(__file__)) + "/recommendations/tests/test_data/input_properties.pkl", "rb"
# ) as f:
# input_properties = pickle.load(f)
-external_wall_insulation_parts = [
- {
- # Example product
- # https://insulationgo.co.uk/100mm-rockwool-external-wall-insulation-dual-density-slabs-a1-non-combustible
- # -slab-ewi-render-fire/
- "type": "external_wall_insulation",
- "description": "Mineral Wool External Wall Insulation",
- "depths": [30, 50, 70, 80, 90, 100, 150, 200],
- "depth_unit": "mm",
- "cost": [30, 50, 70, 80, 90, 100, 150, 200],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.0278,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.036,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- # Example product
- # https://www.insulationking.co.uk/products/polystyrene-eps70?variant=44156186558759
- "type": "external_wall_insulation",
- "description": "Expanded Polystyrene External Wall Insulation",
- "depths": [25, 50, 100, 125],
- "depth_unit": "mm",
- "cost": [25, 50, 100, 125],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.02703,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.037,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- # Example product
- # https://www.insulationshop.co/20mm_kooltherm_k5_external_wall_kingspan.html
- "type": "external_wall_insulation",
- "description": "Phenolic Foam External Wall Insulation",
- "depths": [20, 50, 100],
- "depth_unit": "mm",
- "cost": [20, 50, 100],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.043478260869565216,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.023,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
-
- },
- {
- "type": "external_wall_insulation",
- "description": "Polyisocyanurate/Polyurethane Foam External Wall Insulation",
- "depths": [],
- "depth_unit": "mm",
- "cost": [],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": None,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": None,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- # Example product
- # https://www.mikewye.co.uk/product/steico-duo-dry/
- "type": "external_wall_insulation",
- "description": "Wood Fiber External Wall Insulation",
- "depths": [40, 60],
- "depth_unit": "mm",
- "cost": [40, 60],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.023255813953488375,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.043,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- # Example product
- # https://www.thermablok.co.uk/site/wp-content/uploads/2022/09/Thermablok-Aerogel-Insulation-Blanket-TDS-AIS
- # -and-Steel-Related-Details.pdf
- "type": "external_wall_insulation",
- "description": "Aerogel External Wall Insulation",
- "depths": [10, 20, 30, 40, 50, 60, 70],
- "depth_unit": "mm",
- "cost": [10, 20, 30, 40, 50, 60, 70],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.06666666666666667,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.015,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- "type": "external_wall_insulation",
- "description": "Vacuum Insulation Panels External Wall Insulation",
- "depths": [45, 60],
- "depth_unit": "mm",
- "cost": [45, 60],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.16666666666666666,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.006,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- }
-]
-
-internal_wall_insulation_parts = [
- {
- # Example product
- # https://www.insulationshop.co/25mm_polystyrene_insulation_eps_70jablite.html
- "type": "internal_wall_insulation",
- "description": "Rigid Insulation Boards Internal Wall Insulation",
- "depths": [25, 40, 50, 75, 100],
- "depth_unit": "mm",
- "cost": [25, 40, 50, 75, 100],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.026315789473684213,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.038,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- # Example product
- # https://www.rockwool.com/siteassets/rw-uk/downloads/datasheets/flexi.pdf
- "type": "internal_wall_insulation",
- "description": "Mineral Wool Internal Wall Insulation",
- "depths": [140],
- "depth_unit": "mm",
- "cost": [140],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.02857142857142857,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.035,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- # Example product
- # https://www.kingspan.com/gb/en/products/insulation-boards/wall-insulation-boards/kooltherm-k118-insulated
- # -plasterboard/
- "type": "internal_wall_insulation",
- "description": "Insulated Plasterboard Internal Wall Insulation",
- "depths": [25, 80],
- "depth_unit": "mm",
- "cost": [25, 80],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.02857142857142857,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.019,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- "type": "internal_wall_insulation",
- "description": "Reflective Internal Wall Insulation",
- "depths": [],
- "depth_unit": "mm",
- "cost": [],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": None,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": None,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
- {
- # Example product
- # https://www.insulationsuperstore.co.uk/product/vacutherm-vacupor-nt-b2-vacuum-insulated-panel-1m-x-600mm-x
- # -30mm.html
- "type": "internal_wall_insulation",
- "description": "Vacuum Insulation Panels Wall Insulation",
- "depths": [20, 30],
- "depth_unit": "mm",
- "cost": [20, 30],
- "cost_unit": "gbp_sq_meter",
- "r_value_per_mm": 0.125,
- "r_value_unit": "square_meter_kelvin_per_watt",
- "thermal_conductivity": 0.008,
- "thermal_conductivity_unit": "watt_per_meter_kelvin"
- },
-]
-
-cavity_wall_insulation_parts = [
- {'id': 4, 'type': 'cavity_wall_insulation', 'description': 'Example Material 1',
- 'depths': None,
- 'depth_unit': None, 'cost': 20,
- 'cost_unit': 'gbp_sq_meter', 'r_value_per_mm': 0.0278, 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': 0.036, 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
- 'link': None, 'created_at': None, 'is_active': True},
- {'id': 10, 'type': "cavity_wall_insulation", 'description': 'Example Material 2',
- 'depths': None, 'depth_unit': None, 'cost': 25, 'cost_unit': 'gbp_sq_meter',
- 'r_value_per_mm': 0.02631579, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
- 'link': None,
- 'created_at': None, 'is_active': True}
-]
-
-wall_parts = external_wall_insulation_parts + internal_wall_insulation_parts + cavity_wall_insulation_parts
-
class TestWallRecommendations:
@@ -217,17 +29,20 @@ class TestWallRecommendations:
# Creating a mock instance of WallRecommendations with the necessary attributes
property_mock = Mock()
property_mock.full_sap_epc = {"lodgement-date": "2000-01-01"} # or any date you want
- property_mock.data = {"construction-age-band": "1950"} # or any other data that fits your tests
+ property_mock.data = {"construction-age-band": "1950",
+ "county": "Derbyshire"} # or any other data that fits your tests
mock_wall_rec_instance = WallRecommendations(
- property_mock, materials=wall_parts
+ property_mock, materials=materials
)
return mock_wall_rec_instance
def test_init(self, input_properties):
+ input_properties[0].insulation_wall_area = 100
+
obj = WallRecommendations(
property_instance=input_properties[0],
- materials=wall_parts
+ materials=materials
)
assert obj
assert obj.property
@@ -244,10 +59,11 @@ class TestWallRecommendations:
input_properties[0].year_built = 2014
input_properties[0].in_conservation_area = None
input_properties[0].restricted_measures = False
+ input_properties[0].insulation_wall_area = 100
recommender = WallRecommendations(
property_instance=input_properties[0],
- materials=wall_parts
+ materials=materials
)
assert recommender.property.walls["original_description"] == "Average thermal transmittance 0.16 W/m-¦K"
recommender.recommend()
@@ -272,7 +88,7 @@ class TestWallRecommendations:
recommender = WallRecommendations(
property_instance=input_properties[1],
- materials=wall_parts
+ materials=materials
)
assert recommender.property.walls["original_description"] == "Solid brick, as built, no insulation (assumed)"
assert not recommender.ewi_valid
@@ -306,9 +122,11 @@ class TestWallRecommendations:
input_properties[6].year_built = 1991
input_properties[6].restricted_measures = False
+ input_properties[6].insulation_wall_area = 100
+
recommender = WallRecommendations(
property_instance=input_properties[6],
- materials=wall_parts
+ materials=materials
)
assert recommender.property.walls["original_description"] == "Solid brick, as built, insulated (assumed)"
@@ -383,12 +201,14 @@ class TestWallRecommendationsBase:
property_mock.full_sap_epc = {"lodgement-date": "1999-12-31"}
property_mock.in_conservation_area = "not_in_conservation_area"
property_mock.restricted_measures = False
+ property_mock.insulation_wall_area = 100
+ property_mock.data = {"county": "Derbyshire"}
return property_mock
@pytest.fixture
def wall_recommendations_instance(self, property_mock):
wall_recommendations_instance = WallRecommendations(
- property_mock, materials=wall_parts
+ property_mock, materials=materials
)
return wall_recommendations_instance
@@ -425,10 +245,11 @@ class TestCavityWallRecommensations:
}
input_property.age_band = "C"
input_property.insulation_wall_area = 50
+ input_property.data = {"county": "Derbyshire"}
recommender = WallRecommendations(
property_instance=input_property,
- materials=cavity_wall_insulation_parts
+ materials=materials
)
assert not recommender.recommendations
@@ -437,11 +258,11 @@ class TestCavityWallRecommensations:
assert recommender.recommendations
assert recommender.estimated_u_value == 1.5
- assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.37)
- assert np.isclose(recommender.recommendations[0]["cost"], 1000)
+ assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.35)
+ assert np.isclose(recommender.recommendations[0]["total"], 1668.6600000000003)
- assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.38)
- assert np.isclose(recommender.recommendations[1]["cost"], 1250)
+ assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.35)
+ assert np.isclose(recommender.recommendations[1]["total"], 2004.6600000000003)
def test_fill_partial_filled_cavity(self):
input_property = Property(id=1, postcode="F4k3", address1="123 fake street", epc_client=Mock())
@@ -458,10 +279,11 @@ class TestCavityWallRecommensations:
}
input_property.age_band = "C"
input_property.insulation_wall_area = 50
+ input_property.data = {"county": "County Durham"}
recommender = WallRecommendations(
property_instance=input_property,
- materials=cavity_wall_insulation_parts
+ materials=materials
)
assert not recommender.recommendations
@@ -470,11 +292,11 @@ class TestCavityWallRecommensations:
assert recommender.recommendations
assert recommender.estimated_u_value == 1.3
- assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.43)
- assert np.isclose(recommender.recommendations[0]["cost"], 1000)
+ assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.41)
+ assert np.isclose(recommender.recommendations[0]["total"], 1663.9350000000002)
- assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.45)
- assert np.isclose(recommender.recommendations[1]["cost"], 1250)
+ assert np.isclose(recommender.recommendations[1]["new_u_value"], 0.41)
+ assert np.isclose(recommender.recommendations[1]["total"], 1999.9350000000002)
def test_system_built_wall(self):
input_property2 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
@@ -492,13 +314,13 @@ class TestCavityWallRecommensations:
input_property2.age_band = "F"
input_property2.insulation_wall_area = 120
input_property2.restricted_measures = False
- input_property2.data = {"property-type": "house"}
+ input_property2.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Detached"}
assert input_property2.walls["is_system_built"]
recommender2 = WallRecommendations(
property_instance=input_property2,
- materials=internal_wall_insulation_parts + external_wall_insulation_parts
+ materials=materials
)
assert not recommender2.recommendations
@@ -506,22 +328,22 @@ class TestCavityWallRecommensations:
recommender2.recommend()
assert recommender2.recommendations
- assert len(recommender2.recommendations) == 6
+ assert len(recommender2.recommendations) == 9
assert recommender2.estimated_u_value == 1
- assert np.isclose(recommender2.recommendations[0]["new_u_value"], 0.29)
- assert np.isclose(recommender2.recommendations[0]["cost"], 10800)
+ assert np.isclose(recommender2.recommendations[0]["new_u_value"], 0.19)
+ assert np.isclose(recommender2.recommendations[0]["total"], 15899.9616)
assert recommender2.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender2.recommendations[0]["parts"][0]["depths"] == [90]
+ assert recommender2.recommendations[0]["parts"][0]["depth"] == 100
- assert np.isclose(recommender2.recommendations[5]["new_u_value"], 0.29)
- assert np.isclose(recommender2.recommendations[5]["cost"], 2400)
- assert recommender2.recommendations[5]["parts"][0]["type"] == "internal_wall_insulation"
- assert recommender2.recommendations[5]["parts"][0]["depths"] == [20]
+ assert np.isclose(recommender2.recommendations[8]["new_u_value"], 0.23)
+ assert np.isclose(recommender2.recommendations[8]["total"], 10916.3424)
+ assert recommender2.recommendations[8]["parts"][0]["type"] == "internal_wall_insulation"
+ assert recommender2.recommendations[8]["parts"][0]["depth"] == 72.5
- assert np.isclose(recommender2.recommendations[3]["new_u_value"], 0.28)
- assert np.isclose(recommender2.recommendations[3]["cost"], 4800)
- assert recommender2.recommendations[3]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender2.recommendations[3]["parts"][0]["depths"] == [40]
+ assert np.isclose(recommender2.recommendations[6]["new_u_value"], 0.29)
+ assert np.isclose(recommender2.recommendations[6]["total"], 10621.934399999998)
+ assert recommender2.recommendations[6]["parts"][0]["type"] == "internal_wall_insulation"
+ assert recommender2.recommendations[6]["parts"][0]["depth"] == 52.5
def test_timber_frame_wall(self):
input_property3 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
@@ -539,13 +361,13 @@ class TestCavityWallRecommensations:
input_property3.age_band = "B"
input_property3.insulation_wall_area = 99
input_property3.restricted_measures = False
- input_property3.data = {"property-type": "house"}
+ input_property3.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Semi-Detached"}
assert input_property3.walls["is_timber_frame"]
recommender3 = WallRecommendations(
property_instance=input_property3,
- materials=internal_wall_insulation_parts + external_wall_insulation_parts
+ materials=materials
)
assert not recommender3.recommendations
@@ -553,17 +375,17 @@ class TestCavityWallRecommensations:
recommender3.recommend()
assert recommender3.recommendations
- assert len(recommender3.recommendations) == 2
+ assert len(recommender3.recommendations) == 6
assert recommender3.estimated_u_value == 1.9
- assert np.isclose(recommender3.recommendations[0]["new_u_value"], 0.26)
- assert np.isclose(recommender3.recommendations[0]["cost"], 12375)
+ assert np.isclose(recommender3.recommendations[0]["new_u_value"], 0.2)
+ assert np.isclose(recommender3.recommendations[0]["total"], 13117.46832)
assert recommender3.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender3.recommendations[0]["parts"][0]["depths"] == [125]
+ assert recommender3.recommendations[0]["parts"][0]["depth"] == 100.0
- assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.26)
- assert np.isclose(recommender3.recommendations[1]["cost"], 4950)
+ assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.23)
+ assert np.isclose(recommender3.recommendations[1]["total"], 34070.50944)
assert recommender3.recommendations[1]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender3.recommendations[1]["parts"][0]["depths"] == [50]
+ assert recommender3.recommendations[1]["parts"][0]["depth"] == 150.0
def test_granite_or_whinstone_wall(self):
input_property4 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
@@ -581,13 +403,13 @@ class TestCavityWallRecommensations:
input_property4.age_band = "A"
input_property4.insulation_wall_area = 223
input_property4.restricted_measures = False
- input_property4.data = {"property-type": "Bungalow"}
+ input_property4.data = {"property-type": "Bungalow", "county": "Derbyshire", "built-form": "Detached"}
assert input_property4.walls["is_granite_or_whinstone"]
recommender4 = WallRecommendations(
property_instance=input_property4,
- materials=internal_wall_insulation_parts + external_wall_insulation_parts
+ materials=materials
)
assert not recommender4.recommendations
@@ -595,17 +417,17 @@ class TestCavityWallRecommensations:
recommender4.recommend()
assert recommender4.recommendations
- assert len(recommender4.recommendations) == 2
+ assert len(recommender4.recommendations) == 6
assert recommender4.estimated_u_value == 2.3
- assert np.isclose(recommender4.recommendations[0]["new_u_value"], 0.27)
- assert np.isclose(recommender4.recommendations[0]["cost"], 27875)
+ assert np.isclose(recommender4.recommendations[0]["new_u_value"], 0.21)
+ assert np.isclose(recommender4.recommendations[0]["total"], 28562.514352)
assert recommender4.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender4.recommendations[0]["parts"][0]["depths"] == [125]
+ assert recommender4.recommendations[0]["parts"][0]["depth"] == 100
- assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.27)
- assert np.isclose(recommender4.recommendations[1]["cost"], 11150)
+ assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.23)
+ assert np.isclose(recommender4.recommendations[1]["total"], 74186.52678400002)
assert recommender4.recommendations[1]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender4.recommendations[1]["parts"][0]["depths"] == [50]
+ assert recommender4.recommendations[1]["parts"][0]["depth"] == 150
def test_cob_wall(self):
input_property5 = Property(id=1, postcode="F4k3 2", address1="223 fake street", epc_client=Mock())
@@ -623,13 +445,13 @@ class TestCavityWallRecommensations:
input_property5.age_band = "E"
input_property5.insulation_wall_area = 77
input_property5.restricted_measures = False
- input_property5.data = {"property-type": "Bungalow"}
+ input_property5.data = {"property-type": "Bungalow", "county": "Derbyshire", "built-form": "Detached"}
assert input_property5.walls["is_cob"]
recommender5 = WallRecommendations(
property_instance=input_property5,
- materials=internal_wall_insulation_parts + external_wall_insulation_parts
+ materials=materials
)
assert not recommender5.recommendations
@@ -637,22 +459,17 @@ class TestCavityWallRecommensations:
recommender5.recommend()
assert recommender5.recommendations
- assert len(recommender5.recommendations) == 9
+ assert len(recommender5.recommendations) == 5
assert recommender5.estimated_u_value == 0.8
assert np.isclose(recommender5.recommendations[0]["new_u_value"], 0.29)
- assert np.isclose(recommender5.recommendations[0]["cost"], 6160)
+ assert np.isclose(recommender5.recommendations[0]["total"], 8665.040384000002)
assert recommender5.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender5.recommendations[0]["parts"][0]["depths"] == [80]
+ assert recommender5.recommendations[0]["parts"][0]["depth"] == 50
assert np.isclose(recommender5.recommendations[3]["new_u_value"], 0.26)
- assert np.isclose(recommender5.recommendations[3]["cost"], 7700)
- assert recommender5.recommendations[3]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender5.recommendations[3]["parts"][0]["depths"] == [100]
-
- assert np.isclose(recommender5.recommendations[6]["new_u_value"], 0.26)
- assert np.isclose(recommender5.recommendations[6]["cost"], 7700)
- assert recommender5.recommendations[6]["parts"][0]["type"] == "internal_wall_insulation"
- assert recommender5.recommendations[6]["parts"][0]["depths"] == [100]
+ assert np.isclose(recommender5.recommendations[3]["total"], 20078.742992)
+ assert recommender5.recommendations[3]["parts"][0]["type"] == "internal_wall_insulation"
+ assert recommender5.recommendations[3]["parts"][0]["depth"] == 100
def test_sandstone_or_limestone_wall(self):
input_property6 = Property(id=1, postcode="F4k3 6", address1="623 fake street", epc_client=Mock())
@@ -670,13 +487,13 @@ class TestCavityWallRecommensations:
input_property6.age_band = "F"
input_property6.insulation_wall_area = 350
input_property6.restricted_measures = False
- input_property6.data = {"property-type": "House"}
+ input_property6.data = {"property-type": "House", "county": "Derbyshire", "built-form": "Mid-Terrace"}
assert input_property6.walls["is_sandstone_or_limestone"]
recommender6 = WallRecommendations(
property_instance=input_property6,
- materials=internal_wall_insulation_parts + external_wall_insulation_parts
+ materials=materials
)
assert not recommender6.recommendations
@@ -684,19 +501,19 @@ class TestCavityWallRecommensations:
recommender6.recommend()
assert recommender6.recommendations
- assert len(recommender6.recommendations) == 6
+ assert len(recommender6.recommendations) == 9
assert recommender6.estimated_u_value == 1
- assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.29)
- assert np.isclose(recommender6.recommendations[0]["cost"], 31500)
+ assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.19)
+ assert np.isclose(recommender6.recommendations[0]["total"], 44829.0584)
assert recommender6.recommendations[0]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender6.recommendations[0]["parts"][0]["depths"] == [90]
+ assert recommender6.recommendations[0]["parts"][0]["depth"] == 100
- assert np.isclose(recommender6.recommendations[2]["new_u_value"], 0.28)
- assert np.isclose(recommender6.recommendations[2]["cost"], 35000)
+ assert np.isclose(recommender6.recommendations[2]["new_u_value"], 0.21)
+ assert np.isclose(recommender6.recommendations[2]["total"], 116436.25280000002)
assert recommender6.recommendations[2]["parts"][0]["type"] == "external_wall_insulation"
- assert recommender6.recommendations[2]["parts"][0]["depths"] == [100]
+ assert recommender6.recommendations[2]["parts"][0]["depth"] == 150
assert np.isclose(recommender6.recommendations[4]["new_u_value"], 0.28)
- assert np.isclose(recommender6.recommendations[4]["cost"], 35000)
+ assert np.isclose(recommender6.recommendations[4]["total"], 91267.0136)
assert recommender6.recommendations[4]["parts"][0]["type"] == "internal_wall_insulation"
- assert recommender6.recommendations[4]["parts"][0]["depths"] == [100]
+ assert recommender6.recommendations[4]["parts"][0]["depth"] == 100