From 76716f35d3c54eec78509fe62e60e3bd8d7eb83e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 3 Nov 2025 14:44:36 +0000 Subject: [PATCH] added some basic level of override so we force solar recommendations if we have inspections --- backend/apis/GoogleSolarApi.py | 62 ++++++++++++++++++++++++++++++---- backend/app/plan/utils.py | 2 +- backend/engine/engine.py | 19 ++++++++--- 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index a8982061..dcf08fb5 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -479,9 +479,7 @@ class GoogleSolarApi: roi_results = pd.DataFrame(roi_results) - panel_performance = panel_performance.merge( - roi_results, how="left", on="n_panels" - ) + panel_performance = panel_performance.merge(roi_results, how="left", on="n_panels") # We want max roi, minimal generation deficit, and max generation value - we create a ranking score # Assign equal weights to each metric @@ -742,7 +740,7 @@ class GoogleSolarApi: @classmethod def building_solar_analysis( cls, building_solar_config: List, input_properties: List[Property], session, google_solar_api_key: str, - solar_materials: list + solar_materials: list, ): """ Perform the solar analysis for the building level @@ -826,9 +824,21 @@ class GoogleSolarApi: @classmethod def unit_solar_analysis( cls, unit_solar_config: List, input_properties: List[Property], session, body, google_solar_api_key: str, - solar_materials: list + solar_materials: list, inspections_map: dict ): + """ + Perform the solar analysis for the unit level + :param unit_solar_config: List of unit solar configurations + :param input_properties: List of properties + :param session: Database session + :param body: PlanTriggerRequest instance + :param google_solar_api_key: Google Solar API key + :param solar_materials: List of solar materials + :param inspections_map: Dictionary mapping property IDs to inspection data + :return: + """ + if not unit_solar_config: return input_properties @@ -879,6 +889,15 @@ class GoogleSolarApi: property_instance=property_instance, ) + property_inspections = inspections_map.get(property_instance.id, {}) + + if property_inspections: + # If we have some inspections data, we check if we have some data which indicates solar cannot + # be installed. We're loose about this now since this is post review + if solar_api_client.panel_performance.empty: + # We assume solar is a suitable option + solar_api_client.panel_performance = solar_api_client.default_panel_performance(property_instance) + # Store the data in the database solar_api_client.save_to_db( session=session, @@ -923,12 +942,43 @@ class GoogleSolarApi: None ) - if material_1_6 is None or material_3_2 is None: + material_4_35 = next( + (m for m in self.solar_materials if m["type"] == "solar_pv" and + abs(m["size"] - 4.35) < 0.1 and not m["includes_battery"]), + None + ) + + if material_1_6 is None or material_3_2 is None or material_4_35 is None: raise ValueError("No suitable solar product found for the default configuration.") # We return a 1.6 and 3.2 kwp system panel_performance = pd.DataFrame( [ + { + 'n_panels': 10, + 'yearly_dc_energy': 4350 * assumptions.MEDIAN_WATTAGE_TO_DC, + 'total_cost': cost_instance.solar_pv( + solar_product=material_4_35, + scaffolding_options=[ + {"total_cost": 1000, "size": property_instance.number_of_floors}, + {"total_cost": 1000, "size": 3} + ], + n_floors=property_instance.number_of_floors + )["total"], + 'weighted_ratio': None, + 'panneled_roof_area': 9 * assumptions.RDSAP_AREA_PER_PANEL, + 'array_wattage': 4350, + 'initial_ac_kwh_per_year': 4350 * assumptions.MEDIAN_WATTAGE_TO_AC, + 'lifetime_ac_kwh': None, + 'lifetime_dc_kwh': None, + 'roi': None, + 'generation_value': None, + 'generation_deficit': None, + 'expected_payback_years': None, + 'surplus': None, + 'combined_score': None, + 'rank': None + }, { 'n_panels': 8, 'yearly_dc_energy': 3200 * assumptions.MEDIAN_WATTAGE_TO_DC, diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py index fe995935..4ebb41f8 100644 --- a/backend/app/plan/utils.py +++ b/backend/app/plan/utils.py @@ -139,7 +139,7 @@ def parse_eco_packages(config: dict[str, Any]) -> tuple[list[str], int, str] | t "plan_type": "solar_eco4" }, "Solar Eligible, Needs Heating Upgrade": { - "measures": ["solar_pv", "loft_insulation", "high_heat_retention_storage_heater"], + "measures": ["solar_pv", "loft_insulation", "high_heat_retention_storage_heater", "mechanical_ventilation"], "target_sap": 86, # High B "plan_type": "solar_hhrsh_eco4" }, diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 0cb9d860..175d12a0 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -400,8 +400,13 @@ async def model_engine(body: PlanTriggerRequest): plan_input = plan_input.rename( columns={"domna_address_1": "address", "domna_postcode": "postcode", "epc_os_uprn": "uprn"} ) - # Where the EPC has been estimated, that is because a UPRN wasn't avaialble and so we remote UPRN - plan_input["uprn"] = np.where(plan_input["estimated"].isin([1, True]), None, plan_input["uprn"]) + # Where the EPC has been estimated, that is because a UPRN wasn't avaialble and so we remove UPRN + # This will be reflexted + plan_input["uprn"] = np.where( + plan_input["estimated"].isin([1, True]) & ( + (plan_input["uprn"] < 0) | pd.isnull(plan_input["uprn"]) + ), None, plan_input["uprn"] + ) # We handle the landlord property type and built form plan_input["property_type"] = plan_input["landlord_property_type"].copy() if "landlord_built_form" in plan_input.columns: @@ -512,7 +517,9 @@ async def model_engine(body: PlanTriggerRequest): epc_searcher.ordnance_survey_client.property_type = config.get("property_type", None) # For the moment, our OS API access is unavailable, so we skip and interpolate epc_searcher.find_property(skip_os=True) - if epc_searcher.newest_epc.get("estimated") and body.file_format == "domna_asset_list": + if epc_searcher.newest_epc.get("estimated") and body.file_format == "domna_asset_list" and ( + epc_searcher.newest_epc["uprn"] < 0 + ): epc_searcher.newest_epc["uprn-source"] = epc_searcher.UPRN_SOURCE_SIMULATED # We check for an energy assessment we have performed on this property: @@ -678,7 +685,7 @@ async def model_engine(body: PlanTriggerRequest): input_properties=input_properties, session=session, google_solar_api_key=get_settings().GOOGLE_SOLAR_API_KEY, - solar_materials=[m for m in materials if m["type"] == "solar_pv"] + solar_materials=[m for m in materials if m["type"] == "solar_pv"], ) input_properties = GoogleSolarApi.unit_solar_analysis( @@ -688,8 +695,12 @@ async def model_engine(body: PlanTriggerRequest): body=body, solar_materials=[m for m in materials if m["type"] == "solar_pv"], google_solar_api_key=get_settings().GOOGLE_SOLAR_API_KEY, + inspections_map=inspections_map ) + # We also make a tweak - if the property has been flagged for solar but doesn't contain + # any panel performance, we ensure that we have a 3kWp and 4kWp option for the property + logger.info("Identifying property recommendations") recommendations = {} recommendations_scoring_data = []