mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
1081 lines
50 KiB
Python
1081 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
|
|
)
|
|
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(
|
|
self.property.roof["insulation_thickness"],
|
|
self.property.roof["is_pitched"],
|
|
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
|
|
|
|
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.data["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": non_invasive_recommendations.get("survey", False),
|
|
"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.data["roof-energy-eff"] in ["Very Poor", "Poor"]:
|
|
new_efficiency = "Average"
|
|
else:
|
|
new_efficiency = self.property.data["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": rir_non_invasive_recommendation.get("survey", None),
|
|
"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": sloping_ceiling_recommendation.get("survey", None),
|
|
"innovation_rate": 0
|
|
}
|
|
]
|