mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
460 lines
17 KiB
Python
460 lines
17 KiB
Python
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, table_s9 as s9, table_s10 as s10,
|
|
table_s11 as s11
|
|
)
|
|
|
|
|
|
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, selected_depth, selected_total_cost, quantity, quantity_unit):
|
|
"""
|
|
Utility function to return a recommended part with the selected depth.
|
|
:param part: part to be recommended
|
|
:param selected_depth: depth of the selected part
|
|
:param selected_total_cost: Total cost of the selected part
|
|
:param quantity: Quantity of the selected part
|
|
:param quantity_unit: Unit of the quantity
|
|
:return:
|
|
"""
|
|
recommended_part = deepcopy(part)
|
|
recommended_part["depths"] = [selected_depth]
|
|
recommended_part["estimated_cost"] = selected_total_cost
|
|
recommended_part["quantity"] = quantity
|
|
recommended_part["quantity_unit"] = quantity_unit
|
|
|
|
return recommended_part
|
|
|
|
|
|
def get_uvalue_estimate(uvalue_estimates, property: Property, total_floor_area_group_decile):
|
|
"""
|
|
Wrapper function which contains the methodology to extract a property's walls u-value estimate
|
|
when we don't have a true value and if we can't base our assumption off of the material
|
|
:return:
|
|
"""
|
|
|
|
if not uvalue_estimates:
|
|
raise ValueError("No U-value estimate found for the given property - investigate")
|
|
|
|
# We try and filter on total_floor_area_group_decile
|
|
floor_area_filter = [
|
|
x for x in uvalue_estimates if
|
|
x["total-floor-area_group"] == total_floor_area_group_decile
|
|
]
|
|
|
|
if not floor_area_filter:
|
|
# Take a mean of all the u-value estimates
|
|
return mean(
|
|
[x["median_thermal_transmittance"] for x in uvalue_estimates if x["median_thermal_transmittance"]]
|
|
)
|
|
|
|
# Because of how spuriously populated the data is for number-habitable-rooms and number-heated-rooms,
|
|
# we will try and filter on these to see if we get a result
|
|
|
|
habitable_rooms_filer = [
|
|
x for x in floor_area_filter if
|
|
x["number-habitable-rooms"] == property.data["number-habitable-rooms"]
|
|
]
|
|
|
|
if not habitable_rooms_filer:
|
|
# Take a mean of all the u-value estimates
|
|
return mean(
|
|
[x["median_thermal_transmittance"] for x in floor_area_filter if x["median_thermal_transmittance"]]
|
|
)
|
|
|
|
# Try perform a filter on heated rooms
|
|
heated_rooms_filter = [
|
|
x for x in habitable_rooms_filer if
|
|
x["number-heated-rooms"] == property.data["number-heated-rooms"]
|
|
]
|
|
|
|
if not heated_rooms_filter:
|
|
# Take a mean of all the u-value estimates
|
|
return mean(
|
|
[x["median_thermal_transmittance"] for x in habitable_rooms_filer if x["median_thermal_transmittance"]]
|
|
)
|
|
|
|
return mean(
|
|
[x["median_thermal_transmittance"] for x in heated_rooms_filter if x["median_thermal_transmittance"]]
|
|
)
|
|
|
|
|
|
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:
|
|
"""
|
|
|
|
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 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":
|
|
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
|
|
|
|
|
|
def calculate_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
|
|
"""
|
|
|
|
# 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] / 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
|