Model/recommendations/recommendation_utils.py
2026-01-22 08:49:57 +00:00

977 lines
32 KiB
Python

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:
# Handle rare edge case
if clean_description == "":
return 0
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 _try_convert_to_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def extract_thickness(thickness, is_roof_room, is_at_rafters, is_loft, is_flat):
thickness_map = {
"below average": "50",
"average": "100",
"above average": "150",
"none": "0",
}
# Normalise none value early
if thickness is None:
thickness = "none"
if is_roof_room or is_at_rafters:
int_thickness = _try_convert_to_int(thickness)
if int_thickness is not None:
return int_thickness
# We re-map the thickness
thickness = thickness_map.get(thickness)
if thickness is None:
return None
return int(thickness)
if is_flat:
return _try_convert_to_int(thickness)
# Thicknes will never be none
if thickness in thickness_map or (
not (is_loft or is_roof_room or is_at_rafters)
):
return None
if isinstance(thickness, str) and str(thickness).endswith("+"):
return _try_convert_to_int(thickness[:-1])
# final attempt
return _try_convert_to_int(thickness)
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: None | str, age_band: str) -> float:
"""
We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document
:param insulation_thickness_str: Insulation thickness as defined in the EPC data
:param age_band: Age band of the property
: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", ""))
filtered = s12[s12["age_band"] == age_band][
f"insulation_{insulation_thickness}"
]
if filtered.empty:
# We don't have data so we use the median value
return float(s12[f"insulation_{insulation_thickness}"].median())
return float(filtered.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