diff --git a/backend/Property.py b/backend/Property.py index fa607cfd..c320fdd8 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -84,6 +84,7 @@ class Property: uprn=None, # Pass as an optional input property_valuation=None, already_installed=None, + find_my_epc_components=None, non_invasive_recommendations=None, measures=None, energy_assessment=None, @@ -114,6 +115,7 @@ class Property: non_invasive_recommendations['recommendations'] if non_invasive_recommendations else [] ) + self.find_my_epc_components = find_my_epc_components # Store the find my epc components # This is a list of measures that have been recommended for the property if isinstance(measures, list): self.measures = measures diff --git a/backend/engine/engine.py b/backend/engine/engine.py index a9156078..67353b7a 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -796,9 +796,9 @@ async def model_engine(body: PlanTriggerRequest): property_non_invasive_recommendations, patch = req_data.non_invasive_recommendations, req_data.patch # if we have a remote assment data type, we pull the additional data and include it - epc_page_source = {} + epc_page_source, find_my_epc_components = {}, [] if (body.event_type == "remote_assessment") and not (epc_searcher.newest_epc.get("estimated")): - property_non_invasive_recommendations, patch, epc_page_source = ( + property_non_invasive_recommendations, patch, epc_page_source, find_my_epc_components = ( RetrieveFindMyEpc.get_from_epc_with_fallback( epc=epc_searcher.newest_epc, epc_page=epc_page, @@ -834,6 +834,7 @@ async def model_engine(body: PlanTriggerRequest): postcode=epc_searcher.postcode_clean, epc_record=prepared_epc, already_installed=property_already_installed + eco_packages.get(property_id)[3], + find_my_epc_components=find_my_epc_components, property_valuation=req_data.valuation, non_invasive_recommendations=property_non_invasive_recommendations, energy_assessment=energy_assessment, diff --git a/etl/find_my_epc/RetrieveFindMyEpc.py b/etl/find_my_epc/RetrieveFindMyEpc.py index 82215443..8bdc45c5 100644 --- a/etl/find_my_epc/RetrieveFindMyEpc.py +++ b/etl/find_my_epc/RetrieveFindMyEpc.py @@ -36,6 +36,9 @@ class RetrieveFindMyEpc: self.rrn = rrn self.address_cleaned = self.address.replace(",", "").replace(" ", "").lower() + + # Containers for the extracted components + self.property_components = [] self.walls = [] self.address_postal_town = address_postal_town @@ -256,12 +259,10 @@ class RetrieveFindMyEpc: property_features_table = soup.find("tbody", class_="govuk-table__body") property_features_table = property_features_table.find_all("tr") - # Extract wall types - self.walls = [] - for row in property_features_table: - cells = row.find_all("td") - if row.find("th").text.strip() == "Wall": - self.walls.append(cells[0].text.strip()) + self.extract_property_components(property_features_table) + + # Extract walls + self.walls = [x["description"] for x in self.property_components if x["component_name"] == "Wall"] # Finally, we format the recommendations recommendations = self.format_recommendations(recommendations, assessment_data, sap_2012_date) @@ -424,6 +425,37 @@ class RetrieveFindMyEpc: return chosen_epc, epc_certificate + @staticmethod + def extract_property_components(property_features_table: list): + """ + Function to pull out a table for property components, marking their appearance index + :param property_features_table: The table of property features, as extracted by BeautifulSoup + :return: List of property components with appearance index + """ + property_components = [] + for row in property_features_table: + cells = row.find_all("td") + component_name = row.find("th").text.strip() + property_components.append( + { + "component_name": component_name, + "description": cells[0].text.strip(), + "efficiency": cells[1].text.strip(), + } + ) + # Add an appearance index, which will indicate if the component appears multiple times, so this + # becomes a reference for the building part the component is associated to (main, extensions, etc) + # We want to inject this appearance index into the component dictionaries + component_count = {} + for component in property_components: + name = component['component_name'] + if name not in component_count: + component_count[name] = 0 + component['appearance_index'] = component_count[name] + component_count[name] += 1 + + return property_components + def retrieve_newest_find_my_epc_data( self, sap_2012_date=None, return_page=False, epc_page_source=None, rrn=None ): @@ -577,12 +609,10 @@ class RetrieveFindMyEpc: property_features_table = address_res.find("tbody", class_="govuk-table__body") property_features_table = property_features_table.find_all("tr") - # Extract wall types - self.walls = [] - for row in property_features_table: - cells = row.find_all("td") - if row.find("th").text.strip() == "Wall": - self.walls.append(cells[0].text.strip()) + property_components = self.extract_property_components(property_features_table) + + # Extract walls + self.walls = [x["description"] for x in self.property_components if x["component_name"] == "Wall"] # Finally, we format the recommendations recommendations = self.format_recommendations(recommendations, assessment_data, sap_2012_date) @@ -615,6 +645,7 @@ class RetrieveFindMyEpc: "heating_text": heating_text, "hot_water_text": hot_water_text, "recommendations": recommendations, + "property_components": property_components, "epc_data": epc_data, **assessment_data, **low_carbon_energy_sources, @@ -804,7 +835,9 @@ class RetrieveFindMyEpc: "page_source": find_epc_data.get("page_source") } - return non_invasive_recommendations, patch, page_source + property_components = find_epc_data.get("property_components", []) + + return non_invasive_recommendations, patch, page_source, property_components @classmethod def get_from_epc_with_fallback( diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index d7eb5fbb..bcaea687 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -11,6 +11,7 @@ from recommendations.recommendation_utils import ( ) 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: @@ -122,7 +123,7 @@ class RoofRecommendations: @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 + primary_roof_is_sloped: bool, insulation_thickness: str ) -> bool: """ @@ -133,6 +134,7 @@ class RoofRecommendations: recommendation :param primary_roof_is_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 :return: """ # We need to check: @@ -141,11 +143,27 @@ class RoofRecommendations: # 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 + # 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_is_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 (is_pitched and not is_loft and not is_assumed and has_sloping_ceiling_recommendation and - primary_roof_is_sloped): + if has_suitable_features and needs_recommendation: return True return False @@ -157,17 +175,18 @@ class RoofRecommendations: 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 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 :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 @@ -183,6 +202,15 @@ class RoofRecommendations: 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 @@ -283,6 +311,146 @@ class RoofRecommendations: 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[1]["needs_loft_insulation"] + secondary_needs_loft = any( + p['needs_loft_insulation'] for idx, p in component_needs.items() if idx != 1 + ) + + if primary_needs_loft and not secondary_needs_loft: + # Only option is loft + return "loft" + + primary_needs_sloping = component_needs[1]["needs_sloping_ceiling"] + secondary_needs_sloping = any( + p['needs_sloping_ceiling'] for idx, p in component_needs.items() if idx != 1 + ) + + 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 + 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 False, True + + if "sloping_ceiling_insulation" in roof_non_invasive_recommendations: + return True, False + + return True, False # Indicates that the property needs sloping ceiling as we only run this in that case + + extracted_roof_descriptions = { + idx: { + "description": component["description"], + **RoofAttributes(component["description"]).process() + } for idx, component in enumerate(find_my_epc_components) if component["component_name"] == "Roof" + } + + 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"] + + 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=True, + insulation_thickness=insulation_thickness + ) + # 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 True, False # Set sloping ceiling to true, loft to false + + return False, True # 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 @@ -322,17 +490,13 @@ class RoofRecommendations: non_invasive_recommendations = self.property.non_invasive_recommendations - # 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 - 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 @@ -343,24 +507,32 @@ class RoofRecommendations: primary_roof_is_sloped = self._is_primary_roof_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 "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, + 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 + primary_roof_is_sloped=primary_roof_is_sloped, + insulation_thickness=insulation_thickness ) 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, has_loft_insulation_recommendation=has_loft_insulation_recommendation, - is_assumed=is_assumed, has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation + 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, + is_flat=is_flat, + measures=measures, has_flat_roof_recommendation=has_flat_roof_recommendation, primary_roof_is_sloped=primary_roof_is_sloped ) @@ -371,6 +543,17 @@ class RoofRecommendations: has_room_roof_recommendation=has_room_roof_recommendation ) + # We handle possible multi roof types + if needs_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 + ) + ################################################################ # ~~~~~ Loft Insulation Recommendation Logic ~~~~~ ################################################################ diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py index e797892d..48e96af7 100644 --- a/recommendations/tests/test_roof_recommendations.py +++ b/recommendations/tests/test_roof_recommendations.py @@ -1,6 +1,7 @@ import pytest from unittest.mock import Mock from backend.Property import Property +from etl.customers.immo.pilot.asset_list import non_invasive_recommendations from etl.epc.Record import EPCRecord from recommendations.RoofRecommendations import RoofRecommendations from recommendations.tests.test_data.materials import materials @@ -407,7 +408,7 @@ class TestRoofRecommendations: # ~~~~~~~~~~~~ Sloping Ceiling Insulation ~~~~~~~~~~~~ @pytest.mark.parametrize( - "roof, has_sloping_ceiling_recommendation, primary_roof_is_sloped, expected_result", + "roof, has_sloping_ceiling_recommendation, primary_roof_is_sloped, insulation_thickness, expected_result", [ ( { @@ -427,6 +428,7 @@ class TestRoofRecommendations: }, True, True, + "none", True, ), ( @@ -439,19 +441,22 @@ class TestRoofRecommendations: }, False, False, + "average", False ) ] ) def test_is_sloping_ceiling_appropriate( - self, roof, has_sloping_ceiling_recommendation, primary_roof_is_sloped, expected_result + self, roof, has_sloping_ceiling_recommendation, primary_roof_is_sloped, + insulation_thickness, expected_result ): assert RoofRecommendations.is_sloping_ceiling_appropriate( is_pitched=roof["is_pitched"], is_loft=roof["is_loft"], is_assumed=roof["is_assumed"], has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation, - primary_roof_is_sloped=primary_roof_is_sloped + primary_roof_is_sloped=primary_roof_is_sloped, + insulation_thickness=insulation_thickness ) == expected_result def test_sloping_ceiling_pitched_no_insulation(self): @@ -465,19 +470,42 @@ class TestRoofRecommendations: 'insulation_thickness': 'none' }, roof_area=64.085, - data={"county": None, "local-authority-label": "Manchester"} + data={"county": None, "local-authority-label": "Manchester"}, + age_band="D", + already_installed=[], + non_invasive_recommendations=[ + {'type': 'flat_roof_insulation', 'sap_points': 9, 'survey': True}, + {'type': 'sloping_ceiling_insulation', 'sap_points': 9, 'survey': True}, + {'type': 'cavity_wall_insulation', 'sap_points': 6, 'survey': True}, + {'type': 'suspended_floor_insulation', 'sap_points': 2, 'survey': True}, + {'type': 'roomstat_programmer_trvs', 'sap_points': 3, 'survey': True}, + {'type': 'time_temperature_zone_control', 'sap_points': 3, 'survey': True}, + {'type': 'solar_pv', 'sap_points': 5, 'survey': True, 'suitable': True} + ], + find_my_epc_components=[ + {'component_name': 'Wall', 'description': 'Solid brick, as built, no insulation (assumed)', + 'efficiency': 'Very poor', 'appearance_index': 0}, + {'component_name': 'Roof', 'description': 'Pitched, no insulation', 'efficiency': 'Very poor', + 'appearance_index': 0}, + {'component_name': 'Roof', 'description': 'Pitched, limited insulation', 'efficiency': 'Very poor', + 'appearance_index': 1}, + {'component_name': 'Window', 'description': 'Some multiple glazing', 'efficiency': 'Very poor', + 'appearance_index': 0}, + {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas', + 'efficiency': 'Good', 'appearance_index': 0}, + {'component_name': 'Main heating control', 'description': 'Programmer, room thermostat and TRVs', + 'efficiency': 'Good', 'appearance_index': 0}, + {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good', + 'appearance_index': 0}, + {'component_name': 'Lighting', 'description': 'Low energy lighting in 28% of fixed outlets', + 'efficiency': 'Average', 'appearance_index': 0}, + {'component_name': 'Floor', 'description': 'Solid, no insulation (assumed)', 'efficiency': 'N/A', + 'appearance_index': 0}, + {'component_name': 'Secondary heating', 'description': 'None', 'efficiency': 'N/A', + 'appearance_index': 0} + ] + ) - property_instance.age_band = "D" - property_instance.already_installed = [] - property_instance.non_invasive_recommendations = [ - {'type': 'flat_roof_insulation', 'sap_points': 9, 'survey': True}, - {'type': 'sloping_ceiling_insulation', 'sap_points': 9, 'survey': True}, - {'type': 'cavity_wall_insulation', 'sap_points': 6, 'survey': True}, - {'type': 'suspended_floor_insulation', 'sap_points': 2, 'survey': True}, - {'type': 'roomstat_programmer_trvs', 'sap_points': 3, 'survey': True}, - {'type': 'time_temperature_zone_control', 'sap_points': 3, 'survey': True}, - {'type': 'solar_pv', 'sap_points': 5, 'survey': True, 'suitable': True} - ] roof_recommender = RoofRecommendations(property_instance=property_instance, materials=[]) assert not roof_recommender.recommendations @@ -500,3 +528,186 @@ class TestRoofRecommendations: assert roof_recommender.recommendations[0]["description_simulation"] == { 'roof-description': 'Pitched, insulated', 'roof-energy-eff': 'Average' } + + def test_ambiguous_sloping_ceiling_or_loft(self): + # In this case, we actually expect loft insulation to be recommended + property_instance = Mock( + id=0, + roof={ + # Roof looks like it could be a sloping ceiling but it's actually a loft + 'original_description': 'Pitched, no insulation', 'clean_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' + }, + roof_area=197.748, + data={"county": None, "local-authority-label": "Manchester"}, + already_installed=[], + find_my_epc_components=[ + {'component_name': 'Wall', 'description': 'Solid brick, as built, no insulation (assumed)', + 'efficiency': 'Very poor', 'appearance_index': 0}, + {'component_name': 'Roof', 'description': 'Pitched, no insulation', 'efficiency': 'Very poor', + 'appearance_index': 0}, + {'component_name': 'Roof', 'description': 'Pitched, limited insulation', 'efficiency': 'Very poor', + 'appearance_index': 1}, + {'component_name': 'Window', 'description': 'Some multiple glazing', 'efficiency': 'Very poor', + 'appearance_index': 0}, + {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas', + 'efficiency': 'Good', 'appearance_index': 0}, + {'component_name': 'Main heating control', 'description': 'Programmer, room thermostat and TRVs', + 'efficiency': 'Good', 'appearance_index': 0}, + {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good', + 'appearance_index': 0}, + {'component_name': 'Lighting', 'description': 'Low energy lighting in 28% of fixed outlets', + 'efficiency': 'Average', 'appearance_index': 0}, + {'component_name': 'Floor', 'description': 'Solid, no insulation (assumed)', 'efficiency': 'N/A', + 'appearance_index': 0}, + {'component_name': 'Secondary heating', 'description': 'None', 'efficiency': 'N/A', + 'appearance_index': 0} + ], + age_band="B", + non_invasive_recommendations=[ + {'type': 'loft_insulation', 'sap_points': 3, 'survey': True}, + {'type': 'flat_roof_insulation', 'sap_points': 2, 'survey': True}, + {'type': 'sloping_ceiling_insulation', 'sap_points': 2, 'survey': True}, + {'type': 'internal_wall_insulation', 'sap_points': 9, 'survey': True}, + {'type': 'draught_proofing', 'sap_points': 1, 'survey': True}, + {'type': 'low_energy_lighting', 'sap_points': 1, 'survey': True}, + {'type': 'solar_water_heating', 'sap_points': 1, 'survey': True}, + {'type': 'double_glazing', 'sap_points': 3, 'survey': True}, + {'type': 'solar_pv', 'sap_points': 4, 'survey': True, 'suitable': True} + ], + insulation_floor_area=162 + ) + + roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials) + assert not roof_recommender.recommendations + + roof_recommender.recommend(phase=0) + assert len(roof_recommender.recommendations) == 3 + + # Should all be loft insulation recommendations + assert all( + rec["type"] == "loft_insulation" for rec in roof_recommender.recommendations + ) + + def test_no_access_pitched_roof_assumed(self): + """ + In this case, the roof will have been surveyed as pitched, but the surveyor won't + have gotten access to the property to check the insulation. Therefore, we + recommend loft insulation. We assume that the roof is a locked off loft + :return: + """ + + property_instance = Mock( + id=0, + roof={ + 'original_description': 'Pitched, limited insulation (assumed)', + 'clean_description': 'Pitched, limited 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': True, + 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average' + }, + roof_area=73.24, + data={"county": None, "local-authority-label": "Manchester"}, + already_installed=[], + find_my_epc_components=[ + {'component_name': 'Wall', 'description': 'Solid brick, as built, no insulation (assumed)', + 'efficiency': 'Very poor', 'appearance_index': 0}, + {'component_name': 'Wall', 'description': 'System built, as built, no insulation (assumed)', + 'efficiency': 'Poor', 'appearance_index': 1}, + {'component_name': 'Wall', 'description': 'Cavity wall, filled cavity', 'efficiency': 'Average', + 'appearance_index': 2}, + {'component_name': 'Roof', 'description': 'Pitched, limited insulation (assumed)', + 'efficiency': 'Very poor', 'appearance_index': 0}, + {'component_name': 'Window', 'description': 'Fully double glazed', 'efficiency': 'Average', + 'appearance_index': 0}, + {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas', + 'efficiency': 'Good', 'appearance_index': 0}, + {'component_name': 'Main heating control', 'description': 'Programmer and room thermostat', + 'efficiency': 'Average', 'appearance_index': 0}, + {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good', + 'appearance_index': 0}, + {'component_name': 'Lighting', 'description': 'Low energy lighting in 75% of fixed outlets', + 'efficiency': 'Very good', 'appearance_index': 0}, + {'component_name': 'Roof', 'description': '(another dwelling above)', 'efficiency': 'N/A', + 'appearance_index': 1}, + {'component_name': 'Floor', 'description': 'Suspended, no insulation (assumed)', 'efficiency': 'N/A', + 'appearance_index': 0}, + {'component_name': 'Floor', 'description': 'Solid, no insulation (assumed)', 'efficiency': 'N/A', + 'appearance_index': 1}, + {'component_name': 'Secondary heating', 'description': 'None', 'efficiency': 'N/A', + 'appearance_index': 0} + ], + age_band="B", + non_invasive_recommendations=[ + {'type': 'internal_wall_insulation', 'sap_points': 2, 'survey': True}, + {'type': 'suspended_floor_insulation', 'sap_points': 2, 'survey': True}, + {'type': 'solid_floor_insulation', 'sap_points': 1, 'survey': True}, + {'type': 'low_energy_lighting', 'sap_points': 0, 'survey': True} + ], + insulation_floor_area=60 + ) + + roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials) + assert not roof_recommender.recommendations + + roof_recommender.recommend(phase=0) + assert len(roof_recommender.recommendations) == 3 + + # Should all be loft insulation recommendations + assert all( + rec["type"] == "loft_insulation" for rec in roof_recommender.recommendations + ) + + def test_traditional_loft_insulation(self): + property_instance = Mock( + id=0, + roof={ + 'original_description': 'Pitched, no insulation', 'clean_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' + }, + roof_area=48.82666666666667, + data={"county": None, "local-authority-label": "Manchester"}, + already_installed=[], + find_my_epc_components=[ + {'component_name': 'Wall', 'description': 'Cavity wall, filled cavity', 'efficiency': 'Good', + 'appearance_index': 0}, + {'component_name': 'Roof', 'description': 'Pitched, no insulation', 'efficiency': 'Very poor', + 'appearance_index': 0}, + {'component_name': 'Window', 'description': 'Fully double glazed', 'efficiency': 'Good', + 'appearance_index': 0}, + {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas', + 'efficiency': 'Good', 'appearance_index': 0}, + {'component_name': 'Main heating control', 'description': 'TRVs and bypass', 'efficiency': 'Average', + 'appearance_index': 0}, + {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good', + 'appearance_index': 0}, + {'component_name': 'Lighting', 'description': 'Low energy lighting in all fixed outlets', + 'efficiency': 'Very good', 'appearance_index': 0}, + {'component_name': 'Floor', 'description': 'Solid, no insulation (assumed)', 'efficiency': 'N/A', + 'appearance_index': 0}, + {'component_name': 'Secondary heating', 'description': 'Room heaters, electric', 'efficiency': 'N/A', + 'appearance_index': 0} + ], + age_band="F", + non_invasive_recommendations=[ + {'type': 'loft_insulation', 'sap_points': 9, 'survey': True}, + {'type': 'solid_floor_insulation', 'sap_points': 2, 'survey': True}, + {'type': 'solar_water_heating', 'sap_points': 1, 'survey': True}, + {'type': 'solar_pv', 'sap_points': 11, 'survey': True, 'suitable': True} + ], + insulation_floor_area=40.0 + ) + + roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials) + assert not roof_recommender.recommendations + + roof_recommender.recommend(0) + assert len(roof_recommender.recommendations) == 3 + # should all be loft insulation recommendations + assert all(rec["type"] == "loft_insulation" for rec in roof_recommender.recommendations)