diff --git a/model_data/cleaner_app.py b/model_data/cleaner_app.py index b823081b..1ccb6238 100644 --- a/model_data/cleaner_app.py +++ b/model_data/cleaner_app.py @@ -77,113 +77,6 @@ def app(): if len(descriptions) != len(set(descriptions)): raise ValueError("Duplicated descriptions found, check me") - # Finally, we attach u-values to the descriptions for walls, roofs and floors - - df = pd.DataFrame(cleaned_data["roof-description"]) - df = df[pd.isnull(df["thermal_transmittance"])] - - 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(description_dict, age_band, s9, s10): - """ - 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. - - Parameters: - description_dict (dict): Dictionary containing the details of the roof description. - 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 description_dict['has_dwelling_above']: - return 0.0 - - # Step 1: Try to get the U-value from table S9 based on the insulation thickness - u_value = get_u_value_from_s9( - thickness=description_dict['insulation_thickness'], - s9=s9, - is_loft=description_dict['is_loft'], - is_roof_room=description_dict['is_roof_room'], - is_thatched=description_dict['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 description_dict['is_flat']: - column = 'Flat_roof' - elif description_dict['is_thatched']: - if description_dict['is_roof_room']: - column = 'Thatched_roof_room_in_roof' - else: - column = 'Thatched_roof' - elif description_dict['is_roof_room']: - column = 'Room_in_roof_slates_or_tiles' - elif description_dict['is_pitched']: - if description_dict['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 u_value - - from recommendations.rdsap_tables import age_bands - - z = pd.DataFrame(cleaned_data["roof-description"]) - z = z[pd.isnull(z["thermal_transmittance"])] - z["insulation_thickness"].value_counts() - z[z["insulation_thickness"] == "above average"] - - z.head(30).to_dict("records") - - for i, roof in enumerate(cleaned_data["roof-description"]): - if roof["thermal_transmittance"] is not None or "Average thermal transmittance" in roof["clean_description"]: - continue - - for ab in age_bands: - value = float( - get_roof_u_value( - description_dict=roof, - age_band=ab, - s9=table_s9, - s10=table_s10 - ) - ) - # We store a singular file however we could store the data under the following file path: # cleaned_epc_data/{component}/{original_description}/cleaned.bson # where component is one of the keys of cleaned_data. If we store it against the original data, this diff --git a/model_data/simulation_system/generate_rdsap_change.py b/model_data/simulation_system/generate_rdsap_change.py index 27ad9f70..5bf8cde8 100644 --- a/model_data/simulation_system/generate_rdsap_change.py +++ b/model_data/simulation_system/generate_rdsap_change.py @@ -15,6 +15,8 @@ from model_data.simulation_system.core.Settings import ( ) from model_data.simulation_system.core.DataProcessor import DataProcessor from utils.s3 import save_dataframe_to_s3_parquet, read_from_s3, read_dataframe_from_s3_parquet +from recommendations.rdsap_tables import england_wales_age_band_lookup +from recommendations.recommendation_utils import get_wall_u_value, get_roof_u_value DATA_DIRECTORY = Path(__file__).parent / "model_data" / "simulation_system" / "data" / "all-domestic-certificates" @@ -48,17 +50,11 @@ def process_and_prune_desriptions(df, cleaned_lookup): :return: """ - # TODO: In a future iteration, we can test using the binary features and the insulation thickness - # estimates, we well as estimated U-values - - # TODO: If we integrate u values, we can probably remove insulation thickness - - # TODO: Add in main fuel - cols_to_drop = { "walls": [ - 'original_description', 'clean_description', 'thermal_transmittance_unit', - 'original_description_ENDING', 'clean_description_ENDING', + # We need to cleaned descriptions for pulling out u-values + 'original_description', 'thermal_transmittance_unit', + 'original_description_ENDING', 'thermal_transmittance_unit_ENDING', 'is_cavity_wall_ENDING', 'is_filled_cavity_ENDING', 'is_solid_brick_ENDING', 'is_system_built_ENDING', @@ -112,19 +108,23 @@ def process_and_prune_desriptions(df, cleaned_lookup): component_upper = component_upper.replace("-", "_") cleaned_key = "main-fuel" if component == "main-fuel" else f"{component}-description" - left_on = ( + left_on_starting = ( f"{component_upper}_STARTING" if component == "main-fuel" else f"{component_upper}_DESCRIPTION_STARTING" ) + left_on_ending = ( + f"{component_upper}_ENDING" if component == "main-fuel" else f"{component_upper}_DESCRIPTION_ENDING" + ) + df = df.merge( pd.DataFrame(cleaned_lookup[cleaned_key]), how="left", - left_on=left_on, + left_on=left_on_starting, right_on="original_description", ).merge( pd.DataFrame(cleaned_lookup[cleaned_key]), how="left", - left_on=left_on, + left_on=left_on_ending, right_on="original_description", suffixes=("", "_ENDING") ) @@ -163,14 +163,14 @@ def process_and_prune_desriptions(df, cleaned_lookup): # Drop original cols original_cols = [ f"{component_upper}_DESCRIPTION_STARTING", f"{component_upper}_DESCRIPTION_ENDING" + ] if component != "main-fuel" else [ + f"{component_upper}_STARTING", f"{component_upper}_ENDING" ] - df = df.drop( - columns=cols_to_drop[component] + original_cols - ) + df = df.drop(columns=cols_to_drop[component] + original_cols) # If we have an insulation_thickness column, rename it - if "insulation_thickness" in cleaned_lookup[f"{component}-description"][0]: + if "insulation_thickness" in cleaned_lookup[cleaned_key][0]: df = df.rename( columns={ "insulation_thickness": f"{component}_insulation_thickness", @@ -178,7 +178,7 @@ def process_and_prune_desriptions(df, cleaned_lookup): } ) # If we have thermal transmittance, rename it - if "thermal_transmittance" in cleaned_lookup[f"{component}-description"][0]: + if "thermal_transmittance" in cleaned_lookup[cleaned_key][0]: df = df.rename( columns={ "thermal_transmittance": f"{component}_thermal_transmittance", @@ -187,7 +187,7 @@ def process_and_prune_desriptions(df, cleaned_lookup): ) # If we have tarrif, rename it - if "tariff_type" in cleaned_lookup[f"{component}-description"][0]: + if "tariff_type" in cleaned_lookup[cleaned_key][0]: df = df.rename( columns={ "tariff_type": f"{component}_tariff_type", @@ -195,10 +195,109 @@ def process_and_prune_desriptions(df, cleaned_lookup): } ) + # We need the walls descriptions so we rename them to distinguish them + if component == "walls": + df = df.rename( + columns={ + "clean_description": f"{component}_clean_description", + "clean_description_ENDING": f"{component}_clean_description_ENDING", + } + ) + # We don't need any lighting specific cleaning, we just drop the original description as we use # LOW_ENERGY_LIGHTING_STARTING, LOW_ENERGY_LIGHTING_ENDING - df = df.drop(columns=["LOW_ENERGY_LIGHTING_STARTING", "LOW_ENERGY_LIGHTING_ENDING"]) + df = df.drop(columns=["LIGHTING_DESCRIPTION_STARTING", "LIGHTING_DESCRIPTION_ENDING"]) + + return df + + +def make_uvalues(df): + df["row_index"] = df.index + + uvalues = [] + for _, x in df.iterrows(): + + uprn = x["UPRN"] + row_index = x["row_index"] + + # ~~~~~~~~~~~~~~~~~~ + # Walls + # ~~~~~~~~~~~~~~~~~~ + + starting_wall_uvalue = x["walls_thermal_transmittance"] + if pd.isnull(starting_wall_uvalue): + starting_wall_uvalue = get_wall_u_value( + clean_description=x["walls_clean_description"], + age_band=england_wales_age_band_lookup[x["CONSTRUCTION_AGE_BAND"]], + is_granite_or_whinstone=x["is_granite_or_whinstone"], + is_sandstone_or_limestone=x["is_sandstone_or_limestone"], + ) + + ending_wall_uvalue = x["walls_thermal_transmittance_ENDING"] + if pd.isnull(ending_wall_uvalue): + if x["walls_clean_description"] != x["walls_clean_description_ENDING"]: + ending_wall_uvalue = get_wall_u_value( + clean_description=x["walls_clean_description_ENDING"], + age_band=england_wales_age_band_lookup[x["CONSTRUCTION_AGE_BAND"]], + is_granite_or_whinstone=x["is_granite_or_whinstone"], + is_sandstone_or_limestone=x["is_sandstone_or_limestone"], + ) + else: + ending_wall_uvalue = starting_wall_uvalue + + # ~~~~~~~~~~~~~~~~~~ + # Roof + # ~~~~~~~~~~~~~~~~~~ + starting_roof_uvalue = x["roof_thermal_transmittance"] + if pd.isnull(starting_roof_uvalue): + starting_roof_uvalue = get_roof_u_value( + insulation_thickness=x["roof_insulation_thickness"], + has_dwelling_above=x["has_dwelling_above"], + is_loft=x["is_loft"], + is_roof_room=x["is_roof_room"], + is_thatched=x["is_thatched"], + is_flat=x["is_flat"], + is_pitched=x["is_pitched"], + is_at_rafters=x["is_at_rafters"], + age_band=england_wales_age_band_lookup[x["CONSTRUCTION_AGE_BAND"]] + ) + + ending_roof_uvalue = x["roof_thermal_transmittance_ENDING"] + + if pd.isnull(ending_roof_uvalue): + ending_roof_uvalue = get_roof_u_value( + insulation_thickness=x["roof_insulation_thickness_ENDING"], + has_dwelling_above=x["has_dwelling_above"], + is_loft=x["is_loft"], + is_roof_room=x["is_roof_room"], + is_thatched=x["is_thatched"], + is_flat=x["is_flat"], + is_pitched=x["is_pitched"], + is_at_rafters=x["is_at_rafters"], + age_band=england_wales_age_band_lookup[x["CONSTRUCTION_AGE_BAND"]] + ) + + # ~~~~~~~~~~~~~~~~~~ + # Floor + # ~~~~~~~~~~~~~~~~~~ + + uvalues.append( + { + "UPRN": uprn, + "row_index": row_index, + "starting_wall_uvalue": starting_wall_uvalue, + "ending_wall_uvalue": ending_wall_uvalue, + "starting_roof_uvalue": starting_roof_uvalue, + "ending_roof_uvalue": ending_roof_uvalue, + } + ) + + uvalues = pd.DataFrame(uvalues) + + df = df.merge( + uvalues, how="left", on=["UPRN", "row_index"] + ).drop(columns="row_index") return df @@ -332,10 +431,6 @@ def app(): pd.to_datetime(data_by_urpn_df["LODGEMENT_DATE_ENDING"]) - pd.to_datetime(EARLIEST_EPC_DATE) ).dt.days - # TODO: We need to pre-process the data. For instance, rather than using static for roofs, walls and - # floors, we may want to use the U-value. We may also want to handle the (assumed) tags - # within descriptions - # We look for key building fabric features that have changed from one EPC to the next. # if, for example, we see that a home has gone from being a cavity wall to a solid wall, we # remove this record, as it indicates that the quality of the EPC conducted in the first instance @@ -347,6 +442,25 @@ def app(): data_by_urpn_df = process_and_prune_desriptions(data_by_urpn_df, cleaned_lookup) + # Apply u-values + ######################## + # Walls + ######################## + + for col in ["walls_clean_description", "walls_clean_description_ENDING"]: + data_by_urpn_df[col] = data_by_urpn_df[col].str.replace("(assumed)", "").str.rstrip() + + data_by_urpn_df = make_uvalues(data_by_urpn_df).drop( + columns=["walls_clean_description", "walls_clean_description_ENDING"] + ) + + + + + + + get_wall_u_value(clean_description=) + if pd.isnull(data_by_urpn_df).sum().sum(): raise ValueError("Null values found in dataset after process_and_prune_desriptions") diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py index fb28fdf4..ee5a9db5 100644 --- a/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -6,7 +6,7 @@ from backend.Property import Property from recommendations.rdsap_tables import default_wall_thickness, age_band_data 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_uvalue_estimate + get_recommended_part, get_uvalue_estimate, estimate_perimeter, estimate_perimeter_2_rooms ) @@ -61,32 +61,6 @@ class FloorRecommendations(Definitions): part for part in self.materials if part["type"] == "solid_floor_insulation" ] - @staticmethod - def _estimate_perimeter(floor_area, num_rooms): - # 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 - - # Assuming the house is a square (which is a very rough approximation), compute the perimeter - perimeter = math.sqrt(total_side_length) * 4 - - return perimeter - - @staticmethod - def _estimate_perimeter_2_rooms(floor_area): - # Assuming a square layout for the entire floor area to get a first-order approximation of the perimeter - side_length = math.sqrt(floor_area) - - # Calculating the perimeter of the square layout - perimeter = 4 * side_length - - return perimeter - def _estimate_suspended_floor_u_value( self, floor_area, number_of_rooms, insulation_thickness, wall_type, region, age_band ): @@ -140,9 +114,9 @@ class FloorRecommendations(Definitions): # P is the exposed perimeter, which we estimate as we not have this data if number_of_rooms <= 2: - p = self._estimate_perimeter_2_rooms(floor_area=floor_area) + p = estimate_perimeter_2_rooms(floor_area=floor_area) else: - p = self._estimate_perimeter(floor_area=floor_area, num_rooms=number_of_rooms) + p = estimate_perimeter(floor_area=floor_area, num_rooms=number_of_rooms) b = 2 * floor_area / p u_g = 2 * defaults["lambda_g"] * math.log(math.pi * b / dg + 1) / (math.pi * b + dg) u_x = (2 * defaults["h"] * defaults["Uw"] / b) + (1450 * defaults["E"] * defaults["v"] * defaults["fw"] / b) diff --git a/recommendations/rdsap_tables.py b/recommendations/rdsap_tables.py index ee0c26bc..717c243e 100644 --- a/recommendations/rdsap_tables.py +++ b/recommendations/rdsap_tables.py @@ -92,6 +92,10 @@ age_band_data = [ }, ] +england_wales_age_band_lookup = { + f"England and Wales: %s" % x["England_Wales"]: x["age_band"] for x in age_band_data +} + ######################################################################################################################## # As defined in the rdsap documentation on page 9 # https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf @@ -336,39 +340,39 @@ s9_list = [ s10_list = [ { "Age_band": "A, B, C, D", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "2.3", - "Pitched_slates_or_tiles_insulation_at_rafters": "2.3", - "Flat_roof": "2.3", - "Room_in_roof_slates_or_tiles": "2.3", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 2.3, + "Pitched_slates_or_tiles_insulation_at_rafters": 2.3, + "Flat_roof": 2.3, + "Room_in_roof_slates_or_tiles": 2.3, "Thatched_roof": 0.35, "Thatched_roof_room_in_roof": 0.25, "Park_home": None }, { "Age_band": "E", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "1.5", - "Pitched_slates_or_tiles_insulation_at_rafters": "1.5", - "Flat_roof": "1.5", - "Room_in_roof_slates_or_tiles": "1.5", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 1.5, + "Pitched_slates_or_tiles_insulation_at_rafters": 1.5, + "Flat_roof": 1.5, + "Room_in_roof_slates_or_tiles": 1.5, "Thatched_roof": 0.35, "Thatched_roof_room_in_roof": 0.25, "Park_home": None }, { "Age_band": "F", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "0.68", - "Pitched_slates_or_tiles_insulation_at_rafters": "0.68", - "Flat_roof": "0.68", - "Room_in_roof_slates_or_tiles": "0.80", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 0.68, + "Pitched_slates_or_tiles_insulation_at_rafters": 0.68, + "Flat_roof": 0.68, + "Room_in_roof_slates_or_tiles": 0.80, "Thatched_roof": 0.35, "Thatched_roof_room_in_roof": 0.25, "Park_home": 1.7 }, { "Age_band": "G", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "0.40", - "Pitched_slates_or_tiles_insulation_at_rafters": "0.40", - "Flat_roof": "0.40", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 0.40, + "Pitched_slates_or_tiles_insulation_at_rafters": 0.40, + "Flat_roof": 0.40, "Room_in_roof_slates_or_tiles": "0.50", "Thatched_roof": 0.35, "Thatched_roof_room_in_roof": 0.25, @@ -376,27 +380,27 @@ s10_list = [ }, { "Age_band": "H", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "0.30", - "Pitched_slates_or_tiles_insulation_at_rafters": "0.35", - "Flat_roof": "0.35", - "Room_in_roof_slates_or_tiles": "0.35", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 0.30, + "Pitched_slates_or_tiles_insulation_at_rafters": 0.35, + "Flat_roof": 0.35, + "Room_in_roof_slates_or_tiles": 0.35, "Thatched_roof": 0.35, "Thatched_roof_room_in_roof": 0.25, "Park_home": None }, { "Age_band": "I", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "0.26", - "Pitched_slates_or_tiles_insulation_at_rafters": "0.35", - "Flat_roof": "0.35", - "Room_in_roof_slates_or_tiles": "0.35", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 0.26, + "Pitched_slates_or_tiles_insulation_at_rafters": 0.35, + "Flat_roof": 0.35, + "Room_in_roof_slates_or_tiles": 0.35, "Thatched_roof": 0.35, "Thatched_roof_room_in_roof": 0.25, "Park_home": 0.35 }, { "Age_band": "J", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "0.16", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 0.16, "Pitched_slates_or_tiles_insulation_at_rafters": 0.20, "Flat_roof": 0.25, "Room_in_roof_slates_or_tiles": 0.30, @@ -406,17 +410,17 @@ s10_list = [ }, { "Age_band": "K", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "0.16", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 0.16, "Pitched_slates_or_tiles_insulation_at_rafters": 0.20, - "Flat_roof": "0.25", - "Room_in_roof_slates_or_tiles": "0.25", - "Thatched_roof": "0.25", - "Thatched_roof_room_in_roof": "0.25", + "Flat_roof": 0.25, + "Room_in_roof_slates_or_tiles": 0.25, + "Thatched_roof": 0.25, + "Thatched_roof_room_in_roof": 0.25, "Park_home": 0.30 }, { "Age_band": "L", - "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": "0.16", + "Pitched_slates_or_tiles_insulation_between_joists_or_unknown": 0.16, "Pitched_slates_or_tiles_insulation_at_rafters": 0.18, "Flat_roof": 0.18, "Room_in_roof_slates_or_tiles": 0.18, diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index e1f8eaf7..d6209e0e 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -1,7 +1,10 @@ +import math from copy import deepcopy from backend.Property import Property from statistics import mean -from recommendations.rdsap_tables import epc_wall_description_map, wall_uvalues_df, default_wall_thickness +from recommendations.rdsap_tables import ( + epc_wall_description_map, wall_uvalues_df, default_wall_thickness, table_s9 as s9, table_s10 as s10 +) def r_value_per_mm_to_u_value(depth_mm: int, r_value_per_mm: float): @@ -230,3 +233,193 @@ def get_wall_u_value(clean_description, age_band, is_granite_or_whinstone, is_sa return None 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 u_value + + +def estimate_perimeter(floor_area, num_rooms): + # 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 + + # Assuming the house is a square (which is a very rough approximation), compute the perimeter + perimeter = math.sqrt(total_side_length) * 4 + + return perimeter + + +def estimate_perimeter_2_rooms(floor_area): + # Assuming a square layout for the entire floor area to get a first-order approximation of the perimeter + side_length = math.sqrt(floor_area) + + # Calculating the perimeter of the square layout + perimeter = 4 * side_length + + return perimeter + + +import math + + +def calculate_floor_u_value(floor_type, area, perimeter, wall_thickness, insulation_thickness=None): + # 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 + + # Calculate Rf for insulated floors + if insulation_thickness is not None: + Rf = 0.001 * insulation_thickness / lambda_ins + else: + Rf = 0 + + # 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 is not None: + 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 diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py index 83a35587..f6d4905a 100644 --- a/recommendations/tests/test_recommendation_utils.py +++ b/recommendations/tests/test_recommendation_utils.py @@ -75,3 +75,176 @@ class TestRecommendationUtils: with pytest.raises(KeyError): recommendation_utils.get_uvalue_estimate(uvalue_estimates_missing_key, property_mock, "Decile 1") + + def test_get_roof_u_value(self): + # Test case 1: Insulation thickness is known and is_loft is True + description_dict = { + 'insulation_thickness': '50', + 'is_loft': True, + 'is_roof_room': False, + 'is_thatched': False, + 'has_dwelling_above': False, + 'is_flat': False, + 'is_pitched': True, + 'is_at_rafters': False, + } + for age_band in ["A", "B", "C", "D"]: + assert recommendation_utils.get_roof_u_value(description_dict, age_band) == 0.68 + + def test_get_roof_u_value_case_2(self): + description_dict = { + 'original_description': 'Pitched, 400+ mm insulation at joists', + 'clean_description': 'Pitched, 400+ mm insulation at joists', + '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': False, + 'has_dwelling_above': False, + 'is_valid': True, + 'insulation_thickness': '400+' + } + age_band = "J" + + u_value = recommendation_utils.get_roof_u_value(description_dict, age_band) + assert u_value == 0.16, f"Expected 0.16, but got {u_value}" + + def test_get_roof_u_value_case_3(self): + description_dict = { + 'original_description': 'Room-in-roof, 200 mm insulation at rafters', + 'clean_description': 'Room-in-roof, 200 mm insulation at rafters', + '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': True, + 'is_assumed': False, + 'has_dwelling_above': False, + 'is_valid': True, + 'insulation_thickness': '200' + } + age_band = "J" + + u_value = recommendation_utils.get_roof_u_value(description_dict, age_band) + assert u_value == 0.21, f"Expected 0.21, but got {u_value}" + + def test_get_roof_u_value_case_4(self): + description_dict = { + 'original_description': 'Pitched, below average insulation', + 'clean_description': 'Pitched, below average 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': False, + 'has_dwelling_above': False, + 'is_valid': True, + 'insulation_thickness': 'below average' + } + age_band = "E" + + u_value = recommendation_utils.get_roof_u_value(description_dict, age_band) + assert u_value == 1.5, f"Expected 1.5, but got {u_value}" + + def test_get_roof_u_value_case_5(): + # Test case where insulation thickness is exactly specified + description_dict = { + 'original_description': 'Pitched, 100mm insulation', + 'clean_description': 'Pitched, 100mm 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': False, + 'has_dwelling_above': False, + 'is_valid': True, + 'insulation_thickness': '100' + } + age_band = "G" + + u_value = get_roof_u_value(description_dict, age_band, table_s9, table_s10) + assert u_value == 0.40, f"Expected 0.40, but got {u_value}" + + def test_get_roof_u_value_case_6(): + # Test case for a thatched roof + description_dict = { + 'original_description': 'Thatched, 75mm insulation', + 'clean_description': 'Thatched, 75mm insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': False, + 'is_roof_room': False, + 'is_loft': False, + 'is_flat': False, + 'is_thatched': True, + 'is_at_rafters': False, + 'is_assumed': False, + 'has_dwelling_above': False, + 'is_valid': True, + 'insulation_thickness': '75' + } + age_band = "H" + + u_value = get_roof_u_value(description_dict, age_band, table_s9, table_s10) + assert u_value == 0.22, f"Expected 0.22, but got {u_value}" + + def test_get_roof_u_value_case_7(): + # Test case where the roof has a room in it + description_dict = { + 'original_description': 'Pitched, room-in-roof, 100mm insulation', + 'clean_description': 'Pitched, room-in-roof, 100mm insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, + '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': '100' + } + age_band = "J" + + u_value = get_roof_u_value(description_dict, age_band, table_s9, table_s10) + assert u_value == 0.30, f"Expected 0.30, but got {u_value}" + + def test_get_roof_u_value_case_8(): + # Test case where there is a dwelling above the roof, U-value should be 0 + description_dict = { + 'original_description': 'Pitched, 100mm insulation', + 'clean_description': 'Pitched, 100mm 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': False, + 'has_dwelling_above': True, + 'is_valid': True, + 'insulation_thickness': '100' + } + age_band = "J" + + u_value = get_roof_u_value(description_dict, age_band, table_s9, table_s10) + assert u_value == 0.0, f"Expected 0.0, but got {u_value}"