diff --git a/backend/Property.py b/backend/Property.py index f5123b96..f15a0d7b 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -159,7 +159,7 @@ class Property: self.floor_height = epc_record.prepared_epc.get("floor_height") self.insulation_wall_area = None self.floor_area = epc_record.prepared_epc.get("total_floor_area") - self.pitched_roof_area = None + self.roof_area = None self.insulation_floor_area = None self.number_lighting_outlets = epc_record.prepared_epc.get( "fixed_lighting_outlets_count" @@ -604,18 +604,12 @@ class Property: def get_components( self, cleaned, - photo_supply_lookup, - floor_area_decile_thresholds, energy_consumption_client ): """ Given the cleaning that has been performed, we'll use this to identify the property components, from roof to walls to windows, heating and hot water :param cleaned: This is the dictionary of components found in cleaner.cleaned - :param photo_supply_lookup: This is the lookup table for the photo supply, used to estimate the percentage - of the roof that is suitable for solar panels - :param floor_area_decile_thresholds: This is the decile thresholds for the floor area, used in estimating the - solar pv roof area :param energy_consumption_client: Contains the heating and hot water kwh models - used to predict current energy annual consumption in kWh :return: @@ -680,20 +674,21 @@ class Property: self.set_floor_type() self.set_floor_level() self.set_windows_count() - self.set_solar_panel_area( - photo_supply_lookup=photo_supply_lookup, - floor_area_decile_thresholds=floor_area_decile_thresholds, - ) self.set_energy_source() self.find_energy_sources() self.set_current_energy_bill(energy_consumption_client) - def set_solar_panel_configuration(self, solar_panel_configuration): + def set_solar_panel_configuration( + self, solar_panel_configuration, roof_area + ): """ This funtion inserts the solar panel configuration into the property object """ self.solar_panel_configuration = solar_panel_configuration + # We also set the roof area + self.roof_area = roof_area + def set_current_energy_bill(self, energy_consumption_client): """ Given what we know about the property now, estimates the current energy consumption using the UCL paper @@ -1079,9 +1074,9 @@ class Property: if condition_data["main_dwelling_ground_floor_area"] is not None \ else self.floor_area / self.number_of_floors - self.pitched_roof_area = esimtate_pitched_roof_area( - floor_area=self.insulation_floor_area, floor_height=self.floor_height - ) + # self.pitched_roof_area = esimtate_pitched_roof_area( + # floor_area=self.insulation_floor_area, floor_height=self.floor_height + # ) def set_floor_level(self): self.floor_level = ( @@ -1195,48 +1190,6 @@ class Property: if condition_data["windows_area"] is not None \ else None - def set_solar_panel_area(self, photo_supply_lookup, floor_area_decile_thresholds): - """ - Sets the approximate area of the solar panels - :return: - """ - - if (self.insulation_floor_area is None) and (self.pitched_roof_area is None): - raise ValueError( - "Need to set insulation floor area and pitched roof area before setting solar pv roof area" - ) - - photo_supply_matched = SolarPhotoSupply.filter_photo_supply_lookup( - photo_supply_lookup=photo_supply_lookup, - floor_area_decile_thresholds=floor_area_decile_thresholds, - tenure=self.data["tenure"], - built_form=self.data["built-form"], - property_type=self.data["property-type"], - construction_age_band=self.construction_age_band, - is_flat=self.roof["is_flat"], - is_pitched=self.roof["is_pitched"], - is_roof_room=self.roof["is_roof_room"], - floor_area=self.floor_area, - ) - - percentage_of_roof = photo_supply_matched["photo_supply_median"].mean() - percentage_of_roof = percentage_of_roof / 100 - - self.solar_pv_percentage = percentage_of_roof - - def get_solar_pv_roof_area(self, percentage_of_roof): - """ - Given a percentage of the roof, this method will return the estimated area of the solar panels - :param percentage_of_roof: - :return: - """ - - return ( - self.insulation_floor_area * percentage_of_roof - if self.roof["is_flat"] - else self.pitched_roof_area * percentage_of_roof - ) - def set_energy_source(self): """ This method sets the energy source of the property, based on the mains gas flag and energy tariff. @@ -1335,6 +1288,23 @@ class Property: return suitable_property_type and not has_air_source_heat_pump + def is_solar_pv_valid(self): + + # If the property is a flat but we are looking at building solar potential, we can include this + if (self.building_id is not None) and (self.solar_panel_configuration is not None): + return True + + is_valid_property_type = self.data["property-type"] in ["House", "Bungalow", "Maisonette"] + is_valid_roof_type = ( + self.roof["is_flat"] or self.roof["is_pitched"] or self.roof["is_roof_room"] + ) + # If there is no existing solar PV, the photo-supply field will be None or a missing value + has_no_existing_solar_pv = self.data["photo-supply"] in [ + None, 0, self.DATA_ANOMALY_MATCHES + ] + + return is_valid_property_type and is_valid_roof_type and has_no_existing_solar_pv + def estimate_electrical_consumption(self, assumed_ashp_efficiency, exclusions): """ Given a property, this method estimates the electrical consumption of the property, based on the energy diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 221075f9..563134ea 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -408,7 +408,6 @@ async def trigger_plan(body: PlanTriggerRequest): uprn_filenames = read_dataframe_from_s3_parquet( bucket_name=get_settings().DATA_BUCKET, file_key="spatial/filename_meta.parquet" ) - photo_supply_lookup, floor_area_decile_thresholds = SolarPhotoSupply.load(bucket=get_settings().DATA_BUCKET) solar_api_client = GoogleSolarApi(api_key=get_settings().GOOGLE_SOLAR_API_KEY) dataset_version = "2024-07-08" @@ -425,10 +424,10 @@ async def trigger_plan(body: PlanTriggerRequest): logger.info("Getting spatial data") for p in input_properties: - p.get_components(cleaned, photo_supply_lookup, floor_area_decile_thresholds, energy_consumption_client) + p.get_components(cleaned=cleaned, energy_consumption_client=energy_consumption_client) p.get_spatial_data(uprn_filenames) - # TODO: Handle the case of modelling some units as buildings and some as properties individually + # TODO: Tidy this up building_ids = [ { "building_id": p.building_id, @@ -439,7 +438,7 @@ async def trigger_plan(body: PlanTriggerRequest): # property to achieve post retrofit of just the fabric "energy_consumption": energy_consumption_client.estimate_new_consumption( current_energy_efficiency=p.data["current-energy-efficiency"], - target_efficiency="C", + target_efficiency="69", current_consumption=p.estimate_electrical_consumption( assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions ) @@ -448,6 +447,24 @@ async def trigger_plan(body: PlanTriggerRequest): "uprn": p.uprn } for p in input_properties if p.building_id is not None ] + individual_units = [ + { + "longitude": p.spatial["longitude"], + "latitude": p.spatial["latitude"], + # Energy consumption is adjusted for the property's expected post retrofit state + # We set the target rating to EPC C, which is the typical EPC rating we would expect the + # property to achieve post retrofit of just the fabric + "energy_consumption": energy_consumption_client.estimate_new_consumption( + current_energy_efficiency=p.data["current-energy-efficiency"], + target_efficiency="69", + current_consumption=p.estimate_electrical_consumption( + assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions + ), + ), + "property_id": p.id, + "uprn": p.uprn + } for p in input_properties if p.building_id is None + ] if building_ids: # Find the unique longitude and latitude pairs for each building id unique_coordinates = {} @@ -511,32 +528,21 @@ async def trigger_plan(body: PlanTriggerRequest): ) p.set_solar_panel_configuration(unit_solar_panel_configuration) - else: + if individual_units: # Model the solar potential at the property level - for p in input_properties: - # TODO: Complete me! - we probably won't do this for individual flats - IGNORE FLATS FROM THIS WITHOUT - # BUILDING IDS - - electric_consumption = p.estimate_electrical_consumption( - assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions - ) - - # We now decrease this, based on the expected energy efficiency of the property post retrofit to a C, - # which is the common level we would expect the property to reach when treating the fabric of the - # home - electric_consumption = energy_consumption_client.estimate_new_consumption( - current_energy_efficiency=p.data["current-energy-efficiency"], - target_efficiency="69", - current_consumption=electric_consumption - ) + for unit in individual_units: + property_instance = [p for p in input_properties if p.id == unit["property_id"]][0] + # At this level, we check if the property is suitable for solar and if now, skip + if not property_instance.is_solar_pv_valid(): + continue solar_api_client.get( - longitude=p.spatial["longitude"], - latitude=p.spatial["latitude"], - energy_consumption=electric_consumption, + longitude=unit["longitude"], + latitude=unit["latitude"], + energy_consumption=unit["energy_consumption"], is_building=False, session=session, - uprn=p.uprn + uprn=unit["uprn"] ) # Store the data in the database @@ -544,16 +550,22 @@ async def trigger_plan(body: PlanTriggerRequest): solar_api_client.save_to_db( session=session, uprns_to_location=[ - {"uprn": p.uprn, "longitude": p.spatial["longitude"], "latitude": p.spatial["latitude"]} + { + "uprn": property_instance.uprn, + "longitude": property_instance.spatial["longitude"], + "latitude": property_instance.spatial["latitude"] + } ], scenario_type="unit" ) - # TODO: Insert the pitched roof area into the property class as we store the solar performance - # in the property class - print("Implement me") - - # TODO: We can set the pitched roof area based on the results of the solar api! + property_instance.set_solar_panel_configuration( + solar_panel_configuration={ + "insights_data": solar_api_client.insights_data, + "panel_performance": solar_api_client.panel_performance + }, + roof_area=solar_api_client.roof_area + ) logger.info("Getting components and epc recommendations") recommendations = {} diff --git a/recommendations/SolarPvRecommendations.py b/recommendations/SolarPvRecommendations.py index 276573ec..4eece985 100644 --- a/recommendations/SolarPvRecommendations.py +++ b/recommendations/SolarPvRecommendations.py @@ -78,23 +78,6 @@ class SolarPvRecommendations: } ] - def is_solar_pv_valid(self): - - # If the property is a flat but we are looking at building solar potential, we can include this - if (self.property.building_id is not None) and (self.property.solar_panel_configuration is not None): - return True - - is_valid_property_type = self.property.data["property-type"] in ["House", "Bungalow", "Maisonette"] - is_valid_roof_type = ( - self.property.roof["is_flat"] or self.property.roof["is_pitched"] or self.property.roof["is_roof_room"] - ) - # If there is no existing solar PV, the photo-supply field will be None or a missing value - has_no_existing_solar_pv = self.property.data["photo-supply"] in [ - None, 0, self.property.DATA_ANOMALY_MATCHES - ] - - return is_valid_property_type and is_valid_roof_type and has_no_existing_solar_pv - def recommend_building_analysis(self, phase): """ This recommendation approach handles the case of producing solar PV recommendations at the building level, @@ -159,7 +142,7 @@ class SolarPvRecommendations: :return: """ - if not self.is_solar_pv_valid(): + if not self.property.is_solar_pv_valid(): return # If we have a buiilding level analysis, we implement separate logic