import math from typing import List import pandas as pd from datatypes.enums import QuantityUnits from backend.Property import Property from BaseUtility import Definitions from recommendations.recommendation_utils import ( r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns, update_lowest_selected_u_value, get_recommended_part, get_wall_u_value ) from recommendations.config import PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION from recommendations.Costs import Costs from utils.logger import setup_logger logger = setup_logger() class WallRecommendations(Definitions): YEAR_WALLS_BUILT_WITH_INSULATION = 1990 # After 1930, Solid brick walls became less populate and instead, cavity walls became a # more popular choice YEARS_CAVITY_WALLS_BEGAN = 1930 U_VALUE_UNIT = 'w/m-¦k' # 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 # 0.15 is an often cited diminishing returns value for new builds NEW_BUILD_DIMINISHING_RETURNS_U_VALUE = 0.15 # Add some error so that if, for example, a new part we recommend provides a u-value of 0.19, # we still consider it as an option U_VALUE_ERROR = 0.01 def __init__( self, property_instance: Property, materials: List ): self.property = property_instance self.costs = Costs(self.property) # For audit purposes, when estimating u values we'll store it self.estimated_u_value = None # Will contains a list of recommended measures self.recommendations = [] self.cavity_wall_insulation_materials = [ part for part in materials if part["type"] == "cavity_wall_insulation" ] self.internal_wall_insulation_materials = [ part for part in materials if part["type"] == "internal_wall_insulation" ] self.internal_wall_non_insulation_materials = [ part for part in materials if part["type"] in [ "iwi_wall_demolition", "iwi_vapour_barrier", "iwi_redecoration" ] ] self.external_wall_insulation_materials = [ part for part in materials if part["type"] == "external_wall_insulation" ] self.external_wall_non_insulation_materials = [ part for part in materials if part["type"] in [ "ewi_wall_demolition", "ewi_wall_preparation", "ewi_wall_redecoration" ] ] @property def ewi_valid(self): """ This method check available data, to determine if a property is suitable for external wall insulation """ # Current logic: If the property is in a conservation area/heritage building/listed building or a flat, # it is not suitable for EWI if self.property.restricted_measures or (self.property.data["property-type"].lower() == "flat"): return False return True def recommend(self): # if building built after 1990 + we're able to identify U-value + # U-value less than 0.18 and if in or close to a conversation area, # recommend internal wall insulation as a possible measure u_value = self.property.walls["thermal_transmittance"] is_cavity_wall = self.property.walls["is_cavity_wall"] insulation_thickness = self.property.walls["insulation_thickness"] # We check if the wall is already insulated and if so, we exit if (insulation_thickness in ["average", "above average"]) or self.property.walls["is_filled_cavity"]: return if u_value: if self.property.walls["thermal_transmittance_unit"] != self.U_VALUE_UNIT: raise NotImplementedError("Haven't handled the case of other u value units yet") # We can't detect it's a cavity wall, but it was built after 1990 so likely built with insulation already # + it already has a U-value WORSE than the building regulations, so we recommend either internal or # external wall insulation if (not is_cavity_wall) and (self.property.year_built >= self.YEAR_WALLS_BUILT_WITH_INSULATION) and ( u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE ): # Recommend insulation self.find_insulation(u_value) return # We can't detect it's a cavity wall, but it was built after 1990 so likely built with insulation already # + it already has a U-value better than the building regulations, so we don't need to recommend anything if (not is_cavity_wall) and (self.property.year_built >= self.YEAR_WALLS_BUILT_WITH_INSULATION) and ( u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE ): # Recommend nothing return raise NotImplementedError("Not implemented yet") u_value = get_wall_u_value( clean_description=self.property.walls["clean_description"], age_band=self.property.age_band, is_granite_or_whinstone=self.property.walls["is_granite_or_whinstone"], is_sandstone_or_limestone=self.property.walls["is_sandstone_or_limestone"], ) self.estimated_u_value = u_value if is_cavity_wall: if u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: # Test filling cavity self.find_cavity_insulation(u_value, insulation_thickness) return # Remaining wall types are treated with IWI or EWI if u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: self.find_insulation(u_value) return # If the u-value is within regulations, we don't do anything return def find_cavity_insulation(self, u_value, insulation_thickness): """ This method tests different materials to fill the cavity wall, determining which material will give us the best U-value. We check for diminishing returns, however this function does not check for meeting building Part L regulations right now. This is because Part L is less stringent for cavity walls Width of a cavity: There are various sources online that suggest the width of a cavity wall is around 50mm. The retrofit course indicates that most cavities are 50-75mm. Many sources online indicate that 50mm is the standard MINIMUM figure therefore we'll use 75mm as the base assumption This document: https://www.buildingcentre.co.uk/media/_file/pdf/22220_pdf27.pdf Indicates that they could be 50-85mm wide :param u_value: u_value of the starting wall :param insulation_thickness: describes the insulation level of the wall. If "below average", we have a partially filled cavity wall """ insulation_materials = pd.DataFrame(self.cavity_wall_insulation_materials) cavity_width = 75 if insulation_thickness == "below average": cavity_width = cavity_width * (1 - PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION) # Test the different fill options lowest_selected_u_value = None recommendations = [] for _, material in insulation_materials.iterrows(): part_u_value = r_value_per_mm_to_u_value(cavity_width, material["r_value_per_mm"]) _, new_u_value = calculate_u_value_uplift(u_value, part_u_value) new_u_value = math.ceil(new_u_value * 100.0) / 100.0 if is_diminishing_returns( recommendations, new_u_value, lowest_selected_u_value, self.DIMINISHING_RETURNS_U_VALUE ): continue 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) cost_result = self.costs.cavity_wall_insulation( wall_area=self.property.insulation_wall_area, material=material.to_dict(), ) recommendations.append( { "parts": [ get_recommended_part( part=material.to_dict(), quantity=self.property.insulation_wall_area, quantity_unit=QuantityUnits.m2.value, cost_result=cost_result ) ], "type": "cavity_wall_insulation", "description": self._make_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, **cost_result } ) self.recommendations = recommendations def _find_insulation(self, u_value, insulation_materials, non_insulation_materials): lowest_selected_u_value = None recommendations = [] for _, insulation_material_group in insulation_materials.groupby("description"): for _, material in insulation_material_group.iterrows(): part_u_value = r_value_per_mm_to_u_value(material["depth"], material["r_value_per_mm"]) _, new_u_value = calculate_u_value_uplift(u_value, part_u_value) new_u_value = math.ceil(new_u_value * 100.0) / 100.0 # 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) if material["type"] == "internal_wall_insulation": cost_result = self.costs.internal_wall_insulation( wall_area=self.property.insulation_wall_area, material=material.to_dict(), non_insulation_materials=non_insulation_materials ) elif material["type"] == "external_wall_insulation": cost_result = self.costs.external_wall_insulation( wall_area=self.property.insulation_wall_area, material=material.to_dict(), non_insulation_materials=non_insulation_materials ) else: raise ValueError("Invalid material type") recommendations.append( { "parts": [ get_recommended_part( part=material.to_dict(), quantity=self.property.insulation_wall_area, quantity_unit=QuantityUnits.m2.value, cost_result=cost_result ) ], "type": material["type"], "description": self._make_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, **cost_result } ) return recommendations def find_insulation(self, u_value): """ This function contains the logic for finding potential insulation measures for a property, depending on the parts available and whether the property can have external wall insulation installed :return: """ # Recommend external and internal wall insulation separately # Since external and internal wall insulation are sufficiently different, # we separate the logic for for recommending them, therefore we don't # consider diminishing returns between the two as they are considered to be separate measures ewi_recommendations = [] if self.ewi_valid: ewi_recommendations = self._find_insulation( u_value=u_value, insulation_materials=pd.DataFrame(self.external_wall_insulation_materials), non_insulation_materials=self.external_wall_non_insulation_materials ) iwi_recommendations = self._find_insulation( u_value=u_value, insulation_materials=pd.DataFrame(self.internal_wall_insulation_materials), non_insulation_materials=self.internal_wall_non_insulation_materials ) self.recommendations += ewi_recommendations + iwi_recommendations @staticmethod def _make_description(material): if material["type"] == "internal_wall_insulation": return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} on internal " f"walls") if material["type"] == "external_wall_insulation": return (f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} on external " f"walls") if material["type"] == "cavity_wall_insulation": return f"Fill cavity with {material['description']}" raise ValueError("Invalid material type") @staticmethod def rvalue_per_mm(total_r_value: float, thickness_mm: float) -> float: """Return R-value per mm. Parameters ---------- total_r_value : float Total R-value (in m2K/W). thickness_mm : float Thickness of the material in mm. Returns ------- float R-value per mm. """ return total_r_value / thickness_mm @staticmethod def thermal_conductivity_to_r_value_per_mm(thermal_conductivity: float) -> float: """Convert thermal conductivity to R-value per mm. Parameters ---------- thermal_conductivity : float Thermal conductivity (in W/mK). Returns ------- float R-value per mm. """ # Calculate R-value in m²K/W for 1 meter of the material r_value_per_meter = 1 / thermal_conductivity # Convert R-value to R-value per mm r_value_per_mm = r_value_per_meter / 1000 return r_value_per_mm