Model/recommendations/RoofRecommendations.py
2026-03-25 22:16:30 +00:00

1091 lines
50 KiB
Python

import math
import pandas as pd
from backend.Property import Property
from backend.app.plan.schemas import MEASURE_MAP
from typing import List, Mapping, Any
from datatypes.enums import QuantityUnits
from recommendations.recommendation_utils import (
get_roof_u_value, r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns,
update_lowest_selected_u_value, get_recommended_part, convert_thickness_to_numeric, override_costs,
check_simulation_difference, check_use_survey
)
from recommendations.Costs import Costs
from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes
from backend.app.plan.schemas import ROOF_INSULATION_MEASURES
class RoofRecommendations:
# part L building regulations indicate that any rennovations on an existing property's roof should
# achieve a U-value of no higher than 0.16
# This can be seen in table 4.3 in building regulations part L:
# https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1133079
# /Approved_Document_L__Conservation_of_fuel_and_power__Volume_1_Dwellings__2021_edition_incorporating_2023_amendments.pdf
BUILDING_REGULATIONS_PART_L_MAX_U_VALUE = 0.16
DIMINISHING_RETURNS_U_VALUE = 0.14
# It is recommended that lofts should have at least 270mm of insulation. If the property has more than 200mm of
# loft insulation in place already, we do not recommend anything for the moment
MINIMUM_LOFT_ISULATION_MM = 200
MINIMUM_RECOMMENDED_LOFT_INSULATION = 280
# Flat roof should have at least 100mm of insulation
MINIMUM_FLAT_ROOF_ISULATION_MM = 100
def __init__(
self,
property_instance: Property,
materials: List
):
self.property = property_instance
self.costs = Costs(self.property)
# For audit purposes, when estimating u values we'll store it
self.estimated_u_value = None
# Will contains a list of recommended measures
self.recommendations = []
self.loft_insulation_materials = [
part for part in materials if (part["type"] == "loft_insulation") and (part["is_installer_quote"])
]
# We don't have proper installer quotes for flat roof insulation
self.flat_roof_insulation_materials = [
part for part in materials if part["type"] == "flat_roof_insulation"
]
self.room_roof_insulation_materials = [
part for part in materials if part["type"] == "room_roof_insulation"
]
# Extract the insulation thickness from the roof, which is used throughout this method
self.insulation_thickness = convert_thickness_to_numeric(
string_thickness=self.property.roof["insulation_thickness"],
is_pitched=self.property.roof["is_pitched"],
is_flat=self.property.roof["is_flat"]
)
@classmethod
def get_loft_insulation_sap_limit(cls, roof_energy_eff, existing_thickness):
"""
Get the SAP limit for loft insulation
:param roof_energy_eff:
:return:
"""
if str(existing_thickness).isdigit():
if float(existing_thickness) >= 250:
return 0
if roof_energy_eff in ["Good", "Very Good"]:
return 1
return None
def is_loft_already_insulated(self, measures):
"""
Check if the loft is already insulated
"""
# If we have a non-invasive recommendation for the loft insulation, we can assume that the loft is not insulated
if "loft_insulation" in measures:
return False
return (self.insulation_thickness > self.MINIMUM_LOFT_ISULATION_MM) and self.property.roof["is_pitched"]
def is_room_roof_insulated_or_unsuitable(self, measures):
"""
Check if the room roof is already insulated
"""
# If the roof is a room roof room roof is not included in the measures, we deem the recommendation unsuitable
unsuitable = "room_roof_insulation" not in measures and self.property.roof["is_roof_room"]
if unsuitable:
return True
full_insulated_room_roof = (
self.property.roof["is_roof_room"] and
self.property.roof["insulation_thickness"] in ["average", "above_average"]
)
room_roof_insulated_at_rafters = (
self.property.roof["is_pitched"] and
self.property.roof["is_at_rafters"] and
self.property.roof["insulation_thickness"] in ["average", "above_average"]
)
has_non_invasive_recommendation = any(
x["type"] == "room_roof_insulation" for x in self.property.non_invasive_recommendations
)
return (full_insulated_room_roof or room_roof_insulated_at_rafters) and not has_non_invasive_recommendation
@staticmethod
def is_sloping_ceiling_appropriate(
is_pitched: bool,
is_loft: bool,
is_assumed: bool,
is_flat: bool,
has_sloping_ceiling_recommendation: bool,
primary_roof_looks_sloped: bool,
insulation_thickness: str,
has_loft_insulation_recommendation: bool
) -> bool:
"""
:param is_pitched: Boolean - indicates whether or not the roof is pitched
:param is_flat: Boolean - indicates whether or not the roof is flat
:param is_loft: Boolean - indicates whether or not the roof is described as a loft
:param is_assumed: Boolean - indiates if the assessment of the roof is assumed or actually confirmed
:param has_sloping_ceiling_recommendation: Boolean - indicates if the property has a sloping ceiling
recommendation
:param primary_roof_looks_sloped: Boolean - indicates if the primary room is described a sloped (as opposed to
an extension)
:param insulation_thickness: String - insulation thickness of the roof
:param has_loft_insulation_recommendation: Boolean - indicates whether or not there
:return:
"""
# We need to check:
# 1) If the property has a pitched roof
# 2) Does it have a recommendation for sloping ceiling
# 3) Is the insulation status NOT assumed
# 4) Is there a sloping ceiling recommendation (this may relate to the primary or secondary roof)
# If we have a loft primary roof and sloping ceiling
has_suitable_features = (
is_pitched and not is_loft and not is_assumed and primary_roof_looks_sloped
)
# Check if it needs a recommendation
needs_recommendation_condition1 = has_sloping_ceiling_recommendation | (
insulation_thickness in ["below average"]
)
needs_recommendation_condition2 = has_sloping_ceiling_recommendation & (
insulation_thickness in ["none"]
)
# If the insulation thickness is 'none' this isn't alone conclusive for us to determine if it's
# a sloped ceiling
needs_recommendation = needs_recommendation_condition1 | needs_recommendation_condition2
# The property is pitched, not a loft, not assumed and has a sloping ceiling rec
if has_suitable_features and needs_recommendation:
return True
# In this case, we have an assumed pitched roof with average or below average insulation
# but a sloping ceiling insulation without loft
if has_sloping_ceiling_recommendation and not has_loft_insulation_recommendation and not is_flat:
return True
return False
@staticmethod
def is_loft_insulation_appropriate(
measures: List,
is_pitched: bool,
is_at_rafters: bool,
rir_over_loft: bool,
is_assumed: bool,
insulation_thickness: str,
has_loft_insulation_recommendation: bool,
has_sloping_ceiling_recommendation: bool
) -> bool:
"""
Determine if loft insulation is appropriate
:param measures: List - list of measures
:param is_pitched: Boolean - indicates whether or not the roof is pitched
:param is_at_rafters: Boolean - indicates whether or not the loft insulation is at rafters
:param rir_over_loft: Boolean - indicates whether or not there we should be doing RIR insulation
:param is_assumed: Boolean - indicates whether or not the roof insulation status is assumed
:param insulation_thickness: String - insulation thickness of the roof
:param has_loft_insulation_recommendation: Boolean - indicates whether or not there
is a loft insulation non-invasive recommendation
:param has_sloping_ceiling_recommendation: Boolean - indicates whether or not there
is a sloping ceiling non-invasive recommendation
:return:
"""
has_li_in_measures = "loft_insulation" in measures
# Key business logic:
# If we have a pitched roof, no insulation, it's not assumed and we have a sloping ceiling recommendation,
# we do NOT recommend loft insulation
if is_pitched and not is_assumed and has_sloping_ceiling_recommendation:
return False
# We check the insulation thickness. If it's one of the "average", "below average", "none" values,
if (
is_assumed and is_pitched and insulation_thickness in ["average", "below average", "above average"]
and not has_sloping_ceiling_recommendation and not has_loft_insulation_recommendation
):
# This is a pitched roof, without access to the loft, with unknown insulation status
return True
return has_loft_insulation_recommendation or (
is_pitched and has_li_in_measures and not is_at_rafters
) and not rir_over_loft
@staticmethod
def is_flat_roof_insulation_appropriate(
is_flat: bool, measures: List, has_flat_roof_recommendation: bool, primary_roof_looks_sloped: bool
) -> bool:
"""
Determine if flat roof insulation is appropriate
:param is_flat: Boolean - indicates whether or not the roof is flat
:param measures: List - list of measures
:param has_flat_roof_recommendation: Boolean - indicates whether or not there is a flat roof non-invasive
recommendation
:param primary_roof_looks_sloped: Boolean - indicates if the primary roof looks like a sloped roof
:return: Boolean
When checking if has_flat_roof_recommendation and primary_roof_looks_sloped, we need to check both
conditions. This is because within a default EPC recommendation, the EPC will pair these recommendations
together. Therefore, weneed to ensure the primary roof isn't sloped
"""
flat_roof_in_measures = "flat_roof_insulation" in measures
return (is_flat and flat_roof_in_measures) or (has_flat_roof_recommendation and not primary_roof_looks_sloped)
@staticmethod
def is_room_roof_insulation_appropriate(
is_room_roof, measures, rir_over_loft, has_room_roof_recommendation
):
"""
Determine if room roof insulation is appropriate
:param is_room_roof: Boolean - indicates whether or not the roof is a room roof
:param measures: List - list of measures
:param rir_over_loft: Boolean - indicates whether or not there we should be doing RIR insulation
:param has_room_roof_recommendation: Boolean - indicates whether or not there is a room roof non-invasive
recommendation
:return:
"""
return is_room_roof and ("room_roof_insulation" in measures) or (
has_room_roof_recommendation or rir_over_loft
)
def _does_roof_need_recommendation(self, measures: List | None = None, u_value: float | None = None):
"""
Utility function to recommend which contains the logic to determine whether the roof needs a recommendation
:return:
"""
# If there is a property above, nothing can be done
if self.property.roof["has_dwelling_above"]:
return False
# If we have a flat roof but not flat roof insulation recommendation
if self.property.roof["is_flat"] and "flat_roof_insulation" not in measures:
return False
# Logic to check if we have an already insulated loft
if self.is_loft_already_insulated(measures):
return False
# Logic to check if we have an insulated flat roof
if (self.insulation_thickness >= self.MINIMUM_FLAT_ROOF_ISULATION_MM) and self.property.roof["is_flat"]:
return False
# Logic to check if we have an already insulated room in roof
if self.is_room_roof_insulated_or_unsuitable(measures):
return False
if self.property.roof["is_thatched"]:
return False
if (u_value is not None) and not any(
x in MEASURE_MAP["roof_insulation"] for x in [r["type"] for r in self.property.non_invasive_recommendations]
):
return False
if self.property.roof["original_description"] is None:
# There is no description so we cannot make an assessment
return False
return True
@staticmethod
def _does_primary_roof_look_sloped(
is_pitched: bool, is_loft: bool, is_assumed: bool
):
"""
Determine if the primary roof is sloped
:param is_pitched: bool - is the roof pitched
:param is_loft: bool - is the roof a loft
:param is_assumed: bool - is the roof insulation status assumed
:return:
"""
# Conditions for this to be true
# Case 1
# In the property roof description (primary roof)
# 1) Pitched Roof
# 2) Uninsulated
# 3) Not assumed
if is_pitched and not is_loft and not is_assumed:
return True
return False
@staticmethod
def _deduce_primary_roof(component_needs: dict) -> str:
"""
Helper function for deducing the primary roof type used by _handle_multi_roof_types
"""
# Can a non-primary part satisfy loft insulation?
primary_needs_loft = component_needs[0]["needs_loft_insulation"]
secondary_needs_loft = any(
p['needs_loft_insulation'] for idx, p in component_needs.items() if idx != 0
)
if primary_needs_loft and not secondary_needs_loft:
# Only option is loft
return "loft"
primary_needs_sloping = component_needs[0]["needs_sloping_ceiling"]
secondary_needs_sloping = any(
p['needs_sloping_ceiling'] for idx, p in component_needs.items() if idx != 0
)
if primary_needs_sloping and not secondary_needs_sloping:
# Only option is sloping ceiling
return "sloping_ceiling"
return "loft_insulation" # Defer to the cheaper option
def _handle_multi_roof_types(
self,
measures: List,
find_my_epc_components: List[Mapping[str, Any]],
non_invasive_recommendations: List[Mapping[str, Any]],
has_sloping_ceiling_recommendation: bool,
has_loft_insulation_recommendation: bool,
rir_over_loft: bool
) -> tuple[bool, bool]:
"""
This is a rough function to handle some edge cases, where we have two roof descriptions where
both look like they could be sloping ceilings or lofts. In this case, we need to deduce
which roof is the primary roof, and therefore whether or not we should recommend sloping ceiling insulation
:param measures: List - list of measures
:param find_my_epc_components: List - list of components from find my epc
:param non_invasive_recommendations: List - list of non-invasive recommendations
:param has_sloping_ceiling_recommendation: Boolean - indicates whether or not there is a sloping ceiling
recommendation
:param has_loft_insulation_recommendation: Boolean - indicates whether or not there is a loft insulation
recommendation
:param rir_over_loft: Boolean - indicates whether or not there we should be doing RIR insulation
:return: tuple[bool, bool] - (needs_sloping_ceiling, needs_loft_insulation)
"""
# We utilise the find my EPC data to solve cases where the primary roof and secondary roof
# being loft and sloped ceiling is ambiguous
# We need to:
# 1) Check if we have two roof types
# 2) check if both could be considered sloped
# 3) Check if we have two non-invasive recommendations for both roof types
# 4) Determine which roof is the primary roof
# We check a specific condition - which will imply loft insulation isn't appropriate but room in roof
# insulation is
# 1) We have an uninsulated loft (assumed)
# 2) We have a non-intrusive recommendation for room in roof insulation
# We only use this when we have sloping ceiling and loft insulation recommendations
# Components are indexed from 0
needs_sloping = True
needs_loft = True
roof_count = max(
x["appearance_index"] for x in find_my_epc_components if x["component_name"] == "Roof"
) + 1
roof_non_invasive_recommendations = [
x["type"] for x in non_invasive_recommendations if x['type'] in ROOF_INSULATION_MEASURES
]
has_both_recommendations = (
"loft_insulation" in roof_non_invasive_recommendations and \
"sloping_ceiling_insulation" in roof_non_invasive_recommendations
)
if (roof_count <= 1) or not has_both_recommendations:
if roof_count > 1:
if "loft_insulation" in roof_non_invasive_recommendations:
return not needs_sloping, needs_loft
if "sloping_ceiling_insulation" in roof_non_invasive_recommendations:
return needs_sloping, not needs_loft
return needs_sloping, not needs_loft # Indicates that the property needs sloping ceiling as we only run
# this in that case
roof_components = [x for x in find_my_epc_components if x["component_name"] == "Roof"]
extracted_roof_descriptions = {
idx: {
"description": component["description"],
**RoofAttributes(component["description"]).process()
} for idx, component in enumerate(roof_components)
}
component_needs = {}
for component_idx, mapped in extracted_roof_descriptions.items():
is_pitched = mapped["is_pitched"]
is_loft = mapped["is_loft"]
is_assumed = mapped["is_assumed"]
insulation_thickness = mapped["insulation_thickness"]
is_at_rafters = mapped["is_at_rafters"]
is_flat = mapped["is_flat"]
needs_sloping_ceiling = self.is_sloping_ceiling_appropriate(
is_flat=is_flat,
is_pitched=is_pitched,
is_loft=is_loft,
is_assumed=is_assumed,
has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation,
primary_roof_looks_sloped=True,
insulation_thickness=insulation_thickness,
has_loft_insulation_recommendation=has_loft_insulation_recommendation
)
# If the roof has some form of insulation already but isn't a loft, it's
# not a loft. E.g. "pitched, limited insulation" is for sloping ceiling, not loft
needs_loft_insulation = self.is_loft_insulation_appropriate(
measures=measures,
is_pitched=is_pitched,
is_at_rafters=is_at_rafters,
rir_over_loft=rir_over_loft,
insulation_thickness=insulation_thickness,
has_loft_insulation_recommendation=has_loft_insulation_recommendation,
is_assumed=is_assumed,
has_sloping_ceiling_recommendation=False
)
component_needs[component_idx] = {
"needs_sloping_ceiling": needs_sloping_ceiling,
"needs_loft_insulation": needs_loft_insulation
}
# Given the results we determine if the primary roof is sloped. The situation we may be in is
# one where the only otion is to assign one of the primary or secondary roof as a loft or sloped ceiling
# forcing our hand on whether the primary roof is sloped
primary_roof_type = self._deduce_primary_roof(component_needs)
if primary_roof_type in ["ambiguous", "sloping_ceiling"]:
return needs_sloping, not needs_loft # Set sloping ceiling to true, loft to false
return not needs_sloping, needs_loft # Set sloping ceiling to false, loft to true
def recommend(self, phase: int, measures: List | None = None, default_u_values: bool = False):
"""
Main method to recommend roof insulation measures
:param phase: Integer - phase of the recommendation, determines the order in which recommendations are
applied to the property
:param measures: List - list of measures to consider for recommendation
:param default_u_values: Boolean - whether or not to use default u-values for the recommendations
:return:
"""
measures = MEASURE_MAP["roof_insulation"] if measures is None else measures
u_value = self.property.roof["thermal_transmittance"]
property_needs_roof_recommendation = self._does_roof_need_recommendation(measures, u_value)
if not property_needs_roof_recommendation:
# Roof is either:
# - already sufficiently insulated
# - unsuitable (dwelling above, thatched, etc.)
# - not matching available measures
return
u_value = get_roof_u_value(
insulation_thickness=self.property.roof["insulation_thickness"],
has_dwelling_above=self.property.roof["has_dwelling_above"],
is_loft=self.property.roof["is_loft"],
is_roof_room=self.property.roof["is_roof_room"],
is_thatched=self.property.roof["is_thatched"],
age_band=self.property.age_band,
is_flat=self.property.roof["is_flat"],
is_pitched=self.property.roof["is_pitched"],
is_at_rafters=self.property.roof["is_at_rafters"],
)
self.estimated_u_value = u_value
# The Roof is already compliant - in this case, the u-value is beyond the requirements for
# Building Regs Part L and so we don't recommend anything
if (u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) or all(
m not in measures for m in MEASURE_MAP["roof_insulation"]
):
return
non_invasive_recommendations = self.property.non_invasive_recommendations
is_pitched = self.property.roof["is_pitched"]
is_loft = self.property.roof["is_loft"]
is_assumed = self.property.roof["is_assumed"]
is_at_rafters = self.property.roof["is_at_rafters"]
is_flat = self.property.roof["is_flat"]
is_room_roof = self.property.roof["is_roof_room"]
insulation_thickness = self.property.roof["insulation_thickness"]
has_sloping_ceiling_recommendation = any(
x["type"] == "sloping_ceiling_insulation" for x in non_invasive_recommendations
)
has_loft_insulation_recommendation = any(x["type"] == "loft_insulation" for x in non_invasive_recommendations)
has_flat_roof_recommendation = any(x["type"] == "flat_roof_insulation" for x in non_invasive_recommendations)
has_room_roof_recommendation = any(x["type"] == "room_roof_insulation" for x in non_invasive_recommendations)
primary_roof_looks_sloped = self._does_primary_roof_look_sloped(
is_pitched=is_pitched, is_loft=is_loft, is_assumed=is_assumed
)
rir_over_loft = (
is_pitched and
self.property.roof["insulation_thickness"] == "none" and
has_room_roof_recommendation
)
needs_sloping_ceiling = self.is_sloping_ceiling_appropriate(
is_pitched=is_pitched,
is_flat=is_flat,
is_loft=is_loft,
is_assumed=is_assumed,
has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation,
primary_roof_looks_sloped=primary_roof_looks_sloped,
insulation_thickness=insulation_thickness,
has_loft_insulation_recommendation=has_loft_insulation_recommendation
)
needs_loft_insulation = self.is_loft_insulation_appropriate(
measures=measures,
is_pitched=is_pitched,
is_at_rafters=is_at_rafters,
rir_over_loft=rir_over_loft,
insulation_thickness=insulation_thickness,
has_loft_insulation_recommendation=has_loft_insulation_recommendation,
is_assumed=is_assumed,
has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation
)
needs_flat_roof_insulation = self.is_flat_roof_insulation_appropriate(
is_flat=is_flat,
measures=measures,
has_flat_roof_recommendation=has_flat_roof_recommendation,
primary_roof_looks_sloped=primary_roof_looks_sloped
)
needs_rir_insulation = self.is_room_roof_insulation_appropriate(
is_room_roof=is_room_roof,
measures=measures,
rir_over_loft=rir_over_loft,
has_room_roof_recommendation=has_room_roof_recommendation
)
# We handle possible multi roof types
if needs_sloping_ceiling:
# Multi-roof override:
# In ambiguous cases (extensions, mixed descriptions), EPC component analysis
# may force us to choose between loft vs sloping ceiling.
needs_sloping_ceiling, needs_loft_insulation = self._handle_multi_roof_types(
measures=measures,
find_my_epc_components=self.property.find_my_epc_components,
non_invasive_recommendations=non_invasive_recommendations,
has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation,
has_loft_insulation_recommendation=has_loft_insulation_recommendation,
rir_over_loft=rir_over_loft
)
# Explicit override
needs_flat_roof_insulation = False
needs_rir_insulation = False
if needs_sloping_ceiling and needs_loft_insulation:
raise RuntimeError(
"Multi-roof resolution produced conflicting outcomes: "
"both sloping ceiling and loft insulation required"
)
# Retrofit precedence (least → most invasive):
# Loft > Flat roof > Room in roof > Sloping ceiling
################################################################
# ~~~~~ Loft Insulation Recommendation Logic ~~~~~
################################################################
if needs_loft_insulation:
self.recommend_roof_insulation(
u_value=u_value,
phase=phase,
is_flat=False,
is_pitched=True,
default_u_values=default_u_values
)
return
################################################################
# ~~~~~ Flat Roof Insulation Recommendation Logic ~~~~~
################################################################
if needs_flat_roof_insulation:
self.recommend_roof_insulation(
u_value=u_value,
phase=phase,
is_flat=True,
is_pitched=False,
default_u_values=default_u_values
)
return
################################################################
# ~~~~~ Room Roof Insulation Recommendation Logic ~~~~~
################################################################
# There are cases where the property might have a room roof as the second roof, but we have a recommendation for
# it, so we allow this override
if needs_rir_insulation:
self.recommend_room_roof_insulation(u_value, phase, default_u_values)
return
####################################################################################################
# ~~~~~ Sloping Ceiling Insulation Recommendation Logic ~~~~~
####################################################################################################
if needs_sloping_ceiling:
self.recommend_sloping_ceiling(
phase=phase,
u_value=u_value,
non_invasive_recommendations=non_invasive_recommendations
)
return
raise RuntimeError(
"Roof recommendation undecidable. "
f"needs_loft={needs_loft_insulation}, "
f"needs_flat={needs_flat_roof_insulation}, "
f"needs_rir={needs_rir_insulation}, "
f"needs_sloping={needs_sloping_ceiling}, "
f"roof={self.property.roof}"
)
@staticmethod
def make_roof_insulation_description(material):
if material["type"] == "loft_insulation":
return f"Install {int(material['depth'])}{material['depth_unit']} of {material['description']} in your loft"
if material["type"] == "flat_roof_insulation":
return (
f"Insulate the home's flat roof with {int(material['depth'])}{material['depth_unit']} of "
f"{material['description']}"
)
if material["type"] == "room_roof_insulation":
return (f"Insulate your room roof with {int(material['depth'])}{material['depth_unit']} of "
f"{material['description']}")
raise ValueError("Invalid material type")
def recommend_roof_insulation(
self, u_value, phase, is_pitched, is_flat, default_u_values
):
"""
This method will recommend which insulation materials to use
This function handles both the case of loft insulation and flat roof insulation
We also follow advide provided in this article on the Energy Saving Trust website, providing
high level guidance around roof insulation:
https://energysavingtrust.org.uk/advice/roof-and-loft-insulation/
The process roughly looks like the following:
- Remove the Existing Weatherproof Layer: If the roof is being replaced, remove the old weatherproof layer to
expose the timber roof surface.
- Install Insulation Boards: Lay the rigid insulation boards directly on the timber roof surface.
Ensure the boards fit tightly together to prevent thermal bridging (heat loss through the gaps).
- Add a Vapour Control Layer (VCL): This is crucial to prevent moisture from entering the insulation layer,
which can lead to dampness and rot. The VCL is placed over the insulation.
- Install a New Weatherproof Layer: On top of the insulation and VCL, install a new weatherproof layer. This
could be traditional roofing materials like bitumen-based felt, rubber membranes like EPDM, or fiberglass.
:param u_value: U-value of the roof before any retrofit measures have been installed
:param phase: Phase of the recommendation
:param is_pitched: Is the roof pitched
:param is_flat: Is the roof flat
:param default_u_values: Use default u-values
:return:
"""
# With loft insulation, 100mm goes between the joists and the rest is rolled on top
# Therefore the price is 100mm + whatever thickness is rolled on top, rolled at a 90 degree angle
# from the base layer
if is_pitched:
insulation_materials = self.loft_insulation_materials
measure_type = "loft_insulation"
elif is_flat:
insulation_materials = self.flat_roof_insulation_materials
measure_type = "flat_roof_insulation"
else:
raise ValueError("Roof is not pitched or flat")
if not insulation_materials:
raise ValueError("No roof insulation materials found")
insulation_materials = pd.DataFrame(insulation_materials)
non_invasive_recommendations = next(
(r for r in self.property.non_invasive_recommendations if
r["type"] == insulation_materials["type"].values[0]), {}
)
lowest_selected_u_value = None
recommendations = []
for _, insulation_material_group in insulation_materials.groupby("description"):
for _, material in insulation_material_group.iterrows():
# We make sure we hit a depth of 270mm. We should factor in any existing insulation if the
# loft is already partially insulated.
# Note: This requirement is only for loft insulation
if (
material["depth"] < self.MINIMUM_RECOMMENDED_LOFT_INSULATION
) and is_pitched:
continue
part_u_value = r_value_per_mm_to_u_value(material["depth"], material["r_value_per_mm"])
_, new_u_value = calculate_u_value_uplift(u_value, part_u_value)
new_u_value = math.ceil(new_u_value * 100.0) / 100.0
# If I have a lowest U value and my new u value is higher than that but lower than the
# diminishing returns threshold, it can be considered
# If I have a lowest U value and my new u value is lower than the lowest value, it's
# further into the diminishing returns threshold and can shouldn't be
if is_diminishing_returns(
recommendations, new_u_value, lowest_selected_u_value, self.DIMINISHING_RETURNS_U_VALUE
):
continue
# We allow a small tolerance for error so we don't discount the recommendation entirely
if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value)
cost_result = self.costs.loft_and_flat_insulation(
floor_area=self.property.insulation_floor_area,
material=material
)
already_installed = material["type"] in self.property.already_installed
if already_installed:
cost_result = override_costs(cost_result)
if material["type"] == "loft_insulation":
# We take the new thickness as just the thickness of the insulation, to be conservative
# and assume that any existing insulation will be replaced
new_thickness = material["depth"]
# This is based on the values we have in the training data
valid_numeric_values = [
12,
25,
50,
75,
100,
150,
200,
250,
270,
300,
350,
400,
]
proposed_depth = new_thickness
if (new_thickness not in valid_numeric_values) and material["type"] == "loft_insulation":
# Take the nearest value for scoring
proposed_depth = min(
valid_numeric_values, key=lambda x: abs(x - proposed_depth)
)
if proposed_depth >= 300:
new_efficiency = "Very Good"
else:
if self.property.epc_record.roof_energy_eff not in ["Good", "Very Good"]:
new_efficiency = "Good"
else:
new_efficiency = "Very Good"
new_description = f"Pitched, {int(proposed_depth)}mm loft insulation"
if default_u_values:
# We update the u-value with the default if we're using default u-values
new_u_value = get_roof_u_value(
insulation_thickness=str(int(new_thickness)),
has_dwelling_above=self.property.roof["has_dwelling_above"],
is_loft=self.property.roof["is_loft"],
is_roof_room=self.property.roof["is_roof_room"],
is_thatched=self.property.roof["is_thatched"],
age_band=self.property.age_band,
is_flat=self.property.roof["is_flat"],
is_pitched=self.property.roof["is_pitched"],
is_at_rafters=self.property.roof["is_at_rafters"],
)
elif material["type"] == "flat_roof_insulation":
new_description = "Flat, insulated"
new_efficiency = "Good"
if default_u_values:
new_u_value = get_roof_u_value(
insulation_thickness="100",
has_dwelling_above=self.property.roof["has_dwelling_above"],
is_loft=self.property.roof["is_loft"],
is_roof_room=self.property.roof["is_roof_room"],
is_thatched=self.property.roof["is_thatched"],
age_band=self.property.age_band,
is_flat=self.property.roof["is_flat"],
is_pitched=self.property.roof["is_pitched"],
is_at_rafters=self.property.roof["is_at_rafters"],
)
else:
raise ValueError("Invalid material type")
roof_ending_config = RoofAttributes(new_description).process()
roof_simulation_config = check_simulation_difference(
new_config=roof_ending_config, old_config=self.property.roof, prefix="roof_"
)
simulation_config = {
**roof_simulation_config,
"roof_thermal_transmittance_ending": new_u_value,
"roof_energy_eff_ending": new_efficiency
}
recommendations.append(
{
"phase": phase,
"parts": [
get_recommended_part(
part=material.to_dict(),
quantity=self.property.insulation_wall_area,
quantity_unit=QuantityUnits.m2.value,
cost_result=cost_result
)
],
"type": material["type"],
"measure_type": measure_type,
"description": self.make_roof_insulation_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": non_invasive_recommendations.get("sap_points", 0),
"already_installed": already_installed,
"simulation_config": simulation_config,
"description_simulation": {
"roof-description": new_description,
"roof-energy-eff": new_efficiency
},
**cost_result,
"survey": check_use_survey(
non_invasive_recommendations, self.property.epc_record.has_been_remodelled
),
"innovation_rate": material.to_dict()["innovation_rate"]
}
)
self.recommendations = recommendations
def recommend_room_roof_insulation(self, u_value, phase, default_u_values):
"""
This method recommends room in roof insulation for properties that have been identified
to possess a room in roof.
Because we currently have limited data about the construction of the roof, we make the following
assumptions:
1) The room in roof has a sloped roof.
We will make some basic estimations about the area of the roof given the floor area and the height of the
floors
2) Insulation of external walls is covered by the wall recommendation class
3) We assume a "Gable" roof type
Further, we recommend internal roof insulation for the room in roof
The following document contains details around best practices for insulating a room in roof
https://assets.publishing.service.gov.uk/media/61d727d18fa8f50594b59305/retrofit-room-in-roof-insulation-best
-practice.pdf
Of particular interest are the following:
We also follow advide provided in this article on the Energy Saving Trust website, providing
high level guidance around roof insulation:
https://energysavingtrust.org.uk/advice/roof-and-loft-insulation/
To insulate a warm loft, the following advice is given
"An alternative way to insulate your loft is to fit rigid insulation boards between and over the rafters.
Rafters are the sloping timbers that make up the roof itself."
To then insulate a room roof, the following recommendation is provided:
"If you want to use your loft as a living space, or it is already being used as a living space,
then you need to make sure that all the walls and ceilings between a heated room and an unheated space
are insulated.
- Sloping ceilings can be insulated in the same way as for a warm roof,
but with a layer of plasterboard on the inside of the insulation.
- Vertical walls can be insulated in the same way.
- Flat ceilings can be insulated like a standard loft.
:param u_value: Current u-value of the roof
:param phase: Phase of the recommendation
:param default_u_values: Use default u-values
:return:
"""
# We have a list of materials that can be used for room roof insulation
# We will iterate over these materials and recommend them based on the current u-value of the roof
# and the cost of the materials
rir_non_invasive_recommendation = next(
(x for x in self.property.non_invasive_recommendations if x["type"] == "room_in_roof_insulation"), {}
)
insulation_materials = pd.DataFrame(self.room_roof_insulation_materials)
# lowest_selected_u_value = None
recommendations = []
for _, material_group in insulation_materials.groupby("description"):
for material in material_group.itertuples():
part_u_value = r_value_per_mm_to_u_value(material.depth, material.r_value_per_mm)
_, new_u_value = calculate_u_value_uplift(u_value, part_u_value)
new_u_value = math.ceil(new_u_value * 100.0) / 100.0
# We allow a small tolerance for error so we don't discount the recommendation entirely
estimated_cost = (
material.total_cost * self.property.insulation_floor_area if
rir_non_invasive_recommendation.get("cost") is None else
rir_non_invasive_recommendation.get("cost")
)
# Could also be Roof room(s), ceiling insulated
new_descriptin = "Roof room(s), insulated"
roof_ending_config = RoofAttributes(new_descriptin).process()
roof_simulation_config = check_simulation_difference(
new_config=roof_ending_config, old_config=self.property.roof, prefix="roof_"
)
if self.property.epc_record.roof_energy_eff in ["Very Poor", "Poor"]:
new_efficiency = "Average"
else:
new_efficiency = self.property.epc_record.roof_energy_eff
if default_u_values:
new_u_value = get_roof_u_value(
insulation_thickness="average",
has_dwelling_above=self.property.roof["has_dwelling_above"],
is_loft=self.property.roof["is_loft"],
is_roof_room=self.property.roof["is_roof_room"],
is_thatched=self.property.roof["is_thatched"],
age_band=self.property.age_band,
is_flat=self.property.roof["is_flat"],
is_pitched=self.property.roof["is_pitched"],
is_at_rafters=self.property.roof["is_at_rafters"],
)
simulation_config = {
**roof_simulation_config,
"roof_thermal_transmittance_ending": new_u_value,
"roof_energy_eff_ending": new_efficiency
}
already_installed = "flat_roof_insulation" in self.property.already_installed
cost_result = {
"total": estimated_cost,
"labour_hours": 80,
"labour_days": 5,
}
if already_installed:
cost_result = override_costs(cost_result)
recommendations.append(
{
"phase": phase,
"parts": [],
"type": "room_roof_insulation",
"measure_type": "room_roof_insulation",
"description": "Insulate room in roof at rafters and re-decorate",
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": rir_non_invasive_recommendation.get("sap_points", None),
"simulation_config": simulation_config,
"description_simulation": {
"roof-description": new_descriptin,
"roof-energy-eff": new_efficiency
},
**cost_result,
"already_installed": already_installed,
"survey": check_use_survey(
rir_non_invasive_recommendation, self.property.epc_record.has_been_remodelled
),
"innovation_rate": material.innovation_rate
}
)
self.recommendations = recommendations
def recommend_sloping_ceiling(self, phase: int, u_value, non_invasive_recommendations: List[Mapping[str, Any]]):
"""
Sloping ceiling insulation recommendations are different from other roof types, though
the description of the roof appears to be quite similar to a roof with a loft. In order to
deduce the roof type, we apply the following logic:
1) If the roof is descrbed as pitched, insulated, without a loft insulation thickness, it's
an insulated sloped ceiling
2) If the roof insulation is assumed, it implies that the surveyor could not gain access to the
roof and therefore it's a loft
3) If it's a pitched roof that is uninsulated and is NOT assumed, and there is not loft insulation
recommendation, this implies that the surveyor was able to gain access to the roof and there was no
loft insulation recommendation so it must be a sloping ceiling since loft insulation is a default
recommendation for an uninsualted loft
Since we don't have any materials from installers for this specific recommendation, we
do not iterate through any materials. Instead, we provide a single recommendation, we estimated
prices based on desk research.
:return:
"""
sloping_ceiling_recommendation = next(
(x for x in non_invasive_recommendations if x["type"] == "sloping_ceiling_insulation"), {}
)
new_description = "Pitched, insulated"
new_efficiency = "Average" # 75mm insulation only results in average performance category
roof_ending_config = RoofAttributes(new_description).process()
roof_simulation_config = check_simulation_difference(
new_config=roof_ending_config, old_config=self.property.roof, prefix="roof_"
)
# We pull out new u-values, based on 75mm of insulation, with u-values defined from Elmhurst
new_u_value = 0.5 # This doesn't change, regardless of starting u-value
simulation_config = {
**roof_simulation_config,
"roof_thermal_transmittance_ending": new_u_value,
"roof_energy_eff_ending": new_efficiency
}
cost_result = self.costs.sloping_ceiling_insulation(
insulation_roof_area=self.property.roof_area # For a pitched roof, this is the pitched roof area
)
self.recommendations = [
{
"phase": phase,
"parts": [],
"type": "sloping_ceiling_insulation",
"measure_type": "sloping_ceiling_insulation",
"description": "Insulate sloping ceilings at the rafters and re-decorate",
"starting_u_value": u_value,
"new_u_value": None,
"sap_points": sloping_ceiling_recommendation.get("sap_points", None),
"simulation_config": simulation_config,
"description_simulation": {
"roof-description": new_description,
"roof-energy-eff": new_efficiency
},
**cost_result,
"already_installed": "sloping_ceiling_insulation" in self.property.already_installed,
"survey": check_use_survey(
sloping_ceiling_recommendation, self.property.epc_record.has_been_remodelled
),
"innovation_rate": 0
}
]