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_s12 as s12 ) from recommendations.config import PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION, PARTIAL_CAVITY_DESCRIPTIONS def r_value_per_mm_to_u_value(depth_mm: int, r_value_per_mm: float): """ Converts R-value per mm to U-value in W/m²K. Parameters ---------- depth_mm : int Depth of the material in mm. r_value_per_mm : float R-value per mm. Returns ------- float U-value in W/m²K. """ return 1 / (depth_mm * r_value_per_mm) def calculate_u_value_uplift(u_value, insulation_u_value): """ Calculates the U-value uplift (improvement) when applying internal wall insulation to a wall. :param u_value: Float, Starting U-value of the wall (without insulation) in W/m²K. :param insulation_u_value: Float, U-value of the internal wall insulation in W/m²K. Returns: float: U-value uplift (improvement) achieved by applying internal wall insulation in W/m²K. Raises: ZeroDivisionError: If either u_value or iwi_u_value is zero. Notes: This function assumes 100% coverage of the internal wall insulation and does not account for other factors such as thermal bridging or the specific configuration of the wall. """ inverse_u_value = 1 / u_value inverse_insulation_u_value = 1 / insulation_u_value inverse_u_total = inverse_u_value + inverse_insulation_u_value new_u_value = 1 / inverse_u_total u_value_uplift = u_value - new_u_value return u_value_uplift, new_u_value def is_diminishing_returns(recommendations, new_u_value, lowest_selected_u_value, diminishing_returns_u_value): """ What are defines diminishing returns? 1) The new u value is lower than the lowest selected u value 2) The new u value is below the diminishing returns threshold 3) We already have some recommendations so there is no need to insert another recommendation in """ # if we don't have anything selected, lowest_selected_u_value will be missing if lowest_selected_u_value is None: if recommendations: raise ValueError("Recommendations should be empty - investigate") # This means that nothing has been selected yet # the new u value is less than the threshold, however this MIGHT be the only # solution and so we consider it return False # We should already have recommendations if not recommendations: raise ValueError("Recommendations should not be empty - investigate") # We already have a solution that is suitable so we want to make sure that # any new solutin actually has a higher u-value as it will either be # 1) cheaper # 2) thinner with a more efficient material is_diminishing = (new_u_value < diminishing_returns_u_value) and ( new_u_value < lowest_selected_u_value ) return is_diminishing def update_lowest_selected_u_value(lowest_selected_u_value, new_u_value): """ Utility funciton which holds the logic for how we update the lowest selected u value :param lowest_selected_u_value: current lowest selected u value, initialised as None :param new_u_value: new u value to compare against :return: """ if lowest_selected_u_value is None: lowest_selected_u_value = new_u_value if new_u_value <= lowest_selected_u_value: lowest_selected_u_value = new_u_value return lowest_selected_u_value def get_recommended_part(part, cost_result, quantity, quantity_unit): """ Utility function to return a recommended part with the selected depth. :param part: part to be recommended :param cost_result: Total cost of the selected part, as returned by the Cost class :param quantity: Quantity of the selected part :param quantity_unit: Unit of the quantity :return: """ recommended_part = deepcopy(part) recommended_part["quantity"] = quantity recommended_part["quantity_unit"] = quantity_unit recommended_part.update(cost_result) return recommended_part def apply_formula_s_5_1_1(is_granite_or_whinstone, is_sandstone_or_limestone, age_band): """ As the u-value table in https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf on page 19, certain u-values as indicated by an "a", should be populated using a formula as defined in section S.5.1.1 """ stone_wall_thickness = [x for x in default_wall_thickness if x["type"] == "stone"][0] thickness = stone_wall_thickness["J_K_L"] if age_band in ["J", "L", "L"] else stone_wall_thickness[age_band] if is_granite_or_whinstone: return 3.3 - 0.002 * thickness if is_sandstone_or_limestone: return 3 - 0.002 * thickness raise ValueError("This should only be called when is_granite_or_whinstone or is_sandstone_or_limestone is True") def get_wall_u_value( clean_description, age_band, is_granite_or_whinstone, is_sandstone_or_limestone ): """ Given some features about a wall, this function will query the wall u-value table and return the u-value :param clean_description: Cleaned up description of the wall from the EPC data :param age_band: age band of the property from the EPC data :param is_granite_or_whinstone: Boolean indicating if the wall is made of granite or whinstone :param is_sandstone_or_limestone: Boolean indicating if the wall is made of sandstone or limestone :return: """ if clean_description in PARTIAL_CAVITY_DESCRIPTIONS: # If we have a partial cavity fill, we linearly interpolate the u-value. This isn't necessarily the perfect # method and how we do this should be explored, however we want to distinguish between the old filled_uvalue = float(wall_uvalues_df[wall_uvalues_df["Wall_type"] == "Filled cavity"][age_band].values[0]) unfilled_uvalue = float(wall_uvalues_df[wall_uvalues_df["Wall_type"] == "Cavity as built"][age_band].values[0]) mapped_value = str( unfilled_uvalue - (PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION * (unfilled_uvalue - filled_uvalue)) ) else: mapped_description = epc_wall_description_map[clean_description] mapped_value = wall_uvalues_df[wall_uvalues_df["Wall_type"] == mapped_description][age_band].values[0] if pd.isnull(mapped_value) and "Park home" in mapped_description: # We don't know enough in this case so we default to 0 return 0 if mapped_value == "a": # The rdSap documentation indicateswe should use a formula to calculate the u-value return float( apply_formula_s_5_1_1( is_granite_or_whinstone=is_granite_or_whinstone, is_sandstone_or_limestone=is_sandstone_or_limestone, age_band=age_band ) ) if "b" in mapped_value: potential_uvalue = float(mapped_value.replace("b", "")) formula_uvalue = float(apply_formula_s_5_1_1( is_granite_or_whinstone=is_granite_or_whinstone, is_sandstone_or_limestone=is_sandstone_or_limestone, age_band=age_band )) return min(potential_uvalue, formula_uvalue) if mapped_value == "s1.1.2": # We don't know enough in this case so we default to 0 return 0 return float(mapped_value) def get_u_value_from_s9(thickness, s9, is_loft, is_roof_room, is_thatched): """Get the U-value from table S9 based on the insulation thickness.""" if thickness in ["below average", "average", "above average", "none", None] or ( not is_loft and not is_roof_room ): return None elif thickness.endswith("+"): thickness = int(thickness[:-1]) else: try: thickness = int(thickness) except ValueError: # If thickness is not a valid number (could be a string or None), return None return None # Determine the column to refer based on the roof type column = 'Thatched_roof_U_value_W_m2K' if is_thatched else 'Slates_or_tiles_U_value_W_m2K' # Get the correct U-value based on the insulation thickness return s9[s9['Insulation_thickness_mm'] >= thickness][column].iloc[0] def get_roof_u_value( insulation_thickness, has_dwelling_above, is_loft, is_roof_room, is_thatched, age_band, is_flat, is_pitched, is_at_rafters, **kwargs ): """ Determine the U-value for a roof based on the description dictionary and age band. We use table s9 is the insulation thickness was measured, otherwise we use table s10. The methodology for this process can be found in page 23 of the BRE rdsap 2012 document found here: https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf Parameters: insulation_thickness (str): contains description of the insulation thickness - may be missing has_dwelling_above (bool): Indicates if there is a property above is_loft (bool): Indicates if ther oof has a loft is_roof_room (bool): Indicates if there is a room in roof is_thatched (bool): Indicates if the roof is thatched is_flat (bool): Indicates if the roof is flat is_pitched (bool): Indicates if the roof is pitched is_at_rafters (bool): Indicates if there is insulation at the rafters of the roof age_band (str): The age band of the property. s9 (pd.DataFrame): The DataFrame representing table S9. s10 (pd.DataFrame): The DataFrame representing table S10. Returns: float: The determined U-value. """ # If there is a dwelling above, the U-value is 0 if has_dwelling_above: return 0.0 # Step 1: Try to get the U-value from table S9 based on the insulation thickness # The conditions for using table S9 are: # - The insulation thickness is known # - The roof is either a loft or a roof room # The criteria for using this table is predominately defined by insulation around joists which is predominately # a feature of lofts and roof rooms u_value = get_u_value_from_s9( thickness=insulation_thickness, s9=s9, is_loft=is_loft, is_roof_room=is_roof_room, is_thatched=is_thatched, ) if u_value is not None: return u_value # Step 2: If the U-value could not be determined from table S9, use table S10 # Define the columns to be used based on the description details if is_flat: column = 'Flat_roof' elif is_thatched: if is_roof_room: column = 'Thatched_roof_room_in_roof' else: column = 'Thatched_roof' elif is_roof_room: column = 'Room_in_roof_slates_or_tiles' elif is_pitched: if is_at_rafters: column = 'Pitched_slates_or_tiles_insulation_at_rafters' else: column = 'Pitched_slates_or_tiles_insulation_between_joists_or_unknown' else: # Default to pitched roof with insulation between joists or unknown column = 'Pitched_slates_or_tiles_insulation_between_joists_or_unknown' # Get the U-value from table S10 based on the age band and the determined column u_value = s10.loc[s10['Age_band'].str.contains(age_band), column].values[0] return float(u_value) def estimate_perimeter(floor_area, num_rooms): """ Uses a basic methodology to attempt to estimate perimeter. Works better for :param floor_area: floor area of the home :param num_rooms: number of rooms in the home :return: estimated perimeter """ if floor_area < 0: raise ValueError("Floor area cannot be negative.") if num_rooms <= 0: raise ValueError("Number of rooms must be greater than zero.") # Compute average room size based on total floor area and number of rooms avg_room_size = floor_area / num_rooms # Estimate the side length of a square room with the average room size avg_room_side_length = math.sqrt(avg_room_size) # Estimate total side length assuming rooms are lined up in a row total_side_length = avg_room_side_length * num_rooms # Estimate the length and width of the property assuming it is rectangular length = total_side_length / 2 width = floor_area / length # Compute the perimeter of the property perimeter = 2 * (length + width) 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 Default U-value for UNINSULATED suspended floor, based on RdSAP methodology https://files.bregroup.com/bre-co-uk-file-library-copy/filelibrary/SAP/2012/RdSAP-9.93/RdSAP_2012_9.93.pdf w = wall thickness, where these estimates are based on the RD SAP methodology, as in table S3 A = floor area Exposed perimeter = P soil type clas thermal conductivity lambda_g = 1.5 W/mK Rsi = 0.17m^2K/W Rse = 0.04m^2K/W Rf = 0.001 * d_ins / 0.035 where d_ins is the insulation thickness in mm height above external ground h = 0.3m average wind speed at 10m height v=5m/s wind sheilding factor fw = 0.05 vantilation factor E = 0.003 m^2/m U-value of walls to underfloor space Uw = 1.5 W/m^2K # Calulations for suspended ground floors, example for 5 bedroom house with permiter estimated at 44.36214602563767 1) dg = w + lambda_g x (Rsi + Rse) = 0.5 + 1.5 * (0.17 + 0.04) = 0.615 2) B = 2 * A/P = 2 * 123.0 / 44.36214602563767 = 5.545268253204708 3) Ug = 2 * lambda_g * log(pi * B/dg + 1)/(pi * B + dg) = 2 * 1.5 * log(3.141592653589793 * 5.545268253204708/0.615 + 1) / (3.141592653589793 * 5.545268253204708 + 0.615) = 0.5619604457160708 4) Ux = (2 * h * Uw /B) + (1450 * E * v * fw/B) = (2 * 0.3 * 1.5 / 5.545268253204708) + (1450 * 0.003 * 5 * 0.05/5.545268253204708) = 0.35841367978030436 5) U = 1/ (2 * Rsi + Rf + 1/(Ug + Ux)) = 1 / (2 * 0.17 + 0 + 1/(0.5619604457160708 + 0.35841367978030436)) = 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) # Define constants lambda_g = 1.5 # thermal conductivity of soil in W/m·K Rsi = 0.17 # in m²K/W Rse = 0.04 # in m²K/W lambda_ins = 0.035 # thermal conductivity of floor insulation in W/m·K wall_thickness = [x[age_band] for x in default_wall_thickness if x["type"] == wall_type][0] if wall_thickness is None and wall_type == "park home": # We don't know enough and likely won't make recommendations return 0 wall_thickness = wall_thickness / 1000 if insulation_thickness is None: insulation_lookup = s11[s11["Age_band"].str.contains(age_band) & s11["Floor_construction"] == floor_type] if insulation_lookup.empty: insulation_thickness = 0 else: insulation_thickness = insulation_lookup["England_Wales"].values[0] # Calculate Rf for insulated floors Rf = 0.001 * insulation_thickness / lambda_ins # Calculate B B = 2 * area / perimeter if floor_type == 'solid': # Calculate dt dt = wall_thickness + lambda_g * (Rsi + Rf + Rse) # Calculate U value based on dt and B if dt < B: U = 2 * lambda_g * math.log(math.pi * B / dt + 1) / (math.pi * B + dt) else: U = lambda_g / (0.457 * B + dt) elif floor_type == 'suspended': # Define additional constants for suspended floors h = 0.3 # height above external ground level in meters v = 5 # average wind speed at 10 m height in m/s fw = 0.05 # wind shielding factor epsilon = 0.003 # ventilation openings per m exposed perimeter in m²/m Uw = 1.5 # U-value of walls to underfloor space in W/m²K # Calculate dg dg = wall_thickness + lambda_g * (Rsi + Rse) # Calculate Ug and Ux Ug = 2 * lambda_g * math.log(math.pi * B / dg + 1) / (math.pi * B + dg) Ux = (2 * h * Uw / B) + (1450 * epsilon * v * fw / B) # Calculate final U value for suspended floors if insulation_thickness > 0: Rf += 0.2 # adding thermal resistance of floor deck else: Rf = 0.2 # thermal resistance of uninsulated floor deck U = 1 / (2 * Rsi + Rf + 1 / (Ug + Ux)) else: raise ValueError("Invalid floor type. Acceptable values are 'solid' or 'suspended'.") return round(U, 2) # rounding U value to two decimal places def extract_insulation_thickness(insulation_thickness_str): """ Converts insulation thickness to a float :param insulation_thickness_str: :return: """ if insulation_thickness_str in ["none", "average", "below average", "above average", None]: return None if isinstance(insulation_thickness_str, (float, int)): return insulation_thickness_str return int(insulation_thickness_str.replace("mm", "")) def get_wall_type( is_cavity_wall, is_solid_brick, is_granite_or_whinstone, is_sandstone_or_limestone, is_timber_frame, is_cob, is_system_built, is_park_home, **kwargs ): """ Converts booleans to a string wall type, for querying the wall thickness table :return: """ if is_cavity_wall: return "cavity" if is_solid_brick: return "solid brick" if is_granite_or_whinstone or is_sandstone_or_limestone: return "stone" if is_timber_frame: return "timber frame" if is_cob: return "cob" if is_system_built: return "system build" if is_park_home: return "park home" return None def estimate_external_wall_area(num_floors, floor_height, perimeter, built_form): """ This method estimates the external wall area based on fundamental assumptions about the home :param num_floors: Number of floors in the building. :param floor_height: Height of one floor in meters. :param perimeter: Total perimeter of the building on one floor in meters. :param built_form: The built form of the property. This is used to determine the number of exposed walls. :return: """ wall_area_one_floor = perimeter * floor_height total_wall_area = wall_area_one_floor * num_floors number_exposed_walls = { 'End-Terrace': 3, 'Mid-Terrace': 2, 'Semi-Detached': 3, 'Detached': 4, } exposed_wall_area = total_wall_area * (number_exposed_walls.get(built_form, 3) / 4) return exposed_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: """ if thermal_conductivity_w_mK is None: return None 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, is_flat): """ Roof insulation thickness could be a string like "None", "300mm+" or a numeric string. This function will convert these strings to a number for easy usage 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 string_thickness is None: return 0 if is_pitched: lookup = { "none": 0, "below average": 50, "average": 100, "above average": 270 } elif is_flat: # For a flat roof, if it's below average, we assume it's 0 and requires a re-roof lookup = { "none": 0, "below average": 0, "average": 100, "above average": 150 } else: lookup = { "none": 0, "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 def estimate_windows( property_type, built_form, construction_age_band, floor_area, number_habitable_rooms, extension_count ): # Base window count based on habitable rooms window_count = number_habitable_rooms # Additional windows for non-habitable rooms (e.g., kitchen, bathroom) # Assuming most houses will have at least one kitchen and one bathroom # Scale non-habitable windows with the number of habitable rooms non_habitable_base = 2 # Base for kitchen and bathroom extra_non_habitable = max(0, (number_habitable_rooms - 3) // 2) # Extra for large houses window_count += non_habitable_base + extra_non_habitable # Adjustments based on built form and property type if property_type in ["House", "Bungalow"] and built_form in ["Semi-Detached", "Detached"]: built_form_lookup = { "Semi-Detached": 3, "Detached": 4, } else: # For Flats and Maisonettes, adjustments might be less built_form_lookup = { "Mid-Terrace": 0, "End-Terrace": 1, "Semi-Detached": 1, "Detached": 2, } window_count += built_form_lookup.get(built_form, 0) # Adjust for floor area (larger floor area might indicate more rooms/windows) if floor_area < 85: # Small to medium properties # Standard window count likely sufficient pass elif 85 <= floor_area <= 120: # Medium to large properties # More rooms or larger rooms likely, potentially more windows window_count += 1 elif floor_area > 120: # Very large properties # Likely to have significantly more or larger rooms window_count += 2 # Adjust for construction age band if construction_age_band in ["England and Wales: before 1900", "England and Wales: 1900-1929"]: # Older houses with smaller, more numerous windows window_count += 1 # Adjust for extensions (each extension might add windows) window_count += extension_count # Adjustments for specific property types if property_type in ["Flat", "Maisontte"]: # Flats might have fewer windows due to shared walls # Maisonettes might follow a similar pattern to flats or small houses window_count -= 1 # Ensure window count is not negative if window_count < 0: raise ValueError("Window count cannot be negative.") return window_count