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 3b05c6ac..1122b380 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,9 @@ - + + + diff --git a/backend/Property.py b/backend/Property.py index 045b6220..1094e7b2 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -11,7 +11,9 @@ from utils.s3 import read_dataframe_from_s3_parquet from epc_api.client import EpcClient from BaseUtility import Definitions from recommendations.rdsap_tables import england_wales_age_band_lookup -from recommendations.recommendation_utils import estimate_floors, estimate_perimeter, get_wall_type, estimate_wall_area +from recommendations.recommendation_utils import ( + estimate_floors, estimate_perimeter, get_wall_type, estimate_wall_area, esimtate_pitched_roof_area +) ENVIRONMENT = os.environ.get('ENVIRONMENT', 'dev') EPC_AUTH_TOKEN = os.environ.get('EPC_AUTH_TOKEN') @@ -79,6 +81,7 @@ class Property(Definitions): self.floor_height = None self.insulation_wall_area = None self.floor_area = None + self.pitched_roof_area = None if epc_client: self.epc_client = epc_client @@ -587,6 +590,10 @@ class Property(Definitions): num_floors=self.number_of_floors, floor_height=self.floor_height, perimeter=self.perimeter ) + self.pitched_roof_area = esimtate_pitched_roof_area( + floor_area=self.floor_area / self.number_of_floors, floor_height=self.floor_height + ) + def set_wall_type(self): """ This method sets the wall type of the property, using a simple approach based on the wall description @@ -597,9 +604,26 @@ class Property(Definitions): def set_floor_type(self): """ This method sets the floor type of the property, which is used for calculating u-values - :return: + + Section 5.6 of the BRE indicates that + "to simplify data collection no distinction is made in terms of U-value between an exposed floor (to + outside air below) and a semi-exposed floor (to an enclosed but unheated space below) + and the U-values in Table S12 are used. + + Therefore, we treat the exposed floor and suspended floor as the same type of floor, which is used for + calculating u-values """ - self.floor_type = "suspended" if self.floor["is_suspended"] else "solid" + + if self.floor["is_suspended"] | self.floor["another_property_below"]: + self.floor_type = "suspended" + elif self.floor["is_solid"]: + self.floor_type = "solid" + elif self.floor["is_to_unheated_space"] | self.floor["is_to_external_air"]: + self.floor_type = "exposed_floor" + elif self.floor["thermal_transmittance"] is not None: + self.floor_type = "solid" + else: + raise NotImplementedError("Implement this floor type") @staticmethod def _extract_component(component_data, component_rename_cols, component_drop_cols, rename_prefix=None): diff --git a/backend/app/db/models/materials.py b/backend/app/db/models/materials.py index cf6dd971..1dc47276 100644 --- a/backend/app/db/models/materials.py +++ b/backend/app/db/models/materials.py @@ -14,6 +14,7 @@ class MaterialType(enum.Enum): internal_wall_insulation = "internal_wall_insulation" cavity_wall_insulation = "cavity_wall_insulation" mechanical_ventilation = "mechanical_ventilation" + loft_insulation = "loft_insulation" class DepthUnit(enum.Enum): diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index fdbf155d..23ad4262 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -30,6 +30,7 @@ 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 @@ -129,20 +130,38 @@ async def trigger_plan(body: PlanTriggerRequest): # with open("new_sap_dataset.pickle", "rb") as f: # new_sap_dataset = pickle.load(f) + # import pickle + # with open("cleaned.pickle", "rb") as f: + # cleaned = pickle.load(f) + + # with open("sap_dataset.pickle", "rb") as f: + # sap_dataset = pickle.load(f) + + # with open("materials_by_type", "rb") as f: + # materials_by_type = pickle.load(f) + + # materials_by_type["floor"].append( + # {'id': 18, 'type': 'exposed_floor_insulation', 'description': 'Rockwool Stone Wool insulation', + # 'depths': [50, 100, 140], 'depth_unit': 'mm', 'cost': [8, 11, 15], + # 'cost_unit': 'gbp_sq_meter', 'r_value_per_mm': 0.026315789473684213, + # 'r_value_unit': 'square_meter_kelvin_per_watt', + # 'thermal_conductivity': 0.038, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + # 'link': 'https://insulation4less.co.uk/products/rockwool-flexi-slab-all-sizes?variant=33409590853685', + # 'created_at': datetime(2023, 8, 10, 16, 59, 10, 815531), 'is_active': True} + # + # ) + recommendations = {} recommendations_scoring_data = [] for p in input_properties: - property_recommendations = [] - # Property recommendations p.get_components(cleaned) + property_recommendations = [] + # Floor recommendations - floor_recommender = FloorRecommendations( - property_instance=p, - materials=materials_by_type["floor"], - ) + floor_recommender = FloorRecommendations(property_instance=p, materials=materials_by_type["floor"]) floor_recommender.recommend() if floor_recommender.recommendations: @@ -150,15 +169,19 @@ async def trigger_plan(body: PlanTriggerRequest): # Wall recommendations - wall_recomender = WallRecommendations( - property_instance=p, - materials=materials_by_type["walls"] - ) + wall_recomender = WallRecommendations(property_instance=p, materials=materials_by_type["walls"]) wall_recomender.recommend() if wall_recomender.recommendations: property_recommendations.append(wall_recomender.recommendations) + # Roof recommendations + roof_recommender = RoofRecommendations(property_instance=p, materials=materials_by_type["roof"]) + roof_recommender.recommend() + + if roof_recommender.recommendations: + property_recommendations.append(roof_recommender.recommendations) + # Ventilation recommendations ventilation_recomender = VentilationRecommendations( property_instance=p, @@ -170,9 +193,7 @@ async def trigger_plan(body: PlanTriggerRequest): property_recommendations.append(ventilation_recomender.recommendation) # Fireplace sealing recommendations - fireplace_recommender = FireplaceRecommendations( - property_instance=p - ) + fireplace_recommender = FireplaceRecommendations(property_instance=p) fireplace_recommender.recommend() if fireplace_recommender.recommendation: diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py index c06d9293..71a61be1 100644 --- a/backend/app/plan/utils.py +++ b/backend/app/plan/utils.py @@ -15,8 +15,9 @@ def filter_materials(materials): mapping = { "walls": ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"], - "floor": ["suspended_floor_insulation", "solid_floor_insulation"], + "floor": ["suspended_floor_insulation", "solid_floor_insulation", "exposed_floor_insulation"], "ventilation": ["mechanical_ventilation"], + "roof": ["loft_insulation"] } materials = [row2dict(material) for material in materials] @@ -145,7 +146,6 @@ def create_recommendation_scoring_data( # Update description to indicate it's insulate if recommendation["type"] == "floor_insulation": - if len(recommendation["parts"]) > 1: raise NotImplementedError("Have more than 1 floor insulation part - handle this case") @@ -167,6 +167,33 @@ 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": + 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(parts[0]["depths"][0]) + scoring_dict["ROOF_ENERGY_EFF_ENDING"] = "Very Good" + else: + # Fill missing roof u-values - this fill is not based on recommended upgrades + if scoring_dict["roof_thermal_transmittance_ENDING"] is None: + scoring_dict["roof_thermal_transmittance_ENDING"] = get_roof_u_value( + insulation_thickness=property.roof["insulation_thickness"], + has_dwelling_above=property.roof["has_dwelling_above"], + is_loft=property.roof["is_loft"], + is_roof_room=property.roof["is_roof_room"], + is_thatched=property.roof["is_thatched"], + age_band=property.age_band, + is_flat=property.roof["is_flat"], + is_pitched=property.roof["is_pitched"], + is_at_rafters=property.roof["is_at_rafters"], + ) + + if scoring_dict["roof_insulation_thickness_ENDING"] is None: + scoring_dict["roof_insulation_thickness_ENDING"] = "none" + if recommendation["type"] == "mechanical_ventilation": scoring_dict["MECHANICAL_VENTILATION_ENDING"] = 'mechanical, extract only' @@ -174,25 +201,8 @@ def create_recommendation_scoring_data( scoring_dict["NUMBER_OPEN_FIREPLACES_ENDING"] = 0 if recommendation["type"] not in [ - "wall_insulation", "floor_insulation", "mechanical_ventilation", "sealing_open_fireplace" + "wall_insulation", "floor_insulation", "roof_insulation", "mechanical_ventilation", "sealing_open_fireplace", ]: raise NotImplementedError("Implement me") - # Fill missing roof u-values - this fill is not based on recommended upgrades - if scoring_dict["roof_thermal_transmittance_ENDING"] is None: - scoring_dict["roof_thermal_transmittance_ENDING"] = get_roof_u_value( - insulation_thickness=property.roof["insulation_thickness"], - has_dwelling_above=property.roof["has_dwelling_above"], - is_loft=property.roof["is_loft"], - is_roof_room=property.roof["is_roof_room"], - is_thatched=property.roof["is_thatched"], - age_band=property.age_band, - is_flat=property.roof["is_flat"], - is_pitched=property.roof["is_pitched"], - is_at_rafters=property.roof["is_at_rafters"], - ) - - if scoring_dict["roof_insulation_thickness_ENDING"] is None: - scoring_dict["roof_insulation_thickness_ENDING"] = "none" - return scoring_dict diff --git a/backend/tests/test_property.py b/backend/tests/test_property.py index d7028bc6..b376db9e 100644 --- a/backend/tests/test_property.py +++ b/backend/tests/test_property.py @@ -227,7 +227,8 @@ class TestProperty: "mainheat-description": [{"original_description": "Main Heating Description"}], "hotwater-description": [{"original_description": "Hot Water Description"}], "lighting-description": [{"original_description": "Good Lighting Efficiency"}], - "floor-description": [{"original_description": "Floor Description", "is_suspended": True}] + "floor-description": [ + {"original_description": "Floor Description", "is_suspended": True, "another_property_below": False}] } return mock_cleaner @@ -317,7 +318,9 @@ class TestProperty: } property_instance.floor = { - "is_suspended": False + "is_suspended": False, + "another_property_below": False, + "is_solid": True } # Assert backup cleaning has been applied diff --git a/backend/tests/test_sap_model_prep.py b/backend/tests/test_sap_model_prep.py index dc793cad..ab7cd678 100644 --- a/backend/tests/test_sap_model_prep.py +++ b/backend/tests/test_sap_model_prep.py @@ -10,23 +10,22 @@ from utils.s3 import read_dataframe_from_s3_parquet from tqdm import tqdm -# Handy code for selecting testin data +# Handy code for selecting testing data # import pickle # -# with open("sap_change_dataset.pickle", "rb") as f: +# with open("sap_dataset.pickle", "rb") as f: # sap_change_dataset = pickle.load(f) # # search_from = sap_change_dataset[ -# (sap_change_dataset["walls_thermal_transmittance_ENDING"] == sap_change_dataset["walls_thermal_transmittance"]) -# ] +# (sap_change_dataset["walls_thermal_transmittance_ENDING"] == sap_change_dataset["walls_thermal_transmittance"]) & +# sap_change_dataset["is_to_unheated_space"] +# ] # search_from = search_from[ # (search_from["roof_thermal_transmittance_ENDING"] == search_from["roof_thermal_transmittance"]) & -# (search_from["floor_thermal_transmittance_ENDING"] == search_from["floor_thermal_transmittance"]) & +# (search_from["floor_thermal_transmittance_ENDING"] != search_from["floor_thermal_transmittance"]) & # (search_from["MECHANICAL_VENTILATION_ENDING"] == search_from["MECHANICAL_VENTILATION_STARTING"]) & # (search_from["SECONDHEAT_DESCRIPTION_ENDING"] == search_from["SECONDHEAT_DESCRIPTION_STARTING"]) & -# (search_from["GLAZED_TYPE_ENDING"] == search_from["GLAZED_TYPE_STARTING"]) & -# (search_from["NUMBER_OPEN_FIREPLACES_STARTING"] > 0) & -# (search_from["NUMBER_OPEN_FIREPLACES_ENDING"] == 0) +# (search_from["GLAZED_TYPE_ENDING"] == search_from["GLAZED_TYPE_STARTING"]) # ] # # # Find a record where the only difference is cavity wall getting filled @@ -54,7 +53,7 @@ from tqdm import tqdm # starting_cols.append(starting_col) # # # We want them to be different -# if c == "NUMBER_OPEN_FIREPLACES_ENDING": +# if c == "floor_thermal_transmittance_ENDING": # if (row[c] == row[starting_col]) | (row[starting_col] != "natural"): # same = False # break @@ -82,11 +81,11 @@ from tqdm import tqdm # # compare = pd.concat([start, end], axis=1) # -# ending_lmk = "bab3983fa167717b8bb4a36ef395046d53937f9b880a45bcc751270d72e5de45" -# starting_lmk = "736b6f4803a11d9e45b49bf98f36eb8a7f357b0dd24f3e7cddef5295518e5bef" +# ending_lmk = "1252008839062019090910572351658131" +# starting_lmk = "1252008819542014122308482236142128" # # client = EpcClient(auth_token=EPC_AUTH_TOKEN) -# result = client.domestic.search(params={"address": "9 Glebe Road, Asfordby Hill", "postcode": "LE14 3QT"}) +# result = client.domestic.search(params={"address": "Flat 14 Charles House, Freemens Way", "postcode": "CT14 9DL"}) # starting_epc = [x for x in result["rows"] if x["lmk-key"] == starting_lmk][0] # ending_epc = [x for x in result["rows"] if x["lmk-key"] == ending_lmk][0] @@ -101,7 +100,7 @@ from tqdm import tqdm # ) as f: # cleaning_data = pickle.load(f) -# TODO: Need to do floors, both suspended and solid +# TODO: Need to do floors, suspended and solid and to unheated space class TestSapModelPrep: @@ -196,7 +195,7 @@ class TestSapModelPrep: 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_sandstone_or_limestone': False, 'is_park_home': False, 'walls_insulation_thickness': 'none', 'external_insulation': False, 'internal_insulation': False, 'walls_thermal_transmittance_ENDING': 0.7, - 'is_park_home_ENDING': False, 'walls_insulation_thickness_ENDING': 'none', + '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, 'is_suspended': True, 'is_solid': False, 'another_property_below': False, @@ -254,7 +253,29 @@ 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': 44.77882152472145, 'estimated_perimeter_ENDING': 44.77882152472145, + 'HOT_WATER_ENERGY_EFF_STARTING': "Good", + "FLOOR_ENERGY_EFF_STARTING": "Unknown", + "WINDOWS_ENERGY_EFF_STARTING": "Good", + "WALLS_ENERGY_EFF_STARTING": "Poor", + "SHEATING_ENERGY_EFF_STARTING": "Unknown", + "ROOF_ENERGY_EFF_STARTING": "Very Poor", + "MAINHEAT_ENERGY_EFF_STARTING": "Average", + "MAINHEATC_ENERGY_EFF_STARTING": "Good", + "LIGHTING_ENERGY_EFF_STARTING": "Average", + "POTENTIAL_ENERGY_EFFICIENCY": 64, + "ENVIRONMENT_IMPACT_POTENTIAL": 53, + "ENERGY_CONSUMPTION_POTENTIAL": 177.0, + "CO2_EMISSIONS_POTENTIAL": 5.7, + "HOT_WATER_ENERGY_EFF_ENDING": "Good", + "FLOOR_ENERGY_EFF_ENDING": "Unknown", + "WINDOWS_ENERGY_EFF_ENDING": "Good", + "WALLS_ENERGY_EFF_ENDING": "Good", + "SHEATING_ENERGY_EFF_ENDING": "Unknown", + "ROOF_ENERGY_EFF_ENDING": "Very Poor", + "MAINHEAT_ENERGY_EFF_ENDING": "Average", + "MAINHEATC_ENERGY_EFF_ENDING": "Good", + "LIGHTING_ENERGY_EFF_ENDING": "Average", } home = Property( @@ -322,10 +343,9 @@ class TestSapModelPrep: continue if c == "walls_insulation_thickness_ENDING": - print("Add back in the checks") - continue assert row[c] == "average" - assert test_record[c] == "above average" + assert test_record[c].values[0] == "above average" + continue assert test_record[c].values[0] == row[c] @@ -453,7 +473,29 @@ class TestSapModelPrep: 'fuel_type_ENDING': 'electricity', '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': 35.4964786985977, - 'estimated_perimeter_ENDING': 35.4964786985977 + 'estimated_perimeter_ENDING': 35.4964786985977, + 'HOT_WATER_ENERGY_EFF_STARTING': "Very Poor", + "FLOOR_ENERGY_EFF_STARTING": "Unknown", + "WINDOWS_ENERGY_EFF_STARTING": "Average", + "WALLS_ENERGY_EFF_STARTING": "Very Poor", + "SHEATING_ENERGY_EFF_STARTING": "Unknown", + "ROOF_ENERGY_EFF_STARTING": "Unknown", + "MAINHEAT_ENERGY_EFF_STARTING": "Very Poor", + "MAINHEATC_ENERGY_EFF_STARTING": "Good", + "LIGHTING_ENERGY_EFF_STARTING": "Poor", + "POTENTIAL_ENERGY_EFFICIENCY": 71, + "ENVIRONMENT_IMPACT_POTENTIAL": 51, + "ENERGY_CONSUMPTION_POTENTIAL": 307, + "CO2_EMISSIONS_POTENTIAL": 3.6, + 'HOT_WATER_ENERGY_EFF_ENDING': "Very Poor", + "FLOOR_ENERGY_EFF_ENDING": "Unknown", + "WINDOWS_ENERGY_EFF_ENDING": "Average", + "WALLS_ENERGY_EFF_ENDING": "Good", + "SHEATING_ENERGY_EFF_ENDING": "Unknown", + "ROOF_ENERGY_EFF_ENDING": "Unknown", + "MAINHEAT_ENERGY_EFF_ENDING": "Very Poor", + "MAINHEATC_ENERGY_EFF_ENDING": "Good", + "LIGHTING_ENERGY_EFF_ENDING": "Poor", } home2 = Property( @@ -650,7 +692,29 @@ 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': 41.634120622393354, 'estimated_perimeter_ENDING': 41.634120622393354, + 'HOT_WATER_ENERGY_EFF_STARTING': "Good", + "FLOOR_ENERGY_EFF_STARTING": "Unknown", + "WINDOWS_ENERGY_EFF_STARTING": "Average", + "WALLS_ENERGY_EFF_STARTING": "Very Poor", + "SHEATING_ENERGY_EFF_STARTING": "Unknown", + "ROOF_ENERGY_EFF_STARTING": "Very Poor", + "MAINHEAT_ENERGY_EFF_STARTING": "Good", + "MAINHEATC_ENERGY_EFF_STARTING": "Average", + "LIGHTING_ENERGY_EFF_STARTING": "Average", + "POTENTIAL_ENERGY_EFFICIENCY": 80, + "ENVIRONMENT_IMPACT_POTENTIAL": 75, + "ENERGY_CONSUMPTION_POTENTIAL": 152, + "CO2_EMISSIONS_POTENTIAL": 2.9, + 'HOT_WATER_ENERGY_EFF_ENDING': "Good", + "FLOOR_ENERGY_EFF_ENDING": "Unknown", + "WINDOWS_ENERGY_EFF_ENDING": "Average", + "WALLS_ENERGY_EFF_ENDING": "Very Poor", + "SHEATING_ENERGY_EFF_ENDING": "Unknown", + "ROOF_ENERGY_EFF_ENDING": "Very Poor", + "MAINHEAT_ENERGY_EFF_ENDING": "Good", + "MAINHEATC_ENERGY_EFF_ENDING": "Average", + "LIGHTING_ENERGY_EFF_ENDING": "Average", } home3 = Property( @@ -836,7 +900,29 @@ 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': 37.54197650630557, 'estimated_perimeter_ENDING': 37.54197650630557, + 'HOT_WATER_ENERGY_EFF_STARTING': "Good", + "FLOOR_ENERGY_EFF_STARTING": "Unknown", + "WINDOWS_ENERGY_EFF_STARTING": "Average", + "WALLS_ENERGY_EFF_STARTING": "Very Poor", + "SHEATING_ENERGY_EFF_STARTING": "Unknown", + "ROOF_ENERGY_EFF_STARTING": "Good", + "MAINHEAT_ENERGY_EFF_STARTING": "Good", + "MAINHEATC_ENERGY_EFF_STARTING": "Average", + "LIGHTING_ENERGY_EFF_STARTING": "Average", + "POTENTIAL_ENERGY_EFFICIENCY": 78, + "ENVIRONMENT_IMPACT_POTENTIAL": 76, + "ENERGY_CONSUMPTION_POTENTIAL": 153, + "CO2_EMISSIONS_POTENTIAL": 2.4, + 'HOT_WATER_ENERGY_EFF_ENDING': "Good", + "FLOOR_ENERGY_EFF_ENDING": "Unknown", + "WINDOWS_ENERGY_EFF_ENDING": "Average", + "WALLS_ENERGY_EFF_ENDING": "Very Poor", + "SHEATING_ENERGY_EFF_ENDING": "Unknown", + "ROOF_ENERGY_EFF_ENDING": "Good", + "MAINHEAT_ENERGY_EFF_ENDING": "Good", + "MAINHEATC_ENERGY_EFF_ENDING": "Average", + "LIGHTING_ENERGY_EFF_ENDING": "Average", } home4 = Property( diff --git a/etl/epc/property_change_app.py b/etl/epc/property_change_app.py index 435b668d..4f49f6da 100644 --- a/etl/epc/property_change_app.py +++ b/etl/epc/property_change_app.py @@ -415,13 +415,11 @@ def app(): all_equal_rows = [] for directory in tqdm(directories): - filepath = directory / "certificates.csv" data_processor = DataProcessor(filepath=filepath) df = data_processor.pre_process() - df[df["WALLS_DESCRIPTION"].str.contains("Cavity")]["WALLS_DESCRIPTION"].unique() cleaning_averages = data_processor.make_cleaning_averages() diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py index 35e34648..bc24b6c3 100644 --- a/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -45,16 +45,20 @@ class FloorRecommendations(Definitions): part for part in self.materials if part["type"] == "solid_floor_insulation" ] + self.exposed_floor_insulation_parts = [ + part for part in self.materials if part["type"] == "exposed_floor_insulation" + ] + def recommend(self): u_value = self.property.floor["thermal_transmittance"] - is_suspended = self.property.floor["is_suspended"] - is_solid = self.property.floor["is_solid"] + floor_level = ( FLOOR_LEVEL_MAP[self.property.data["floor-level"]] if self.property.data["floor-level"] not in self.DATA_ANOMALY_MATCHES else None ) property_type = self.property.data["property-type"] + floor_area = self.property.floor_area / self.property.number_of_floors year_built = self.property.year_built if self.property.floor["another_property_below"] | (self.property.floor["insulation_thickness"] in [ @@ -81,7 +85,7 @@ class FloorRecommendations(Definitions): u_value = get_floor_u_value( floor_type=self.property.floor_type, - area=float(self.property.data["total-floor-area"]), + area=floor_area, perimeter=self.property.perimeter, age_band=self.property.age_band, insulation_thickness=self.property.floor["insulation_thickness"], @@ -89,13 +93,24 @@ class FloorRecommendations(Definitions): ) self.estimated_u_value = u_value - if is_suspended: + if u_value < self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: + return + + if self.property.floor["is_suspended"]: # Given the U-value, we recommend underfloor insulation self.recommend_floor_insulation(u_value=u_value, parts=self.suspended_floor_insulation_parts) + return - if is_solid: + if self.property.floor["is_solid"]: # Given the U-value, we recommend solid floor insulation options which are usually solid foam self.recommend_floor_insulation(u_value=u_value, parts=self.solid_floor_insulation_parts) + return + + if self.property.floor["is_to_unheated_space"] or self.property.floor["is_to_external_air"]: + self.recommend_floor_insulation(u_value=u_value, parts=self.exposed_floor_insulation_parts) + return + + raise NotImplementedError("Implement me!") @staticmethod def _make_floor_description(part, depth): @@ -122,8 +137,9 @@ class FloorRecommendations(Definitions): if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value) + quantity = self.property.floor_area / self.property.number_of_floors - estimated_cost = cost_per_unit * self.property.floor_area + estimated_cost = cost_per_unit * quantity self.recommendations.append( { @@ -131,7 +147,7 @@ class FloorRecommendations(Definitions): get_recommended_part( part=part, selected_depth=depth, - quantity=self.property.floor_area, + quantity=quantity, quantity_unit=QuantityUnits.m2.value, selected_total_cost=estimated_cost ), diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py new file mode 100644 index 00000000..283370ac --- /dev/null +++ b/recommendations/RoofRecommendations.py @@ -0,0 +1,282 @@ +import math +from backend.Property import Property +from typing import List +from datatypes.enums import QuantityUnits +from recommendations.recommendation_utils import ( + get_roof_u_value, r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns, + update_lowest_selected_u_value, get_recommended_part, convert_thickness_to_numeric +) + + +class RoofRecommendations: + # part L building regulations indicate that any rennovations on an existing property's roof should + # achieve a U-value of no higher than 0.16 + # This can be seen in table 4.3 in building regulations part L: + # https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1133079 + # /Approved_Document_L__Conservation_of_fuel_and_power__Volume_1_Dwellings__2021_edition_incorporating_2023_amendments.pdf + BUILDING_REGULATIONS_PART_L_MAX_U_VALUE = 0.16 + + DIMINISHING_RETURNS_U_VALUE = 0.14 + + # It is recommended that lofts should have at least 270mm of insulation + MINIMUM_LOFT_ISULATION_MM = 270 + + def __init__( + self, + property_instance: Property, + materials: List + ): + self.property = property_instance + # For audit purposes, when estimating u values we'll store it + self.estimated_u_value = None + + # Will contains a list of recommended measures + self.recommendations = [] + + self.materials = materials + + def recommend(self): + u_value = self.property.roof["thermal_transmittance"] + + insulation_thickness = convert_thickness_to_numeric( + self.property.roof["insulation_thickness"], + self.property.roof["is_pitched"] + ) + + # 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 + if insulation_thickness >= self.MINIMUM_LOFT_ISULATION_MM: + return + + # If we have a u-value already, need to implement this + if u_value: + raise NotImplementedError("Implement me") + + u_value = get_roof_u_value(**{**self.property.roof, "age_band": self.property.age_band}) + + if self.property.roof["is_pitched"] or self.property.roof["is_flat"]: + self.recommend_roof_insulation(u_value, insulation_thickness, self.property.roof) + return + + if self.property.roof["is_roof_room"]: + self.recommend_room_roof_insulation(u_value, insulation_thickness) + return + + raise NotImplementedError("Implement me") + + @staticmethod + def make_loft_insulation_description(material, depth): + return f"Install {depth}{material['depth_unit']} of {material['description']} in your loft" + + @staticmethod + def make_room_roof_insulation_description(material, depth): + return f"Insulate your room roof with {depth}{material['depth_unit']} of {material['description']}" + + @staticmethod + def make_flat_roof_insulation_description(material, depth): + return f"Insulate the home's flat roof with {depth}{material['depth_unit']} of {material['description']}" + + def recommend_roof_insulation(self, u_value, insulation_thickness, roof): + + """ + This method will recommend which insulation materials to use + This function handles both the case of loft insulation and flat roof insulation + + We also follow advide provided in this article on the Energy Saving Trust website, providing + high level guidance around roof insulation: + https://energysavingtrust.org.uk/advice/roof-and-loft-insulation/ + + The process roughly looks like the following: + - Remove the Existing Weatherproof Layer: If the roof is being replaced, remove the old weatherproof layer to + expose the timber roof surface. + - Install Insulation Boards: Lay the rigid insulation boards directly on the timber roof surface. + Ensure the boards fit tightly together to prevent thermal bridging (heat loss through the gaps). + - Add a Vapour Control Layer (VCL): This is crucial to prevent moisture from entering the insulation layer, + which can lead to dampness and rot. The VCL is placed over the insulation. + - Install a New Weatherproof Layer: On top of the insulation and VCL, install a new weatherproof layer. This + could be traditional roofing materials like bitumen-based felt, rubber membranes like EPDM, or fiberglass. + + :param u_value: U-value of the roof before any retrofit measures have been installed + :param insulation_thickness: Existing Insulation thickness of the loft + :param roof: dictionary describing the make-up of the roof + :return: + """ + + # With loft insulation, 100mm goes between the joists and the rest is rolled on top + # Therefore the price is 100mm + whatever thickness is rolled on top, rolled at a 90 degree angle + # from the base layer + + if roof["is_pitched"]: + materials = [m for m in self.materials if m["type"] == "loft_insulation"] + elif roof["is_flat"]: + materials = [m for m in self.materials if m["type"] == "flat_roof_insulation"] + else: + raise ValueError("Roof is not pitched or flat") + + if not materials: + raise ValueError("No roof insulation materials found") + + lowest_selected_u_value = None + recommendations = [] + for material in materials: + + for depth, cost_per_unit in zip(material["depths"], material["cost"]): + + # We make sure we hit a depth of 270mm. We should factor in any existing insulation if the + # loft is already partially insulated + if (depth + insulation_thickness) < self.MINIMUM_LOFT_ISULATION_MM: + continue + + part_u_value = r_value_per_mm_to_u_value(depth, material["r_value_per_mm"]) + + _, new_u_value = calculate_u_value_uplift(u_value, part_u_value) + new_u_value = math.ceil(new_u_value * 100.0) / 100.0 + + # If I have a lowest U value and my new u value is higher than that but lower than the + # diminishing returns threshold, it can be considered + + # If I have a lowest U value and my new u value is lower than the lowest value, it's + # further into the diminishing returns threshold and can shouldn't be + + if is_diminishing_returns( + recommendations, new_u_value, lowest_selected_u_value, self.DIMINISHING_RETURNS_U_VALUE + ): + continue + + # We allow a small tolerance for error so we don't discount the recommendation entirely + if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: + lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value) + + estimated_cost = cost_per_unit * self.property.floor_area + + if roof["is_pitched"]: + description = self.make_loft_insulation_description(material, depth) + else: + description = self.make_flat_roof_insulation_description(material, depth) + + recommendations.append( + { + "parts": [ + get_recommended_part( + part=material, + selected_depth=depth, + quantity=self.property.insulation_wall_area, + quantity_unit=QuantityUnits.m2.value, + selected_total_cost=estimated_cost + ) + ], + "type": "roof_insulation", + "description": description, + "starting_u_value": u_value, + "new_u_value": new_u_value, + "sap_points": None, + "cost": estimated_cost, + } + ) + + self.recommendations = recommendations + + def recommend_room_roof_insulation(self, u_value, insulation_thickness): + """ + This method recommends room in roof insulation for properties that have been identified + to possess a room in roof. + + Because we currently have limited data about the construction of the roof, we make the following + assumptions: + 1) The room in roof has a sloped roof. + We will make some basic estimations about the area of the roof given the floor area and the height of the + floors + 2) Insulation of external walls is covered by the wall recommendation class + 3) We assume a "Gable" roof type + + Further, we recommend internal roof insulation for the room in roof + + The following document contains details around best practices for insulating a room in roof + https://assets.publishing.service.gov.uk/media/61d727d18fa8f50594b59305/retrofit-room-in-roof-insulation-best + -practice.pdf + Of particular interest are the following: + + We also follow advide provided in this article on the Energy Saving Trust website, providing + high level guidance around roof insulation: + https://energysavingtrust.org.uk/advice/roof-and-loft-insulation/ + + To insulate a warm loft, the following advice is given + "An alternative way to insulate your loft is to fit rigid insulation boards between and over the rafters. + Rafters are the sloping timbers that make up the roof itself." + + To then insulate a room roof, the following recommendation is provided: + "If you want to use your loft as a living space, or it is already being used as a living space, + then you need to make sure that all the walls and ceilings between a heated room and an unheated space + are insulated. + + - Sloping ceilings can be insulated in the same way as for a warm roof, + but with a layer of plasterboard on the inside of the insulation. + - Vertical walls can be insulated in the same way. + - Flat ceilings can be insulated like a standard loft. + + :param u_value: Current u-value of the roof + :param insulation_thickness: Current insulation thickness of the roof + :return: + """ + + roof_roof_insulation_materials = [m for m in self.materials if m["type"] == "room_roof_insulation"] + if not roof_roof_insulation_materials: + raise ValueError("No room in roof insulation materials found") + + if self.property.pitched_roof_area is None: + raise ValueError("pitched_roof_area not included as property attribute") + + lowest_selected_u_value = None + recommendations = [] + for material in roof_roof_insulation_materials: + for depth, cost_per_unit in zip(material["depths"], material["cost"]): + # We make sure we hit a depth of 270mm. We should factor in any existing insulation if the + # loft is already partially insulated + if (depth + insulation_thickness) < self.MINIMUM_LOFT_ISULATION_MM: + continue + + part_u_value = r_value_per_mm_to_u_value(depth, material["r_value_per_mm"]) + + _, new_u_value = calculate_u_value_uplift(u_value, part_u_value) + new_u_value = math.ceil(new_u_value * 100.0) / 100.0 + + # If I have a lowest U value and my new u value is higher than that but lower than the + # diminishing returns threshold, it can be considered + + # If I have a lowest U value and my new u value is lower than the lowest value, it's + # further into the diminishing returns threshold and can shouldn't be + + if is_diminishing_returns( + recommendations, new_u_value, lowest_selected_u_value, self.DIMINISHING_RETURNS_U_VALUE + ): + continue + + # We allow a small tolerance for error so we don't discount the recommendation entirely + if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: + lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value) + + estimated_cost = cost_per_unit * self.property.pitched_roof_area + + recommendations.append( + { + "parts": [ + get_recommended_part( + part=material, + selected_depth=depth, + quantity=self.property.pitched_roof_area, + quantity_unit=QuantityUnits.m2.value, + selected_total_cost=estimated_cost + ) + ], + "type": "roof_insulation", + "description": self.make_room_roof_insulation_description(material, depth), + "starting_u_value": u_value, + "new_u_value": new_u_value, + "sap_points": None, + "cost": estimated_cost, + } + ) + + self.recommendations = recommendations diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py index ad2ca861..12085840 100644 --- a/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -23,11 +23,17 @@ class WallRecommendations(Definitions): # part L building regulations indicate that any rennovations on an existing property's walls should # achieve a U-value of no higher than 0.3 + # This can be seen in table 4.3 in building regulations part L: + # https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1133079 + # /Approved_Document_L__Conservation_of_fuel_and_power__Volume_1_Dwellings__2021_edition_incorporating_2023_amendments.pdf BUILDING_REGULATIONS_PART_L_MAX_U_VALUE = 0.3 # We don't recommend measures that are too low because it becomes expensive, therefore we aim to avoid # diminishing returns. This value should be verified with Osmosis (TODO) DIMINISHING_RETURNS_U_VALUE = 0.25 + # Building regulations part L also indicates that cavity wall insulation should result in 0.55 u-value + BUILDING_REGULATIONS_PART_L_CAVITY_WALL_MAX_U_VALUE = 0.55 + # Part L regulations indicate that any new build should have walls that achieve a u-value of no higher # than 0.18. BUILDING_REGULATIONS_PART_L_NEW_BUILD_MAX_U_VALUE = 0.18 @@ -167,29 +173,30 @@ class WallRecommendations(Definitions): ): continue - lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value) + if new_u_value <= self.BUILDING_REGULATIONS_PART_L_CAVITY_WALL_MAX_U_VALUE: + lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value) - estimated_cost = part["cost"] * self.property.insulation_wall_area + estimated_cost = part["cost"] * self.property.insulation_wall_area - recommendations.append( - { - "parts": [ - get_recommended_part( - part=part, - selected_depth=None, - quantity=self.property.insulation_wall_area, - quantity_unit=QuantityUnits.m2.value, - selected_total_cost=estimated_cost - ) - ], - "type": "wall_insulation", - "description": f"Fill cavity with {part['description']}", - "starting_u_value": u_value, - "new_u_value": new_u_value, - "sap_points": None, - "cost": estimated_cost, - } - ) + recommendations.append( + { + "parts": [ + get_recommended_part( + part=part, + selected_depth=None, + quantity=self.property.insulation_wall_area, + quantity_unit=QuantityUnits.m2.value, + selected_total_cost=estimated_cost + ) + ], + "type": "wall_insulation", + "description": f"Fill cavity with {part['description']}", + "starting_u_value": u_value, + "new_u_value": new_u_value, + "sap_points": None, + "cost": estimated_cost, + } + ) self.recommendations = recommendations diff --git a/recommendations/rdsap_tables.py b/recommendations/rdsap_tables.py index 0ce139ab..e396f727 100644 --- a/recommendations/rdsap_tables.py +++ b/recommendations/rdsap_tables.py @@ -463,6 +463,34 @@ s11_list = [ table_s11 = pd.DataFrame(s11_list) +######################################################################################################################## +# Table s12 is used for assigning the u-values of floors to unheated spaces or external air +# which can be found on page 26 of the BRE document, section 5.6 +# https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf +# +# the insulation_{thickness} fields indicate the u-value at that insulation thickness +######################################################################################################################## + +s12_list = [ + {"age_band": "A", "insulation_0": 1.2, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + {"age_band": "B", "insulation_0": 1.2, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + {"age_band": "C", "insulation_0": 1.2, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + {"age_band": "D", "insulation_0": 1.2, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + {"age_band": "E", "insulation_0": 1.2, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + {"age_band": "F", "insulation_0": 1.2, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + {"age_band": "G", "insulation_0": 1.2, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + + {"age_band": "H", "insulation_0": 0.51, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + {"age_band": "I", "insulation_0": 0.51, "insulation_50": 0.5, "insulation_100": 0.3, "insulation_150": 0.22}, + + {"age_band": "J", "insulation_0": 0.25, "insulation_50": 0.25, "insulation_100": 0.25, "insulation_150": 0.22}, + + {"age_band": "K", "insulation_0": 0.22, "insulation_50": 0.22, "insulation_100": 0.22, "insulation_150": 0.22}, + {"age_band": "L", "insulation_0": 0.22, "insulation_50": 0.22, "insulation_100": 0.22, "insulation_150": 0.22}, +] + +table_s12 = pd.DataFrame(s12_list) + ######################################################################################################################## # diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index cd7bb3f8..13f58fd9 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -1,11 +1,12 @@ import math from copy import deepcopy +import numpy as np import pandas as pd from recommendations.rdsap_tables import ( epc_wall_description_map, wall_uvalues_df, default_wall_thickness, table_s9 as s9, table_s10 as s10, - table_s11 as s11 + table_s11 as s11, table_s12 as s12 ) from recommendations.config import PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION, PARTIAL_CAVITY_DESCRIPTIONS @@ -340,6 +341,34 @@ def estimate_perimeter(floor_area, num_rooms): return perimeter +def get_exposed_floor_uvalue(insulation_thickness_str, age_band): + """ + We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document + :param insulation_thickness_str: + :return: + """ + + unknown_insulation_age_bands = ["A", "B", "C", "D", "E", "F", "G", "H", "I"] + # As directed by the documentation, if the insulation thickness is not known, we assume it's + # 50mm for these age bands + if insulation_thickness_str in ["below average", "average", "above average"] and ( + age_band in unknown_insulation_age_bands + ): + insulation_thickness = 50 + elif insulation_thickness_str in ["none", None]: + insulation_thickness = 0 + elif insulation_thickness_str == "below average": + insulation_thickness = 50 + elif insulation_thickness_str == "average": + insulation_thickness = 100 + elif insulation_thickness_str == "above average": + insulation_thickness = 150 + else: + insulation_thickness = int(insulation_thickness_str.replace("mm", "")) + + return s12[s12["age_band"] == age_band][f"insulation_{insulation_thickness}"].values[0] + + def get_floor_u_value(floor_type, area, perimeter, age_band, wall_type, insulation_thickness=None): """ Estimate the u-value of a suspended floor, based on RdSap methodology @@ -372,6 +401,12 @@ def get_floor_u_value(floor_type, area, perimeter, age_band, wall_type, insulati 0.701 """ + if floor_type == "exposed_floor": + # In this case, we extract the u-value from table s12 + # See section 5.6 of the RdSAP document for more details + # https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf + return get_exposed_floor_uvalue(insulation_thickness, age_band) + # Cleans our regularly inputted insulation thickness for usage in this function insulation_thickness = extract_insulation_thickness(insulation_thickness) @@ -517,3 +552,90 @@ def estimate_wall_area(num_floors, floor_height, perimeter): total_wall_area = wall_area_one_floor * num_floors return total_wall_area + + +def calculate_r_value_per_mm(thickness_mm, thermal_conductivity_w_mK): + """ + # Calculate R-value (thermal resistance) using the formula: R = thickness / thermal_conductivity + # Note: The thickness should be converted to meters for the units to be consistent. + :param thickness_mm: + :param thermal_conductivity_w_mK: + :return: + """ + + r_value_m2k_w = (thickness_mm / 1000) / thermal_conductivity_w_mK + + # Calculate R-value per mm + r_value_per_mm = r_value_m2k_w / thickness_mm + + return r_value_per_mm + + +def convert_thickness_to_numeric(string_thickness, is_pitched): + """ + 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 + + we handle loft insulation differently to flat roof or room in roof insulation, since for loft insulation, + we are presented with an insulation thickness, whereas for the other forms of roof, we are just told whether or not + the roof is insulated or not. + + :param string_thickness: string measure of insulation thickness + :param is_pitched: boolean indicating if the roof is a pitched roof + :return: integer measure of insulation thickness + """ + + if is_pitched: + lookup = { + "none": 0, + "below average": 50, + "average": 100, + "above average": 270 + } + else: + lookup = { + "none": 0, + "below average": 100, + "average": 270, + "above average": 270 + } + + mapped = lookup.get(string_thickness) + + if mapped is not None: + return mapped + + if "+" in string_thickness: + return int(string_thickness.replace("+", "")) + + return int(string_thickness) + + +def esimtate_pitched_roof_area(floor_area: float, floor_height: float) -> float: + """ + This function will estimate the area of a pitched roof, given the floor area below the roof and the floor + height of the property. + + Given limited information about the home, this is a very rough method to estimate the roof area and we + assume the the room is a gable roof. + + We assume a roughly average pitch of 45 degrees + + Note that both floor area and height should be in the same units. E.g. if floor area is meters squared, + floor height should be in meters + + :param floor_area: area of the home's floor + :param floor_height: height of the home's floors + :return: Numerical estimate of the surface area of the top of the pitched roof + """ + + # We estimate the length of the wall by just modelling the house as a square + wall_width = np.sqrt(floor_area) + + # We're modelling the roof as two triangles where we know two of the three sides. + # The floor height makes up one side and half of the wall width makes up the other side + slope = np.sqrt(np.square(wall_width / 2) + np.square(floor_height)) + + area = 2 * (slope * wall_width) + + return area diff --git a/recommendations/tests/test_fireplace_recommendations.py b/recommendations/tests/test_fireplace_recommendations.py new file mode 100644 index 00000000..a1e0c1c6 --- /dev/null +++ b/recommendations/tests/test_fireplace_recommendations.py @@ -0,0 +1,58 @@ +from backend.Property import Property +from unittest.mock import Mock +from recommendations.FireplaceRecommendations import FireplaceRecommendations + + +class TestFirepaceRecommendations: + + def test_no_fireplaces(self): + property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance.data = { + "number-open-fireplaces": 0 + } + + recommender = FireplaceRecommendations( + property_instance=property_instance + ) + + assert recommender.recommendation is None + + recommender.recommend() + + assert recommender.recommendation is None + + def test_one_fireplace(self): + property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance.data = { + "number-open-fireplaces": 1 + } + + recommender = FireplaceRecommendations( + property_instance=property_instance + ) + + assert recommender.recommendation is None + + recommender.recommend() + + assert recommender.recommendation + assert recommender.recommendation[0]["type"] == "sealing_open_fireplace" + assert recommender.recommendation[0]["cost"] == 300 + + def test_multiple_fireplaces(self): + property_instance = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance.data = { + "number-open-fireplaces": 3 + } + + recommender = FireplaceRecommendations( + property_instance=property_instance + ) + + assert recommender.recommendation is None + + recommender.recommend() + + assert recommender.recommendation + assert recommender.recommendation[0]["type"] == "sealing_open_fireplace" + assert recommender.recommendation[0]["cost"] == 900 diff --git a/recommendations/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py index f34bbe81..82ba7cf4 100644 --- a/recommendations/tests/test_floor_recommendations.py +++ b/recommendations/tests/test_floor_recommendations.py @@ -3,6 +3,7 @@ import pytest import os from unittest.mock import Mock from recommendations.FloorRecommendations import FloorRecommendations +from backend.Property import Property # with open( # os.path.abspath(os.path.dirname(__file__)) + "/recommendations/tests/test_data/input_properties.pkl", "rb" @@ -67,7 +68,23 @@ solid_floor_insulation_parts = [ ] -parts = suspended_floor_insulation_parts + solid_floor_insulation_parts +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: @@ -98,6 +115,8 @@ class TestFloorRecommendations: assert obj.property def test_other_premises_below(self, input_properties): + input_properties[0].floor_area = 100 + input_properties[0].number_of_floors = 1 recommender = FloorRecommendations( property_instance=input_properties[0], materials=parts @@ -119,6 +138,7 @@ class TestFloorRecommendations: input_properties[2].perimeter = 20 input_properties[2].wall_type = "solid brick" input_properties[2].floor_type = "suspended" + input_properties[2].number_of_floors = 1 recommender = FloorRecommendations( property_instance=input_properties[2], @@ -127,7 +147,7 @@ class TestFloorRecommendations: assert recommender.estimated_u_value is None recommender.recommend() assert recommender.property.floor["is_suspended"] - assert recommender.estimated_u_value == 0.39 + assert recommender.estimated_u_value == 0.66 assert recommender.recommendations types = {part["type"] for x in recommender.recommendations for part in x["parts"]} @@ -140,6 +160,8 @@ class TestFloorRecommendations: does not need floor insulation :return: """ + input_properties[3].floor_area = 100 + input_properties[3].number_of_floors = 1 recommender = FloorRecommendations( property_instance=input_properties[3], materials=parts @@ -162,6 +184,7 @@ class TestFloorRecommendations: input_properties[4].perimeter = 50 input_properties[4].wall_type = "solid brick" input_properties[4].floor_type = "solid" + input_properties[4].number_of_floors = 1 recommender = FloorRecommendations( property_instance=input_properties[4], @@ -171,7 +194,7 @@ class TestFloorRecommendations: recommender.recommend() assert not recommender.property.floor["is_suspended"] assert recommender.property.floor["is_solid"] - assert recommender.estimated_u_value == 0.71 + assert recommender.estimated_u_value == 0.73 assert recommender.recommendations types = {part["type"] for x in recommender.recommendations for part in x["parts"]} @@ -183,6 +206,8 @@ class TestFloorRecommendations: This is another description we see when there is a property below """ + input_properties[6].floor_area = 100 + input_properties[6].number_of_floors = 1 recommender = FloorRecommendations( property_instance=input_properties[6], materials=parts @@ -193,3 +218,124 @@ class TestFloorRecommendations: assert not recommender.property.floor["is_solid"] 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 diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py index eb1a5024..22280ed5 100644 --- a/recommendations/tests/test_recommendation_utils.py +++ b/recommendations/tests/test_recommendation_utils.py @@ -1,3 +1,4 @@ +import numpy as np import pytest import math from unittest.mock import MagicMock @@ -277,6 +278,22 @@ class TestRecommendationUtils: insulation_thickness=None, ) + 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("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("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 + def test_estimate_perimeter_regular_inputs(): assert math.isclose( @@ -333,3 +350,58 @@ def test_park_home(): assert recommendation_utils.get_floor_u_value( 'suspended', 100, 40, 'A', 'park home', insulation_thickness="20mm" ) == 0 + + +def test_esimtate_pitched_roof_area(): + roof_area1 = recommendation_utils.esimtate_pitched_roof_area( + floor_area=100, floor_height=2 + ) + + assert np.isclose(roof_area1, 107.70329614269008) + + # As the floor height gets bigger, the area should get bigger + roof_area2 = recommendation_utils.esimtate_pitched_roof_area( + floor_area=100, floor_height=3 + ) + + assert np.isclose(roof_area2, 116.61903789690601) + + # As the floor area gets smaller, the area should get smaller + roof_area3 = recommendation_utils.esimtate_pitched_roof_area( + floor_area=100, floor_height=1 + ) + + assert np.isclose(roof_area3, 101.9803902718557) + + # As the floor area decreases, area should decrease + roof_area4 = recommendation_utils.esimtate_pitched_roof_area( + floor_area=50, floor_height=2 + ) + + assert np.isclose(roof_area4, 57.44562646538029) + + # As the floor area increases, area should increase + roof_area5 = recommendation_utils.esimtate_pitched_roof_area( + floor_area=150, floor_height=2 + ) + + assert np.isclose(roof_area5, 157.797338380595) + + zero_roof_area = recommendation_utils.esimtate_pitched_roof_area( + floor_area=0, floor_height=1000 + ) + + assert zero_roof_area == 0 + + # If the floor height zero, we don't have a traingle, it's a flat roof + flat_roof_area = recommendation_utils.esimtate_pitched_roof_area( + floor_area=1000, floor_height=0 + ) + + assert flat_roof_area == 1000 + + zero_roof_area2 = recommendation_utils.esimtate_pitched_roof_area( + floor_area=0, floor_height=0 + ) + + assert zero_roof_area2 == 0 diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py new file mode 100644 index 00000000..37cc2daf --- /dev/null +++ b/recommendations/tests/test_roof_recommendations.py @@ -0,0 +1,429 @@ +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 + } +] + + +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.roof = { + 'original_description': 'Pitched, no insulation (assumed)', + 'clean_description': 'Pitched, no insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, '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', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' + } + + roof_recommender = RoofRecommendations(property_instance=property_instance, materials=loft_insulation_materials) + + assert not roof_recommender.recommendations + + roof_recommender.recommend() + + assert len(roof_recommender.recommendations) + + 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.roof = { + 'original_description': 'Pitched, 50mm loft insulation (assumed)', + 'clean_description': 'Pitched, 50mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + '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' + } + + roof_recommender2 = RoofRecommendations( + property_instance=property_instance2, materials=loft_insulation_materials + ) + + assert not roof_recommender2.recommendations + + roof_recommender2.recommend() + + assert len(roof_recommender2.recommendations) == 1 + + assert roof_recommender2.recommendations[0]["cost"] == 900 + 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.roof = { + 'original_description': 'Pitched, 50mm loft insulation (assumed)', + 'clean_description': 'Pitched, 50mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + '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' + } + + roof_recommender3 = RoofRecommendations( + property_instance=property_instance3, materials=loft_insulation_materials_50mm_existing + ) + + 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] + + 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.roof = { + 'original_description': 'Pitched, 150mm loft insulation (assumed)', + 'clean_description': 'Pitched, 150mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + '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' + } + + roof_recommender4 = RoofRecommendations( + property_instance=property_instance4, materials=loft_insulation_materials + ) + + assert not roof_recommender4.recommendations + + roof_recommender4.recommend() + + assert len(roof_recommender4.recommendations) == 1 + + assert roof_recommender4.recommendations[0]["cost"] == 900 + assert roof_recommender4.recommendations[0]["new_u_value"] == 0.11 + assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3 + + property_instance5 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance5.age_band = "F" + property_instance5.floor_area = 100 + property_instance5.roof = { + 'original_description': 'Pitched, 150mm loft insulation (assumed)', + 'clean_description': 'Pitched, 150mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + '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' + } + + roof_recommender5 = RoofRecommendations( + property_instance=property_instance5, materials=loft_insulation_materials_150mm_existing + ) + + assert not roof_recommender5.recommendations + + roof_recommender5.recommend() + + # The 130mm insulation should be selected, not the 110 + assert roof_recommender5.recommendations + assert len(roof_recommender5.recommendations) == 1 + assert roof_recommender5.recommendations[0]["parts"][0]["depths"] == [130] + + 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.roof = { + 'original_description': 'Pitched, 270mm loft insulation (assumed)', + 'clean_description': 'Pitched, 270mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + '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' + } + + roof_recommender6 = RoofRecommendations( + property_instance=property_instance6, materials=loft_insulation_materials + ) + + assert not roof_recommender6.recommendations + + roof_recommender6.recommend() + + 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"