From 64eb2e2f204ee6f8d50dc7f4adb7646c23d4e6ff Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 26 Jan 2026 17:49:56 +0000 Subject: [PATCH] added in basic process for sloping ceiling --- backend/app/plan/schemas.py | 10 +++- recommendations/Costs.py | 68 ++++++++++++++++++++++- recommendations/RoofRecommendations.py | 76 +++++++++++++++++++++++--- 3 files changed, 143 insertions(+), 11 deletions(-) diff --git a/backend/app/plan/schemas.py b/backend/app/plan/schemas.py index edac31dc..7c352eba 100644 --- a/backend/app/plan/schemas.py +++ b/backend/app/plan/schemas.py @@ -9,7 +9,9 @@ TYPICAL_MEASURE_TYPES = [ ] WALL_INSULATION_MEASURES = ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"] -ROOF_INSULATION_MEASURES = ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"] +ROOF_INSULATION_MEASURES = [ + "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "sloping_ceiling_insulation" +] # Both all and roof insulaiton measures are eligible for ECO4. These are the remaining fabric and heating measures # This is based on th measures we have recommendations for @@ -31,7 +33,7 @@ SPECIFIC_MEASURES = ( INSULATION_MEASURES = [ "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", - "loft_insulation", "flat_roof_insulation", "room_roof_insulation", + "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "sloping_ceiling_insulation", "suspended_floor_insulation", "solid_floor_insulation", ] @@ -46,7 +48,9 @@ MEASURE_MAP = { "wall_insulation": [ "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", ], - "roof_insulation": ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"], + "roof_insulation": [ + "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "sloping_ceiling_insulation" + ], "floor_insulation": ["suspended_floor_insulation", "solid_floor_insulation"], "heating": ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump"], "windows": ["double_glazing", "secondary_glazing"], diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 3a65312e..fd429afa 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -1,4 +1,6 @@ +from typing import Mapping, Any import numpy as np + from recommendations.county_to_region import county_to_region_map from utils.logger import setup_logger from backend.ml_models.AnnualBillSavings import AnnualBillSavings @@ -166,7 +168,8 @@ class Costs: "room_roof_insulation": 0.26, "heater_removal": 0.1, "sealing_open_fireplace": 0.1, - "mechanical_ventilation": 0.26 + "mechanical_ventilation": 0.26, + "sloping_ceiling_insulation": 0.26 # Similar to IWI so using the same contingency } # Preliminaries are a percentage of the total cost of the work and covers the cost of site-specific costs @@ -935,3 +938,66 @@ class Costs: "labour_hours": 80, "labour_days": 10, } + + @staticmethod + def _estimate_number_of_days_for_sloping_ceiling(insulation_roof_area: float) -> float: + """ + Estimate labour days required to insulate an existing sloping ceiling. + + Heuristic model based on retrofit guidance (Checkatrade, The Green Age) + and analogy with internal wall insulation. + + Assumptions: + - ~30 m² of sloping ceiling takes ~4 working days + - Small jobs still require multiple days (setup, stripping, reboarding) + - Larger areas benefit from economies of scale, but not linearly + + :param insulation_roof_area: m² of sloping ceiling to be insulated + """ + + base_days = 4 + base_area = 30 # m2 reference case + labour_exponent = 0.85 + min_days = 2 + + labour_days = max( + min_days, + base_days * (insulation_roof_area / base_area) ** labour_exponent + ) + + return labour_days + + @classmethod + def sloping_ceiling_insulation(cls, insulation_roof_area: float) -> Mapping[str, Any]: + """ + This costing for this is based on Checkatrade desktop research, since we are yet to receive installer quotes. + :param insulation_roof_area: Area of the sloping ceiling to be insulated + :return: + """ + ################ + # Assumptions + ################ + # Sources: + # https://www.checkatrade.com/blog/cost-guides/vaulted-ceiling-cost/ + # https://www.thegreenage.co.uk/can-i-insulate-my-sloping-ceiling/ + # These assumptions last updated 21/02/2026 + insulation_cost_per_m2 = 52 # The actual install process is quite similar to IWI + labour_rate = 250 # per day + contingency_rate = cls.CONTINGENCIES["sloping_ceiling_insulation"] + + labour_days = cls._estimate_number_of_days_for_sloping_ceiling(insulation_roof_area) + labour_hours = labour_days * 8 + + total = (insulation_cost_per_m2 * insulation_roof_area) + (labour_rate * labour_days) + + # Assume VAT included in the total => total is 120% of subtotal + vat = total - (total / 1.2) + + return { + "total": total, + "contingency": total * contingency_rate, + "contingency_rate": contingency_rate, + "vat": vat, + "labour_hours": labour_hours, + "labour_days": labour_days, + } diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index 6625aeb0..baaaa547 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -324,10 +324,11 @@ class RoofRecommendations: ) 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"] ): - # The Roof is already compliant return non_invasive_recommendations = self.property.non_invasive_recommendations @@ -381,14 +382,12 @@ class RoofRecommendations: has_room_roof_recommendation=has_room_roof_recommendation ) - ################################################## + ################################################################ # ~~~~~ Loft Insulation Recommendation Logic ~~~~~ - ################################################## - # We firstly handle non-intrusive recommendations, which may override the normal roof insulation recommendations + ################################################################ if needs_loft_insulation: self.recommend_roof_insulation( u_value=u_value, - insulation_thickness=self.insulation_thickness, phase=phase, is_flat=False, is_pitched=True, @@ -396,10 +395,12 @@ class RoofRecommendations: ) return + ################################################################ + # ~~~~~ Flat Roof Insulation Recommendation Logic ~~~~~ + ################################################################ if needs_flat_roof_insulation: self.recommend_roof_insulation( u_value=u_value, - insulation_thickness=0, phase=phase, is_flat=True, is_pitched=False, @@ -407,12 +408,21 @@ class RoofRecommendations: ) 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() + raise NotImplementedError("Implement me") @staticmethod @@ -432,7 +442,7 @@ class RoofRecommendations: raise ValueError("Invalid material type") def recommend_roof_insulation( - self, u_value, insulation_thickness, phase, is_pitched, is_flat, default_u_values + self, u_value, phase, is_pitched, is_flat, default_u_values ): """ @@ -773,3 +783,55 @@ class RoofRecommendations: ) self.recommendations = recommendations + + def recommend_sloping_ceiling(self, phase: int, u_value, sloping_ceiling_recommendation: dict = None): + """ + Recommend insulation for a sloping ceiling + 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: + """ + + new_description = "Pitched, insulated" + new_efficiency = "Good" + + 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( + 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 + } + ]