From fb71042c8f948e8a2e2c3306d5294e220c23802f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 4 Dec 2023 21:30:32 +0000 Subject: [PATCH] Flat roof costs - will need review --- backend/app/db/models/materials.py | 4 + etl/costs/app.py | 4 +- recommendations/Costs.py | 90 ++++++++++ recommendations/RoofRecommendations.py | 32 +++- recommendations/county_to_region.py | 1 + recommendations/recommendation_utils.py | 10 +- recommendations/tests/test_costs.py | 81 +++++++++ recommendations/tests/test_data/materials.py | 116 +++++++++++- .../tests/test_recommendation_utils.py | 27 +-- .../tests/test_roof_recommendations.py | 168 +++++++++--------- 10 files changed, 432 insertions(+), 101 deletions(-) diff --git a/backend/app/db/models/materials.py b/backend/app/db/models/materials.py index 64c5e166..1a41f14f 100644 --- a/backend/app/db/models/materials.py +++ b/backend/app/db/models/materials.py @@ -33,6 +33,9 @@ class MaterialType(enum.Enum): ewi_wall_preparation = "ewi_wall_preparation" ewi_wall_redecoration = "ewi_wall_redecoration" low_energy_lighting_installation = "low_energy_lighting_installation" + flat_roof_preparation = "flat_roof_preparation" + flat_roof_vapour_barrier = "flat_roof_vapour_barrier" + flat_roof_waterpoofing = "flat_roof_waterpoofing" class DepthUnit(enum.Enum): @@ -43,6 +46,7 @@ class CostUnit(enum.Enum): gbp_sq_meter = "gbp_sq_meter" gbp_per_unit = "gbp_per_unit" gbp_per_m2 = "gbp_per_m2" + gbp_per_m = "gbp_per_m" class RValueUnit(enum.Enum): diff --git a/etl/costs/app.py b/etl/costs/app.py index 98a324bc..4d53ce28 100644 --- a/etl/costs/app.py +++ b/etl/costs/app.py @@ -74,6 +74,7 @@ def app(): 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) + flat_roof_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="flat_roof_insulation", header=0) # Form a single table to be uploaded costs = pd.concat( @@ -84,7 +85,8 @@ def app(): suspended_floor_costs, solid_floor_costs, ewi_costs, - lel_costs + lel_costs, + flat_roof_costs ] ) diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 02d26c14..a57092f9 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -36,6 +36,10 @@ class Costs: # We assume a conservative 10% contingency for all works which is a rate defined by SPONs CONTINGENCY = 0.1 + # For flat roof, we assume it's a high risk project as it's very weather dependent and also is heavily + # dependent on the quality of the existing roof + FLAT_ROOF_CONTINGENCY = 0.15 + # 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 @@ -627,3 +631,89 @@ class Costs: "labour_days": labour_days, "labour_cost": labour_cost } + + def flat_roof_insulation(self, floor_area, material, non_insulation_materials): + """ + A model of a warm, flat roof construction can be seen in this video: + https://www.youtube.com/watch?v=WZ6Ng6YI9OA + Warm, flat roof insulation will normally be 100-125mm in depth + + We break this measure down into the following jobs to be done + 1) Preparation of the room. This involves cleaning the existing roof surface, removing any debris and repairing + any damage. Additionally, an edge barrier will likely need to be installed, to protect the sides of the + roof from water ingress. + 2) Primer Application. A layer of primer is applied to the clean roof surface to enhance the adhestia of + subsequent layers, and seal the existing roof surface. + 3) Vapour Proof Layer Installation. Lay a vapour control layer to prevent moisture ingress from inside the + building, which is essential in warm roof construction. + 4) Insulation Layer Application. Place and securely fix insulation boards over the roof. These could be rigid + boards like PIR (Polyisocyanurate). + 5) Waterproofing Membrane Installation: Cover the insulation (and timber layer, if used) with a + waterproofing membrane, like EPDM, PVC, or bituminous felt. Carefully seal all joints, edges, and around any + roof penetrations to ensure water tightness + + :param floor_area: Area of the flat roof to be insulated, based on the area of the floor + :param material: Selected insulation material + :param non_insulation_materials: Non-insulation materials required for the job + :return: + """ + + preparation_data_m2 = [ + x for x in non_insulation_materials if + (x["type"] == "flat_roof_preparation") and (x["cost_unit"] == "gbp_per_m2") + ] + vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "flat_roof_vapour_barrier"] + waterproofing_data = [x for x in non_insulation_materials if x["type"] == "flat_roof_waterproofing"] + + if (len(preparation_data_m2) != 2) or (len(vapour_barrier_data) != 1) or ( + len(waterproofing_data) != 1): + raise ValueError("Incorrect number of data entries for non-insulation materials") + + # Break out the individual material costs + preparation_m2_material_costs = sum([x["material_cost"] * floor_area for x in preparation_data_m2]) + vapour_barrier_material_costs = vapour_barrier_data[0]["material_cost"] * floor_area + insulation_material_costs = material["material_cost"] * floor_area + + preparation_m2_labour_costs = sum([x["labour_cost"] * floor_area for x in preparation_data_m2]) + vapour_barrier_labour_costs = vapour_barrier_data[0]["labour_cost"] * floor_area + + # For waterproofing and upstand, we only have a total cost + waterproofing_total_costs = waterproofing_data[0]["total_cost"] * floor_area + + labour_costs = preparation_m2_labour_costs + vapour_barrier_labour_costs + labour_costs = labour_costs * self.labour_adjustment_factor + + materials_costs = preparation_m2_material_costs + vapour_barrier_material_costs + insulation_material_costs + + subtotal_before_profit = labour_costs + materials_costs + waterproofing_total_costs + + contingency_cost = subtotal_before_profit * self.FLAT_ROOF_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 + + preparation_m2_labour_hours = sum([x["labour_hours_per_unit"] * floor_area for x in preparation_data_m2]) + vapour_barrier_labour_hours = vapour_barrier_data[0]["labour_hours_per_unit"] * floor_area + waterproofing_labour_hours = waterproofing_data[0]["labour_hours_per_unit"] * floor_area + + labour_hours = preparation_m2_labour_hours + vapour_barrier_labour_hours + waterproofing_labour_hours + + # To install flat roof insulation, assume a small/medium project might be conducted by a team of 2-4. + # We'll assume a team of 2 since a lot of the roofs will be on the smaller side and will review this later + labour_days = (labour_hours / 8) / 2 + + return { + "total": total_cost, + "subtotal": subtotal_before_vat, + "vat": vat_cost, + "contingency": contingency_cost, + "preliminaries": preliminaries_cost, + "material": materials_costs, + "profit": profit_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + "labour_cost": labour_costs + } diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index 07eeb1e5..dc1aff3f 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -22,6 +22,8 @@ class RoofRecommendations: # It is recommended that lofts should have at least 270mm of insulation MINIMUM_LOFT_ISULATION_MM = 270 + # Flat roof should have at least 100mm of insulation + MINIMUM_FLAT_ROOF_ISULATION_MM = 100 def __init__( self, @@ -41,6 +43,16 @@ class RoofRecommendations: ] self.loft_non_insulation_materials = [] + self.flat_roof_insulation_materials = [ + part for part in materials if part["type"] == "flat_roof_insulation" + ] + + self.flat_roof_non_insulation_materials = [ + part for part in materials if part["type"] in [ + "flat_roof_preparation", "flat_roof_vapour_barrier", "flat_roof_waterproofing" + ] + ] + def recommend(self): if self.property.roof["has_dwelling_above"]: @@ -50,17 +62,24 @@ class RoofRecommendations: insulation_thickness = convert_thickness_to_numeric( self.property.roof["insulation_thickness"], - self.property.roof["is_pitched"] + self.property.roof["is_pitched"], + self.property.roof["is_flat"] ) # We check if the roof is already insulated and if so, we exit # Building regulations part L recommend installing at least 270mm of insulation, however generally we # experience diminishing returns in terms of SAP once we go beyond around 150mm of insulation - # This only holds true for pitched roofs + # This only holds true for pitched roofs. if (insulation_thickness >= self.MINIMUM_LOFT_ISULATION_MM) and self.property.roof["is_pitched"]: return + if (insulation_thickness >= self.MINIMUM_FLAT_ROOF_ISULATION_MM) and self.property.roof["is_flat"]: + return + + if self.property.roof["is_roof_room"]: + raise ValueError("Update convert_thickness_to_numeric for room roof and implement") + # If we have a u-value already, need to implement this if u_value: if u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: @@ -139,7 +158,8 @@ class RoofRecommendations: insulation_materials = self.loft_insulation_materials non_insulation_materials = self.loft_non_insulation_materials elif roof["is_flat"]: - raise ValueError("UPDATE ME") + insulation_materials = self.flat_roof_insulation_materials + non_insulation_materials = self.flat_roof_non_insulation_materials else: raise ValueError("Roof is not pitched or flat") @@ -186,7 +206,11 @@ class RoofRecommendations: material=material ) elif material["type"] == "flat_roof_insulation": - raise ValueError("COMPLETE ME") + cost_result = self.costs.flat_roof_insulation( + floor_area=self.property.insulation_floor_area, + material=material, + non_insulation_materials=non_insulation_materials + ) else: raise ValueError("Invalid material type") diff --git a/recommendations/county_to_region.py b/recommendations/county_to_region.py index a881ea01..7ca86715 100644 --- a/recommendations/county_to_region.py +++ b/recommendations/county_to_region.py @@ -157,6 +157,7 @@ county_to_region_map = { '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', + 'Westmorland': 'North West England', # Additional mappings requried, based on what we find in the EPC database 'Greater London Authority': 'Inner London', diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 5bd77a2a..100ecb15 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -573,7 +573,7 @@ def calculate_r_value_per_mm(thickness_mm, thermal_conductivity_w_mK): return r_value_per_mm -def convert_thickness_to_numeric(string_thickness, is_pitched): +def convert_thickness_to_numeric(string_thickness, is_pitched, is_flat): """ Roof insulation thickness could be a string like "None", "300mm+" or a numeric string. This function will convert these strings to a number for easy usage @@ -597,6 +597,14 @@ def convert_thickness_to_numeric(string_thickness, is_pitched): "average": 100, "above average": 270 } + elif is_flat: + # For a flat roof, if it's below average, we assume it's 0 and requires a re-roof + lookup = { + "none": 0, + "below average": 0, + "average": 100, + "above average": 150 + } else: lookup = { "none": 0, diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py index 2854b298..1d66ff47 100644 --- a/recommendations/tests/test_costs.py +++ b/recommendations/tests/test_costs.py @@ -1,5 +1,6 @@ from recommendations.Costs import Costs from unittest.mock import Mock +import datetime class TestCosts: @@ -418,3 +419,83 @@ class TestCosts: 'profit': 1617.9654432399325, 'labour_hours': 187.02533486285358, 'labour_days': 5.8445417144641745, 'labour_cost': 3921.5600094613983 } + + def test_flat_roof_insulation(self): + mock_property = Mock() + mock_property.data = { + "county": "Northamptonshire" + } + + costs = Costs(mock_property) + flat_roof_material = {'id': 1225, 'type': 'flat_roof_insulation', + 'description': 'Kingspan Thermaroof TR21 zero OPD ' + 'urethene insulation board', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, + 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.025, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'SPONs', + 'created_at': "now", 'is_active': True, + 'prime_material_cost': None, 'material_cost': 50.95, + 'labour_cost': 10.66, 'labour_hours_per_unit': 0.48, + 'plant_cost': 0.0, 'total_cost': 61.61, + 'notes': "SPONs didn't have a labour hours so we use " + "0.48 which is similar to other materials"} + + flat_roof_non_insulation_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': 1221, 'type': 'flat_roof_preparation', + 'description': 'clean surface to receive new damp-proof membrane', + '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, 12, 4, 20, 1, 49, 298076), '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': 'This data is based on concrete however forms a decent baseline for a Bituminous Felt flat roof'}, + {'id': 1223, 'type': 'flat_roof_preparation', + 'description': 'One coat primer; on wood surfaces before fixing; General surfaces; over 300 mm girth', + '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, 12, 4, 20, 1, 49, 298076), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 2.49, 'labour_cost': 1.5, 'labour_hours_per_unit': 0.08, + 'plant_cost': 0.0, 'total_cost': 3.99, 'notes': 'SPONs data gives us a baseline for a wood surface'}, + {'id': 1224, 'type': 'flat_roof_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', + '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, 12, 4, 20, 1, 49, 298076), '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': 1234, 'type': 'flat_roof_waterproofing', + 'description': '20 mm thick two coat coverings; felt isolating membrane; to concrete (or ' + 'timber) base; flat or to falls or slopes not exceeding 10° from horizontal', + '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, 12, 4, 20, 1, 49, 298076), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.5, 'plant_cost': 0.0, 'total_cost': 31.13, 'notes': None} + ] + + flat_roof_floor_results = costs.flat_roof_insulation( + floor_area=33.5, + material=flat_roof_material, + non_insulation_materials=flat_roof_non_insulation_materials + ) + + assert flat_roof_floor_results == {'total': 5325.327767999999, 'subtotal': 4437.773139999999, + 'vat': 887.5546279999999, 'contingency': 459.07998, + 'preliminaries': 306.05332, 'material': 1830.775, 'profit': 612.10664, + 'labour_hours': 24.79, 'labour_days': 1.549375, 'labour_cost': 186.9032} + + assert costs.labour_adjustment_factor == 0.88 diff --git a/recommendations/tests/test_data/materials.py b/recommendations/tests/test_data/materials.py index c0f434a5..d7241be5 100644 --- a/recommendations/tests/test_data/materials.py +++ b/recommendations/tests/test_data/materials.py @@ -7,6 +7,119 @@ materials = [ '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': 1221, 'type': 'flat_roof_preparation', 'description': 'clean surface to receive new damp-proof membrane', + '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, 12, 4, 20, 1, 49, 298076), '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': 'This data is based on concrete however forms a decent baseline for a Bituminous Felt flat roof'}, + {'id': 1223, 'type': 'flat_roof_preparation', + 'description': 'One coat primer; on wood surfaces before fixing; General surfaces; over 300 mm girth', + '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, 12, 4, 20, 1, 49, 298076), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 2.49, 'labour_cost': 1.5, 'labour_hours_per_unit': 0.08, + 'plant_cost': 0.0, 'total_cost': 3.99, 'notes': 'SPONs data gives us a baseline for a wood surface'}, + {'id': 1224, 'type': 'flat_roof_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', + '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, 12, 4, 20, 1, 49, 298076), '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': 1225, 'type': 'flat_roof_insulation', + 'description': 'Kingspan Thermaroof TR21 zero OPD ' + 'urethene insulation board', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, + 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.04, + 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.025, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 12, 4, 20, 1, 49, + 298076), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 50.95, + 'labour_cost': 10.66, 'labour_hours_per_unit': 0.48, + 'plant_cost': 0.0, 'total_cost': 61.61, + 'notes': "SPONs didn't have a labour hours so we use " + "0.48 which is similar to other materials"}, + {'id': 1226, 'type': 'flat_roof_insulation', 'description': 'Ravatherm XPS × 500 SL', 'depth': 100.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, 12, 4, 20, 1, 49, 298076), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 22.14, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.48, 'plant_cost': 0.0, 'total_cost': 32.8, + 'notes': None}, + {'id': 1227, 'type': 'flat_roof_insulation', 'description': 'Ravatherm XPS × 500 SL', 'depth': 120.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': 'https://www.panelsystems.co.uk/product/floormate-ravatherm-sb?attribute_pa_group=floormate-500a' + '&attribute_pa_product-name=ravatherm-xps-x-500-sl&attribute_pa_length=1250&attribute_pa_width=600' + '&attribute_pa_thickness=120&attribute_pa_unit-of-sale=pack-3-brds&attribute_pa_min-order-qty=10&gclid' + '=CjwKCAiAjrarBhAWEiwA2qWdCKJK2iqlzUZ-mBFOfCLy2f5TldAbOj7G3LrvYw5JLaigplJAajzYpRoCtB8QAvD_BwE', + 'created_at': datetime.datetime(2023, 12, 4, 20, 1, 49, 298076), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 26.187656, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.48, 'plant_cost': 0.0, + 'total_cost': 36.847656, + 'notes': "SPONs didn't have this thickness, so the material price is based on the fact that on the link, " + "the 120mm thickness is 18% more expensive per board than the 100mm thickness"}, + {'id': 1228, 'type': 'flat_roof_insulation', 'description': 'Ravatherm XPS × 500 SL', 'depth': 140.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': 'https://www.panelsystems.co.uk/product/floormate-ravatherm-sb?attribute_pa_group=floormate-500a' + '&attribute_pa_product-name=ravatherm-xps-x-500-sl&attribute_pa_length=1250&attribute_pa_width=600' + '&attribute_pa_thickness=120&attribute_pa_unit-of-sale=pack-3-brds&attribute_pa_min-order-qty=10&gclid' + '=CjwKCAiAjrarBhAWEiwA2qWdCKJK2iqlzUZ-mBFOfCLy2f5TldAbOj7G3LrvYw5JLaigplJAajzYpRoCtB8QAvD_BwE', + 'created_at': datetime.datetime(2023, 12, 4, 20, 1, 49, 298076), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 31.114737, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.48, 'plant_cost': 0.0, + 'total_cost': 41.77474, + 'notes': "SPONs didn't have this thickness, so the material price is based on the fact that on the link, " + "the 140mm thickness is 40% more expensive per board than the 100mm thickness"}, + {'id': 1229, 'type': 'flat_roof_insulation', 'description': 'Foamglas T3+ Flat Roof Insulation', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.027777778, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.036, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 12, 4, 20, 1, 49, 298076), 'is_active': True, 'prime_material_cost': 95.83, + 'material_cost': 109.09, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, + 'total_cost': 139.79, 'notes': None}, + {'id': 1230, 'type': 'flat_roof_insulation', 'description': 'Foamglas T4+ Flat Roof Insulation', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.024390243, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.041, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2023, 12, 4, 20, 1, 49, 298076), 'is_active': True, 'prime_material_cost': 63.89, + 'material_cost': 76.19, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, + 'total_cost': 104.53, 'notes': None}, + {'id': 1231, 'type': 'flat_roof_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, 12, 4, 20, 1, 49, 298076), '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': 1232, 'type': 'flat_roof_insulation', 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', + 'depth': 120.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, 12, 4, 20, 1, 49, 298076), 'is_active': True, 'prime_material_cost': 20.16, + 'material_cost': 34.613335, 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, + 'total_cost': 65.31333, + 'notes': "SPONs didn't have this thickness, so the material price is based on the fact that on the link, " + "the 120mm thickness is 33% more expensive than the 100mm thickness"}, + {'id': 1233, 'type': 'flat_roof_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, 12, 4, 20, 1, 49, 298076), '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': 1234, 'type': 'flat_roof_waterproofing', + 'description': '20 mm thick two coat coverings; felt isolating membrane; to concrete (or ' + 'timber) base; flat or to falls or slopes not exceeding 10° from horizontal', + '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, 12, 4, 20, 1, 49, 298076), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.5, 'plant_cost': 0.0, 'total_cost': 31.13, '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, @@ -832,4 +945,5 @@ materials = [ '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'}] + 'lights'} +] diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py index 69f0d1b6..aefc70b0 100644 --- a/recommendations/tests/test_recommendation_utils.py +++ b/recommendations/tests/test_recommendation_utils.py @@ -282,19 +282,24 @@ class TestRecommendationUtils: def test_convert_thickness_to_numeric(self): - assert recommendation_utils.convert_thickness_to_numeric("none", True) == 0 - assert recommendation_utils.convert_thickness_to_numeric("below average", True) == 50 - assert recommendation_utils.convert_thickness_to_numeric("average", True) == 100 - assert recommendation_utils.convert_thickness_to_numeric("above average", True) == 270 + assert recommendation_utils.convert_thickness_to_numeric("none", True, False) == 0 + assert recommendation_utils.convert_thickness_to_numeric("below average", True, False) == 50 + assert recommendation_utils.convert_thickness_to_numeric("average", True, False) == 100 + assert recommendation_utils.convert_thickness_to_numeric("above average", True, False) == 270 - assert recommendation_utils.convert_thickness_to_numeric("300+", True) == 300 - assert recommendation_utils.convert_thickness_to_numeric("400+", True) == 400 - assert recommendation_utils.convert_thickness_to_numeric("270", True) == 270 + assert recommendation_utils.convert_thickness_to_numeric("300+", True, False) == 300 + assert recommendation_utils.convert_thickness_to_numeric("400+", True, False) == 400 + assert recommendation_utils.convert_thickness_to_numeric("270", True, False) == 270 - assert recommendation_utils.convert_thickness_to_numeric("none", False) == 0 - assert recommendation_utils.convert_thickness_to_numeric("below average", False) == 100 - assert recommendation_utils.convert_thickness_to_numeric("average", False) == 270 - assert recommendation_utils.convert_thickness_to_numeric("above average", False) == 270 + assert recommendation_utils.convert_thickness_to_numeric("none", False, False) == 0 + assert recommendation_utils.convert_thickness_to_numeric("below average", False, False) == 100 + assert recommendation_utils.convert_thickness_to_numeric("average", False, False) == 270 + assert recommendation_utils.convert_thickness_to_numeric("above average", False, False) == 270 + + assert recommendation_utils.convert_thickness_to_numeric("none", False, True) == 0 + assert recommendation_utils.convert_thickness_to_numeric("below average", False, True) == 0 + assert recommendation_utils.convert_thickness_to_numeric("average", False, True) == 100 + assert recommendation_utils.convert_thickness_to_numeric("above average", False, True) == 150 def test_estimate_perimeter_regular_inputs(): diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py index 80591970..903f598b 100644 --- a/recommendations/tests/test_roof_recommendations.py +++ b/recommendations/tests/test_roof_recommendations.py @@ -276,89 +276,91 @@ class TestRoofRecommendations: # "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_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 = 33.5 + property_instance11.perimeter = 24 + 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]["depth"] == 150 + assert roof_recommender11.recommendations[0]["total"] == 4380.84324 + assert roof_recommender11.recommendations[0]["new_u_value"] == 0.14 + assert roof_recommender11.recommendations[0]["starting_u_value"] == 2.3 + assert roof_recommender11.recommendations[0]["description"] == \ + "Insulate the home's flat roof with 150mm of Ecotherm Eco-Versal General Purpose Insulation Board" + + 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 = 40 + property_instance12.perimeter = 30 + + 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 = 40 + property_instance13.perimeter = 40 + 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]["depth"] == 150 + + assert roof_recommender13.recommendations[0]["total"] == 5199.969120000002 + 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 150mm of Ecotherm Eco-Versal General Purpose Insulation Board" def test_property_above(self): property_instance14 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock())