import math from typing import List import numpy as np import pandas as pd from datatypes.enums import QuantityUnits from backend.Property import Property from backend.app.plan.schemas import MEASURE_MAP from BaseUtility import Definitions from etl.epc_clean.epc_attributes.WallAttributes import WallAttributes 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, override_costs, check_simulation_difference, check_use_survey ) from recommendations.config import PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION from recommendations.Costs import Costs from recommendations.wall_energy_efficiency_values import cavity_wall_energy_eff, iwi_energy_eff, ewi_energy_eff 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 # Typically when the U-value is around 0.75 and below, and the home is a new build, this is a good indication # that the home is already insulated with at least some partial insulation. We don't recommend insulation # in this case. This estimate was verified with the Warmfront team and 0.75 has been used as a conservative # threshold NEW_BUILD_INSULATED = 0.75 # These are the ending descriptions we consider for walls with external insulation # This maps the clean descriptions to the ending descriptions EXTERNALLY_INSULATED_WALL_DESCRIPTIONS = { "Cavity wall, as built, insulated": "Cavity wall, filled cavity and external insulation", "Solid brick, as built, no insulation": "Solid brick, with external insulation", "Solid brick, as built, insulated": "Solid brick, with external insulation", "Solid brick, as built, partial insulation": "Solid brick, with external insulation", "Cob, as built": "Cob, with external insulation", "System built, as built, no insulation": "System built, with external insulation", 'System built, as built, partial insulation': "System built, with external insulation", "Granite or whinstone, as built, no insulation": 'Granite or whinstone, with external insulation', "Timber frame, as built, no insulation": "Timber frame, with external insulation", 'Timber frame, as built, partial insulation': 'Timber frame, with external insulation', "Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with external insulation", "Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with external insulation", "Sandstone, as built, no insulation": "Sandstone, with external insulation", "Sandstone, as built, partial insulation": "Sandstone, with external insulation", } # These are the ending descriptions we consider for walls with internal insulation INTERNALLY_INSULATED_WALL_DESCRIPTIONS = { "Cavity wall, as built, insulated": "Cavity wall, filled cavity and internal insulation", "Solid brick, as built, no insulation": "Solid brick, with internal insulation", "Solid brick, as built, insulated": "Solid brick, with internal insulation", "Solid brick, as built, partial insulation": "Solid brick, with internal insulation", "Cob, as built": "Cob, with internal insulation", "System built, as built, no insulation": "System built, with internal insulation", 'System built, as built, partial insulation': "System built, with internal insulation", "Granite or whinstone, as built, no insulation": 'Granite or whinstone, with internal insulation', "Timber frame, as built, no insulation": "Timber frame, with internal insulation", 'Timber frame, as built, partial insulation': 'Timber frame, with internal insulation', "Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with internal insulation", "Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with internal insulation", "Sandstone, as built, no insulation": "Sandstone, with internal insulation", "Sandstone, as built, partial insulation": "Sandstone, with internal insulation", } 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 = [] # Contains a list of extended recommendation measures, such as extension insulation self.extended_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.external_wall_insulation_materials = [ part for part in materials if part["type"] == "external_wall_insulation" ] 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.epc_record.property_type.lower() == "flat" ) or ( self.property.walls['is_cob'] or self.property.walls['is_sandstone_or_limestone'] or self.property.walls["is_cavity_wall"] ): return False return True def is_suitable_for_solid_insulation(self): """ Checks if the wall is of a suitable type for internal/external wall insulation """ if self.property.walls["is_cavity_wall"] or self.property.walls["is_cob"] or self.property.walls[ "is_granite_or_whinstone"] or self.property.walls["is_sandstone_or_limestone"]: return False return True def recommend(self, phase=0, measures=None, default_u_values=False): # 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 measures = MEASURE_MAP["wall_insulation"] if measures is None else measures if not measures: return u_value = self.property.walls["thermal_transmittance"] u_value = None if pd.isnull(u_value) else u_value 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"] or self.property.walls["clean_description"] in [None, "Sap05:walls"] ) and ("cavity_extract_and_refill" not in measures ): return if u_value is not None: if self.property.walls["thermal_transmittance_unit"] != self.U_VALUE_UNIT: raise NotImplementedError( "Haven't handled the case of other u value units yet" ) # If the property is a new build and the U-value is below 0.75, we don't recommend insulation because it's # not practical if (self.property.epc_record.transaction_type == "new dwelling") and ( u_value <= self.NEW_BUILD_INSULATED ): # Recommend nothing 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 WORSE than the building regulations, so we recommend either internal or # external wall insulation if ( (not is_cavity_wall) and (u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) ): # Recommend insulation self.find_insulation(u_value, phase, measures=measures, default_u_values=default_u_values) return # We have a sufficiently low U-value return 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 and "cavity_wall_insulation" in measures) or "cavity_extract_and_refill" in measures: if u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: # Test filling cavity self.find_cavity_insulation(u_value, insulation_thickness, phase, measures, default_u_values) return # Remaining wall types are treated with IWI or EWI if (u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) and self.is_suitable_for_solid_insulation(): self.find_insulation(u_value, phase, measures=measures, default_u_values=default_u_values) return # If the u-value is within regulations, we don't do anything return def recommend_extended(self, phase, measures): """ Where we have extended measures, such as extension insulation, which cannot typically be picked up from the EPC api, we handle the recommendation of these here :param measures: :return: """ # These are the measures that are covered by this function extended_measures = ["extension_cavity_wall_insulation"] measures_to_recommend = [measure for measure in measures if measure in extended_measures] if not measures_to_recommend: return phase # We reset this to be empty self.extended_recommendations = [] recommendation_phase = phase for measure in measures_to_recommend: if measure == "extension_cavity_wall_insulation": recommendation = self.recommend_extension_cavity_wall_insulation(phase=recommendation_phase) else: raise NotImplementedError(f"Measure {measure} is not implemented") recommendation_phase += 1 self.extended_recommendations.append(recommendation) return recommendation_phase def recommend_extension_cavity_wall_insulation(self, phase): """ This function produces the recommendation for extension cavity wall insulation :return: """ # TODO: We aren't provided with carbon, heat or bill savings figures for this measure extension_cavity_insulation_recommendation = [ r for r in self.property.non_invasive_recommendations if r["type"] == "extension_cavity_wall_insulation" ][0] # https://surreybuildingprojects.co.uk/how-much-does-a-24m2-extension-cost average_extension_floor_area = 24 # https://assets.publishing.service.gov.uk/media/5f047a01d3bf7f2be8350262 # /Size_of_English_Homes_Fact_Sheet_EHS_2018.pdf # This is rough average_house_floor_area = 94 proposed_extension_floor_area = self.property.floor_area * ( average_extension_floor_area / average_house_floor_area ) # assume 3 walls are external proposed_extension_insulation_wall_area = ( np.sqrt(proposed_extension_floor_area) * self.property.floor_height * 3 ) cost_result = self.costs.cavity_wall_insulation( wall_area=proposed_extension_insulation_wall_area, material=self.cavity_wall_insulation_materials[0], ) recommendation = { "phase": phase, "parts": [], "type": "extension_cavity_wall_insulation", "measure_type": "extension_cavity_wall_insulation", "description": "Insulate the cavity walls of the extension", "starting_u_value": None, "new_u_value": None, "sap_points": extension_cavity_insulation_recommendation["sap_points"], "heat_demand": 0, "kwh_savings": 0, "energy_savings": 0, "energy_cost_savings": 0, "co2_equivalent_savings": 0, "already_installed": False, "simulation_config": {}, "description_simulation": {}, **cost_result, "default": True, } return recommendation def find_cavity_insulation(self, u_value, insulation_thickness, phase, measures, default_u_values): """ 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 :param phase: The phase of the recommendation :param measures: The measures we're considering :param default_u_values: If we should use default u values """ 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) non_invasive_recommendations = next( (r for r in self.property.non_invasive_recommendations if r["type"] == insulation_materials["type"].values[0]), {} ) # 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 ) is_extraction_and_refill = ( "cavity_extract_and_refill" in measures ) cost_result = self.costs.cavity_wall_insulation( wall_area=self.property.insulation_wall_area, material=material.to_dict(), is_extraction_and_refill=is_extraction_and_refill, ) already_installed = ( "cavity_wall_insulation" in self.property.already_installed ) if already_installed: cost_result = override_costs(cost_result) if is_extraction_and_refill: description = f"Extract and refill cavity wall insulation with {material['description']}" else: description = self._make_description(material) # updated the new u-value with the best possible our installers have if default_u_values: new_u_value = get_wall_u_value( clean_description="Cavity wall, filled cavity", age_band="G", is_granite_or_whinstone=self.property.walls["is_granite_or_whinstone"], is_sandstone_or_limestone=self.property.walls["is_sandstone_or_limestone"], ) else: new_u_value = max(0.31, new_u_value) wall_ending_config = WallAttributes("Cavity wall, filled cavity").process() walls_simulation_config = check_simulation_difference( new_config=wall_ending_config, old_config=self.property.walls, prefix="walls_" ) simulation_config = self.set_starting_simulation_config( wall_ending_config=wall_ending_config ) simulation_config = { **simulation_config, **walls_simulation_config, "walls_thermal_transmittance_ending": new_u_value if not default_u_values else 0.7, } recommendations.append( { "phase": phase, "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", "measure_type": "cavity_wall_insulation", "description": description, "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": non_invasive_recommendations.get("sap_points", None), "already_installed": already_installed, "simulation_config": simulation_config, "description_simulation": { "walls-description": "Cavity wall, filled cavity", "walls-energy-eff": "Good" }, **cost_result, "survey": check_use_survey( non_invasive_recommendations, self.property.epc_record.has_been_remodelled ), "innovation_rate": material.to_dict()["innovation_rate"] } ) self.recommendations = recommendations def get_internal_external_wall_description(self, description_map, new_u_value): if "Average thermal transmittance" in self.property.walls["clean_description"]: if new_u_value is None: raise ValueError("New u value is None") return f'Average thermal transmittance {new_u_value} W/m-¦K' return description_map[self.property.walls["clean_description"]] def set_starting_simulation_config(self, wall_ending_config): """ Helper function to set the starting simulation config """ if wall_ending_config["is_cavity_wall"]: efficiency_data = [ x for x in cavity_wall_energy_eff if x["construction-age-band"] == self.property.construction_age_band ][0] elif wall_ending_config["internal_insulation"]: efficiency_data = [ x for x in iwi_energy_eff if x["construction-age-band"] == self.property.construction_age_band ][0] else: efficiency_data = [ x for x in ewi_energy_eff if x["construction-age-band"] == self.property.construction_age_band ][0] if self.property.epc_record.walls_energy_eff == "Good" and efficiency_data["walls-energy-eff"] not in [ "Good", "Very Good" ]: simulation_config = { "walls_energy_eff_ending": self.property.epc_record.walls_energy_eff } elif self.property.epc_record.walls_energy_eff == "Very Good": simulation_config = { "walls_energy_eff_ending": "Very Good" } else: simulation_config = { "walls_energy_eff_ending": efficiency_data["walls-energy-eff"] } # We check if we have double insulation in any instances # TODO: We should pull the energy efficiency categories on double insulation instances, though it's quite rate double_insulation = ( (wall_ending_config["is_filled_cavity"] and wall_ending_config["external_insulation"]) or (wall_ending_config["is_filled_cavity"] and wall_ending_config["internal_insulation"]) or (wall_ending_config["external_insulation"] and wall_ending_config["internal_insulation"]) ) if double_insulation: simulation_config["walls_energy_eff_ending"] = "Very Good" return simulation_config def _find_insulation(self, u_value, insulation_materials, phase, default_u_values): lowest_selected_u_value = None recommendations = [] non_invasive_recommendations = next( (r for r in self.property.non_invasive_recommendations if r["type"] == insulation_materials["type"].values[0]), {} ) 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 ) cost_result = self.costs.solid_wall_insulation( wall_area=self.property.insulation_wall_area, material=material.to_dict(), ) already_installed = material["type"] in self.property.already_installed if already_installed: cost_result = override_costs(cost_result) if non_invasive_recommendations.get("cost") is not None: raise NotImplementedError( "Not handled passing costs from non-invasive recommendations for iwi" ) if material["type"] == "internal_wall_insulation": new_description = self.get_internal_external_wall_description( self.INTERNALLY_INSULATED_WALL_DESCRIPTIONS, new_u_value ) elif material["type"] == "external_wall_insulation": new_description = self.get_internal_external_wall_description( self.EXTERNALLY_INSULATED_WALL_DESCRIPTIONS, new_u_value ) else: raise ValueError("Invalid material type") sap_points = non_invasive_recommendations.get("sap_points", None) wall_ending_config = WallAttributes(new_description).process() walls_simulation_config = check_simulation_difference( new_config=wall_ending_config, old_config=self.property.walls, prefix="walls_" ) simulation_config = self.set_starting_simulation_config( wall_ending_config=wall_ending_config ) simulation_config = { **walls_simulation_config, **simulation_config, "walls_thermal_transmittance_ending": new_u_value } if default_u_values and "Average thermal transmittance" not in new_description: # If we're using default U-values, we overwrite new_u_value new_u_value = get_wall_u_value( clean_description=new_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"], ) recommendations.append( { "phase": phase, "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"], "measure_type": material["type"], # This is distinguished between EWI & IWI "description": self._make_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, "already_installed": already_installed, "sap_points": sap_points, "simulation_config": simulation_config, "description_simulation": { "walls-description": new_description, "walls-energy-eff": simulation_config["walls_energy_eff_ending"] }, **cost_result, "survey": check_use_survey( non_invasive_recommendations, self.property.epc_record.has_been_remodelled ), "innovation_rate": material.to_dict()["innovation_rate"] } ) return recommendations def find_insulation(self, u_value, phase, measures, default_u_values): """ 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 prop_already_installed = self.property.already_installed # So, we'll end up with problems if e.g. an external wall insulation is already installed and we try and # recommend internal wall insulation. To avoid this, we check if either measure is already installed # and: # 1) If EWI is installed, we don't recommend IWI # 2) If IWI is installed, we don't recommend EWI # We only produce the recommendation for the moment, for the purpose of re-baselining ewi_recommendations = [] if self.ewi_valid() and "external_wall_insulation" in measures and ( "internal_wall_insulation" not in prop_already_installed ): ewi_recommendations = self._find_insulation( u_value=u_value, insulation_materials=pd.DataFrame( self.external_wall_insulation_materials ), phase=phase, default_u_values=default_u_values ) iwi_recommendations = [] if "internal_wall_insulation" in measures and "external_wall_insulation" not in prop_already_installed: iwi_recommendations = self._find_insulation( u_value=u_value, insulation_materials=pd.DataFrame(self.internal_wall_insulation_materials), phase=phase, default_u_values=default_u_values ) 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