import math from datetime import datetime from copy import deepcopy from typing import Union 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 extract_thickness(thickness, is_roof_room, is_at_rafters, is_loft, is_flat): if is_roof_room or is_at_rafters: # TODO: We get None instead of a string none, this should be fixed if thickness is None: thickness = "none" # We re-map the thickness thickness_map = { "below average": "50", "average": "100", "above average": "150", "none": "0", } thickness = thickness_map[thickness] if is_flat: try: thickness = int(thickness) return thickness except (TypeError, ValueError): # If thickness is not a valid number (could be a string or None), return None return None if thickness in ["below average", "average", "above average", "none", None] or ( not is_loft and not is_roof_room and not is_at_rafters ): return None elif thickness.endswith("+"): thickness = int(thickness[:-1]) return thickness else: try: thickness = int(thickness) return thickness except ValueError: # If thickness is not a valid number (could be a string or None), return None return None def get_u_value_from_s9( thickness, s9, is_loft, is_roof_room, is_thatched, is_at_rafters ): """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 and not is_at_rafters ): return None if thickness in [0, "0"] and (is_loft or is_roof_room): 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" ) if thickness in [0, "0"] and is_roof_room: return s9[pd.isnull(s9["Insulation_thickness_mm"])][column].iloc[0] else: # 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 thickness = extract_thickness( thickness=insulation_thickness, is_roof_room=is_roof_room, is_at_rafters=is_at_rafters, is_loft=is_loft, is_flat=is_flat, ) # 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=thickness, s9=s9, is_loft=is_loft, is_roof_room=is_roof_room, is_thatched=is_thatched, is_at_rafters=is_at_rafters, ) 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 if is_flat and thickness is not None: u_value = s10.loc[ (s10["Insulation_Thickness"] == thickness) | s10["Age_band"].str.contains(age_band), column, ].values.min() else: u_value = s10.loc[s10["Age_band"].str.contains(age_band), column].values[0] u_value = float(u_value) # As per the documentation here: https://bregroup.com/documents/d/bre-group/rdsap_2012_9-94-20-09-2019 # Table s.10 # "The value from the table applies for unknown and as built. If the roof is known to have more insulation than # would normally be expected for the age band, either observed or on the basis of documentary evidence, use the # lower of the value in the table and: # 50 mm insulation 0.68 # 100 mm insulation: 0.40 # 150 mm or more insulation: 0.30" if thickness is not None: if thickness == 50: u_value = min(u_value, 0.68) if thickness == 100: u_value = min(u_value, 0.40) if thickness >= 150: u_value = min(u_value, 0.30) return u_value def estimate_number_of_floors(property_type): """ Using the property type, we estimate the number of floors in the property """ if property_type is None: return None if property_type == "House": number_of_floors = 2 elif property_type in ["Flat", "Bungalow"]: number_of_floors = 1 elif property_type == "Maisonette": number_of_floors = 2 else: raise NotImplementedError("Implement me") return number_of_floors 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 ] if not wall_thickness: # In some cases, we may estimate an EPC and end up with a slightly mixed EPC, with some fields associated # to a new build and others to an existing. So we might end up with a None wall type here, because of this. # If this happens, nothing will be in the wall_thickness list so this is the fallback, the defauly thickness # for many EPC assessment systems like Elmhurst wall_thickness = 300 else: wall_thickness = wall_thickness[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, ) -> Union[str, None]: """ 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 estimate_pitched_roof_area(floor_area: float) -> float: """ This function mimics the methodology for calculating floor area in Elmhurst, so that we can simulate the outcomes in a way that is consistent with the Elmhurst methodology. :param floor_area: area of the home's floor :return: Numerical estimate of the surface area of the top of the pitched roof """ scalar = 1.0571283428862048 return scalar * (floor_area / np.cos(np.radians(30))) def estimate_windows( property_type, built_form, construction_age_band, floor_area, number_habitable_rooms ): # If there is an extension, that will boost the number of habitable rooms # 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 # 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 def calculate_cavity_age(newest_epc, older_epcs, cleaned): all_epcs = [newest_epc] + older_epcs df = [] for x in all_epcs: # Get the cleaned mapping mapped = [ y for y in cleaned["walls-description"] if y["original_description"] == x["walls-description"] ] if not mapped: continue df.append( { **mapped[0], "inspection-date": x["lodgement-date"], } ) df = pd.DataFrame(df) df = df[df["is_cavity_wall"] & df["is_filled_cavity"]] cavity_age = (datetime.now() - pd.to_datetime(df["inspection-date"].max())).days return cavity_age def check_simulation_difference( old_config, new_config, prefix="", keys_with_prefix=None ): """ Given two dictionaries, that describe the heating control configurations, this method will compare the two and pick out the differences. These differences will be things that have been added and things that have been removed. This will be used to determine how we should be updating the configuration in the simulation :return: """ keys_with_prefix = ( ["is_assumed", "thermal_transmittance", "insulation_thickness"] if keys_with_prefix is None else keys_with_prefix ) differences = {} for key in new_config: if old_config[key] != new_config[key]: new_key = ( prefix + key + "_ending" if key in keys_with_prefix else key + "_ending" ) differences[new_key] = new_config[key] return differences def override_costs(costs): """ If the method is overridden, we want to make sure that the costs are zero. This function sets the costs to zero :param costs: Dictionary of costing, as returned by the Costs class :return: """ for k in costs: costs[k] = 0 return costs def combine_recommendation_configs(recommendation_config1, recommendation_config2): """ Given two simulation configs, this function will combine them into one :param recommendation_config1: :param recommendation_config2: :return: """ # Efficiency values - keys which contain _energy_eff_ending eff_1 = { k: v for k, v in recommendation_config1.items() if ("_energy_eff_ending" in k) or ("-energy-eff" in k) } eff_2 = { k: v for k, v in recommendation_config2.items() if ("_energy_eff_ending" in k) or ("-energy-eff" in k) } # We combine the simulation configs combined = {**recommendation_config1, **recommendation_config2} # Find overlapping keys overlapping_keys = set(eff_1.keys()).intersection(set(eff_2.keys())) if overlapping_keys: # We make sure we take the best value - map efficiency values to numbers numerical_embedding = { "Very poor": 1, "Poor": 2, "Average": 3, "Good": 4, "Very good": 5, } for key in overlapping_keys: if numerical_embedding[eff_1[key]] >= numerical_embedding[eff_2[key]]: combined[key] = eff_1[key] else: combined[key] = eff_2[key] return combined