refactoring roof recommendations logic

This commit is contained in:
Khalim Conn-Kowlessar 2026-01-26 12:25:51 +00:00
parent f4f2ffb599
commit 0ad3f09902
5 changed files with 199 additions and 38 deletions

View file

@ -60,7 +60,7 @@ def app():
"""
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Hackney"
data_filename = "Domna SHF Wave 3.xlsx"
data_filename = "Domna SHF Wave 3 (3).xlsx"
sheet_name = "Domna Wave 3"
postcode_column = 'Postcode'
address1_column = "Address 1"
@ -68,11 +68,11 @@ def app():
fulladdress_column = None
address_cols_to_concat = ["Address 1"]
missing_postcodes_method = None
landlord_year_built = None
landlord_year_built = "Construction Years"
landlord_os_uprn = "UPRN"
landlord_property_type = None
landlord_built_form = None
landlord_wall_construction = None
landlord_property_type = "Type"
landlord_built_form = "Attachment"
landlord_wall_construction = "Wall type"
landlord_roof_construction = None
landlord_heating_system = None
landlord_existing_pv = None

View file

@ -665,7 +665,7 @@ class RetrieveFindMyEpc:
],
"Change heating to gas condensing boiler": ["boiler_upgrade"],
"Fan assisted storage heaters and dual immersion cylinder": ["high_heat_retention_storage_heaters"],
"Flat roof or sloping ceiling insulation": ["flat_roof_insulation"],
"Flat roof or sloping ceiling insulation": ["flat_roof_insulation", "sloping_ceiling_insulation"],
"Heating controls (room thermostat)": [
"roomstat_programmer_trvs", "time_temperature_zone_control"
],

View file

@ -160,6 +160,13 @@ class Costs:
"low_energy_lighting": 0.26,
"high_heat_retention_storage_heaters": 0.1,
"windows_glazing": 0.15,
"boiler_upgrade": 0.26,
"time_and_temperature_zone_control": 0.1,
"roomstat_programmer_trvs": 0.1,
"room_roof_insulation": 0.26,
"heater_removal": 0.1,
"sealing_open_fireplace": 0.1,
"mechanical_ventilation": 0.26
}
# Preliminaries are a percentage of the total cost of the work and covers the cost of site-specific costs
@ -664,10 +671,12 @@ class Costs:
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
vat = total_cost - subtotal_before_vat
contingency_rate = self.CONTINGENCIES["roomstat_programmer_trvs"]
return {
"total": total_cost,
"contingency": total_cost * self.CONTINGENCY,
"contingency_rate": self.CONTINGENCY,
"contingency": total_cost * contingency_rate,
"contingency_rate": contingency_rate,
"subtotal": subtotal_before_vat,
"vat": vat,
"labour_hours": labour_hours,
@ -698,10 +707,12 @@ class Costs:
labour_days = np.ceil(labour_hours / 8)
contingency_rate = self.CONTINGENCIES["time_and_temperature_zone_control"]
return {
"total": total_cost,
"contingency": total_cost * self.CONTINGENCY,
"contingency_rate": self.CONTINGENCY,
"contingency": total_cost * contingency_rate,
"contingency_rate": contingency_rate,
"subtotal": subtotal_before_vat,
"vat": vat,
"labour_hours": labour_hours,
@ -752,10 +763,12 @@ class Costs:
subtotal_before_vat = removal_cost
total_cost = subtotal_before_vat + vat
contingency_rate = self.CONTINGENCIES["heater_removal"]
return {
"total": total_cost,
"contingency": total_cost * self.CONTINGENCY,
"contingency_rate": self.CONTINGENCY,
"contingency": total_cost * contingency_rate,
"contingency_rate": contingency_rate,
"subtotal": subtotal_before_vat,
"vat": vat,
"labour_hours": removal_labour_hours,
@ -858,10 +871,12 @@ class Costs:
subtotal_before_vat += system_change_cost_before_vat
vat += system_change_vat
contingency_rate = self.CONTINGENCIES["boiler_upgrade"]
return {
"total": total_cost,
"contingency": total_cost * self.CONTINGENCY,
"contingency_rate": self.CONTINGENCY,
"contingency": total_cost * contingency_rate,
"contingency_rate": contingency_rate,
"subtotal": subtotal_before_vat,
"vat": vat,
"labour_hours": labour_hours,

View file

@ -137,41 +137,127 @@ class RoofRecommendations:
"""
pass
def recommend(self, phase, measures=None, default_u_values=False):
@staticmethod
def is_sloping_ceiling_appropriate(
is_pitched: bool, is_loft: bool, is_assumed: bool, has_sloping_ceiling_recommendation: bool,
primary_roof_is_sloped: bool
) -> bool:
"""
:param is_pitched: Boolean - indicates whether or not the roof is pitched
: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_is_sloped: Boolean - indicates if the primary room is described a sloped (as opposed to
an extension)
: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 cei
# The property is pitched, not a loft, not assumed and has a sloping ceiling rec
if (is_pitched and not is_loft and not is_assumed and has_sloping_ceiling_recommendation and
primary_roof_is_sloped):
return True
return False
@staticmethod
def is_loft_insulation_appropriate(
non_invasive_recommendations, measures, is_pitched, is_at_rafters, rir_over_loft
) -> bool:
"""
Determine if loft insulation is appropriate
:param non_invasive_recommendations: List - list of non-invasive recommendations
: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
:return:
"""
has_li_in_measures = "loft_insulation" in measures
has_li_non_invasive_recommendation = any(
x["type"] == "loft_insulation" for x in non_invasive_recommendations
)
return has_li_non_invasive_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, non_invasive_recommendations: List
) -> 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 non_invasive_recommendations: List - list of non-invasive recommendations
:return:
"""
flat_roof_in_measures = "flat_roof_insulation" in measures
flat_roof_non_invasive_rec = has_li_non_invasive_recommendation = any(
x["type"] == "flat_roof_insulation" for x in non_invasive_recommendations
)
return (is_flat and flat_roof_in_measures) or flat_roof_non_invasive_rec
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
return False
measures = MEASURE_MAP["roof_insulation"] if measures is None else measures
u_value = self.property.roof["thermal_transmittance"]
# If we have a flat roof but we don't have flat roof as a measure, we exit
# 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
return False
# We check if the roof is already insulated and if so, we exit
# Building regulations part L recommend installing at least 270mm of insulation, however generally we
# experience diminishing returns in terms of SAP once we go beyond around 150mm of insulation
# This only holds true for pitched roofs.
# Logic to check if we have an already insulated loft
if self.is_loft_already_insulated(measures):
return
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
return False
# Logic to check if we have an already insulated room in roof
if self.is_room_roof_insulated_or_unsuitable(measures):
return
return False
if self.property.roof["is_thatched"]:
return
return False
# If we have a u-value and we don't have a non-invasive recommendation, we can't recommend anything
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]
):
# We don't have enough information to provide a recommendation
return False
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:
return
u_value = get_roof_u_value(
@ -200,17 +286,37 @@ class RoofRecommendations:
# 1) We have an uninsulated loft (assumed)
# 2) We have a non-intrusive recommendation for room in roof insulation
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"]
has_sloping_ceiling_recommendation = any(
x["type"] == "sloping_ceiling_insulation" for x in non_invasive_recommendations
)
primary_roof_is_sloped = False # TODO
rir_over_loft = (
self.property.roof["is_pitched"] and
is_pitched and
self.property.roof["insulation_thickness"] == "none" and
"room_in_roof_insulation" in [x["type"] for x in non_invasive_recommendations]
)
needs_sloping_ceiling = self.is_sloping_ceiling_appropriate(
is_pitched=is_pitched, is_loft=is_loft, is_assumed=is_assumed,
has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation,
primary_roof_is_sloped=primary_roof_is_sloped
)
needs_loft_insulation = self.is_loft_insulation_appropriate(
non_invasive_recommendations=non_invasive_recommendations, measures=measures,
is_pitched=is_pitched, is_at_rafters=is_at_rafters, rir_over_loft=rir_over_loft
)
##################################################
# ~~~~~ Loft Insulation Recommendation Logic ~~~~~
##################################################
# We firstly handle non-intrusive recommendations, which may override the normal roof insulation recommendations
if ("loft_insulation" in [x["type"] for x in non_invasive_recommendations]) or (
self.property.roof["is_pitched"] and "loft_insulation" in measures and
not self.property.roof["is_at_rafters"]
) and not rir_over_loft:
if needs_loft_insulation:
self.recommend_roof_insulation(
u_value=u_value,
insulation_thickness=self.insulation_thickness,

View file

@ -2,6 +2,7 @@ from backend.Property import Property
from recommendations.RoofRecommendations import RoofRecommendations
from recommendations.tests.test_data.materials import materials
from etl.epc.Record import EPCRecord
import pytest
class TestRoofRecommendations:
@ -402,3 +403,42 @@ class TestRoofRecommendations:
roof_recommender14.recommend(phase=0)
assert not roof_recommender14.recommendations
# ~~~~~~~~~~~~ Sloping Ceiling Insulation ~~~~~~~~~~~~
@pytest.mark.parameterize("roof",
[
(
# For this example, the roof is pitched, without insulation and the description
# isn't assumed
{'original_description': 'Pitched, no insulation', 'thermal_transmittance': None,
'thermal_transmittance_unit': None,
'is_pitched': True, 'is_roof_room': False, 'is_loft': False, 'is_flat': False,
'is_thatched': False,
'is_at_rafters': False, 'is_assumed': False, 'has_dwelling_above': False,
'is_valid': True,
'insulation_thickness': 'none'}
)
]
)
def test_sloping_ceiling_valid(self, roof):
# All conditions are met and therefore we should produce a sloping ceiling recommendation
assert RoofRecommendations.is_sloping_ceiling_appropriate(
is_pitched=True, is_loft=False, is_assumed=False, has_sloping_ceiling_recommendation=True
)
# One condition not met - we cannot verify
assert not RoofRecommendations.is_sloping_ceiling_appropriate(
is_pitched=True, is_loft=True, is_assumed=False, has_sloping_ceiling_recommendation=True
)
assert not RoofRecommendations.is_sloping_ceiling_appropriate(
is_pitched=False, is_loft=False, is_assumed=False, has_sloping_ceiling_recommendation=True,
primary_roof_is_sloped=True
)
assert not RoofRecommendations.is_sloping_ceiling_appropriate(
is_pitched=True, is_loft=False, is_assumed=True, has_sloping_ceiling_recommendation=True,
primary_roof_is_sloped=True
)
assert not RoofRecommendations.is_sloping_ceiling_appropriate(
is_pitched=True, is_loft=False, is_assumed=True, has_sloping_ceiling_recommendation=True,
primary_roof_is_sloped=True
)