diff --git a/backend/export/property_scenarios/db_functions.py b/backend/export/property_scenarios/db_functions.py index 1527a989..e9b3d7e3 100644 --- a/backend/export/property_scenarios/db_functions.py +++ b/backend/export/property_scenarios/db_functions.py @@ -15,6 +15,7 @@ from backend.app.db.models.portfolio import ( PropertyModel, PropertyDetailsEpcModel, ) +from backend.app.db.models.materials import Material from utils.logger import setup_logger logger = setup_logger() @@ -192,31 +193,35 @@ class DbMethods: recommendations_df["materials"] = [] return recommendations_df - rec_ids: List[int] = [int(x) for x in recommendations_df["id"].tolist()] + rec_ids: List[int] = recommendations_df["id"].astype(int).tolist() - stmt = select(RecommendationMaterials).where( - RecommendationMaterials.recommendation_id.in_(rec_ids) + stmt = ( + select(RecommendationMaterials, Material) + .join(Material, RecommendationMaterials.material_id == Material.id) + .where(RecommendationMaterials.recommendation_id.in_(rec_ids)) ) - materials_query: Sequence[RecommendationMaterials] = ( - self.session.scalars(stmt).all() + rows: Sequence[Tuple[RecommendationMaterials, Material]] = ( + self.session.execute(stmt).tuples().all() ) materials_map: Dict[int, List[Dict[str, Any]]] = defaultdict(list) - for m in materials_query: - materials_map[m.recommendation_id].append( + for rec_mat, material in rows: + materials_map[rec_mat.recommendation_id].append( { - "material_id": m.material_id, - "depth": m.depth, - "quantity": m.quantity, - "quantity_unit": m.quantity_unit, - "estimated_cost": m.estimated_cost, + "material_id": rec_mat.material_id, + "depth": rec_mat.depth, + "quantity": rec_mat.quantity, + "quantity_unit": rec_mat.quantity_unit, + "estimated_cost": rec_mat.estimated_cost, + "type": material.type.value if material.type else None, + "includes_battery": material.includes_battery, } ) recommendations_df["materials"] = recommendations_df["id"].astype(int).apply( - lambda x: materials_map.get(int(x), []) + lambda x: materials_map.get(x, []) ) return recommendations_df diff --git a/backend/export/property_scenarios/main.py b/backend/export/property_scenarios/main.py index b7c61df5..d38db4c9 100644 --- a/backend/export/property_scenarios/main.py +++ b/backend/export/property_scenarios/main.py @@ -19,6 +19,21 @@ def choose_group_keys(payload: ExportRequest) -> List[Union[int, str]]: return payload.scenario_ids +def has_solar_with_battery(materials_list: Optional[List[Dict[str, Any]]]) -> bool: + """ + Simple check to determine if any material in the list is a solar PV measure that includes a battery. + :param materials_list: + :return: + """ + for m in materials_list or []: + if ( + m.get("type") == "solar_pv" + and m.get("includes_battery") is True + ): + return True + return False + + def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, int], pd.DataFrame]: export_files: Dict[Union[str, int], pd.DataFrame] = {} @@ -46,6 +61,19 @@ def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, recommendations_df = db_methods.attach_materials(recommendations_df) + recommendations_df["has_solar_with_battery"] = ( + recommendations_df["materials"].apply(has_solar_with_battery) + ) + + _filter = ( + (recommendations_df["measure_type"] == "solar_pv") + & (recommendations_df["has_solar_with_battery"]) + ) + + recommendations_df.loc[_filter, "measure_type"] = ( + recommendations_df.loc[_filter, "measure_type"] + "_with_battery" + ) + group_keys: List[Union[str, int]] = choose_group_keys(payload) for group_key in group_keys: diff --git a/backend/export/tests/test_export.py b/backend/export/tests/test_export.py index 72eafbe5..823882b5 100644 --- a/backend/export/tests/test_export.py +++ b/backend/export/tests/test_export.py @@ -7,7 +7,9 @@ from backend.export.property_scenarios.main import process_export from backend.export.property_scenarios.input_schema import ExportRequest from backend.app.db.models.portfolio import PropertyModel, Epc, Portfolio, PortfolioStatus, PortfolioGoal, \ PropertyCreationStatus, PropertyDetailsEpcModel -from backend.app.db.models.recommendations import PlanModel, Recommendation, PlanRecommendations +from backend.app.db.models.recommendations import PlanModel, Recommendation, PlanRecommendations, \ + RecommendationMaterials +from backend.app.db.models.materials import Material from utils.logger import setup_logger FIXTURE_PATH = Path("backend/export/tests/fixtures") @@ -262,3 +264,277 @@ def test_default_export_integration(db_session): ) assert df.shape == (10, 95), "Expected dataframe shape to be (10, 11), got {}".format(df.shape) + + +def test_solar_with_battery_example(db_session): + test_portfolio_id = 1 + test_property_id = 1 + + portfolio_df = pd.DataFrame( + [{'id': test_portfolio_id, 'name': 'Example', 'budget': None, + 'status': 'PortfolioStatus.SCOPING', 'goal': 'PortfolioGoal.NONE', 'cost': None, 'number_of_properties': None, + 'co2_equivalent_savings': None, 'energy_savings': None, 'energy_cost_savings': None, + 'property_valuation_increase': None, 'rental_yield_increase': None, 'total_work_hours': None, + 'labour_days': None, 'created_at': '2026-02-12 21:23:37.862000+00:00', + 'updated_at': '2026-02-12 21:23:37.862000+00:00', 'epc_breakdown_pre_retrofit': None, + 'epc_breakdown_post_retrofit': None, 'n_units_to_retrofit': None, 'co2_per_unit_pre_retrofit': None, + 'co2_per_unit_post_retrofit': None, 'energy_bill_per_unit_pre_retrofit': None, + 'energy_bill_per_unit_post_retrofit': None, 'energy_consumption_per_unit_pre_retrofit': None, + 'energy_consumption_per_unit_post_retrofit': None, 'valuation_improvement_per_unit': None, + 'cost_per_unit': None, 'cost_per_co2_saved': None, 'cost_per_sap_point': None, + 'valuation_return_on_investment': None}] + ) + + properties_df = pd.DataFrame( + [{'id': test_property_id, 'portfolio_id': test_portfolio_id, 'creation_status': 'PropertyCreationStatus.READY', + 'uprn': 100090438731, 'landlord_property_id': 'BARR052', 'building_reference_number': 3460742868.0, + 'status': 'PortfolioStatus.ASSESSMENT', 'address': '52, Barrack Street', 'postcode': 'CO1 2LR', + 'has_pre_condition_report': True, 'has_recommendations': True, 'created_at': '2026-02-12 21:59:02.744427', + 'updated_at': '2026-02-19 16:18:57.941443', 'property_type': 'House', 'built_form': 'End-Terrace', + 'local_authority': 'Colchester', 'constituency': 'Colchester', 'number_of_rooms': 4.0, 'year_built': 1900.0, + 'tenure': 'rental (private)', 'current_epc_rating': 'Epc.E', 'current_sap_points': 53.0, + 'current_valuation': 0.0, 'installed_measures_sap_point_adjustment': 0.0, + 'is_sap_points_adjusted_for_installed_measures': False, 'original_sap_points': 53.0}] + ) + + property_details_epc_df = pd.DataFrame( + [ + {'id': 1534934, 'property_id': test_property_id, 'portfolio_id': test_portfolio_id, + 'full_address': '48, Medcalf Road', 'lodgement_date': '2018-09-05', 'is_expired': False, + 'total_floor_area': 68.0, 'walls': 'Solid brick, as built, no insulation', 'walls_rating': 1, + 'roof': 'Pitched, no insulation', 'roof_rating': 1.0, 'floor': 'Solid, no insulation', + 'floor_rating': None, + 'windows': 'Fully double glazed', 'windows_rating': 4, 'heating': 'Boiler and radiators, mains gas', + 'heating_rating': 4, 'heating_controls': 'Programmer, room thermostat and trvs', + 'heating_controls_rating': 4, + 'hot_water': 'From main system', 'hot_water_rating': 4, + 'lighting': 'Low energy lighting in all fixed outlets', 'lighting_rating': 5, + 'mainfuel': 'Mains gas not community', 'ventilation': 'natural', 'solar_pv': 0.0, 'solar_hot_water': False, + 'wind_turbine': 0.0, 'floor_height': 2.55, 'number_heated_rooms': None, 'heat_loss_corridor': False, + 'unheated_corridor_length': None, 'number_of_open_fireplaces': 0, 'number_of_extensions': 0, + 'number_of_storeys': None, 'mains_gas': True, 'energy_tariff': 'Single', + 'primary_energy_consumption': 278.0, + 'co2_emissions': 3.81, 'current_energy_demand': 14643.366, + 'current_energy_demand_heating_hotwater': 12185.6, + 'estimated': False, 'sap_05_overwritten': False, 'sap_05_score': None, 'sap_05_epc_rating': None, + 'heating_cost_current': 711.0628, 'hot_water_cost_current': 139.06198, 'lighting_cost_current': 70.770935, + 'appliances_cost_current': 609.7844, 'gas_standing_charge': 128.0785, + 'electricity_standing_charge': 199.8375, + 'original_co2_emissions': 3.81, 'original_primary_energy_consumption': 278.0, + 'original_current_energy_demand': 14643.366, 'original_current_energy_demand_heating_hotwater': 12185.6, + 'installed_measures_co2_adjustment': 0.0, 'installed_measures_energy_demand_adjustment': 0.0, + 'installed_measures_total_energy_bill_adjustment': 0.0, 'installed_measures_heat_demand_adjustment': 0.0, + 'is_epc_adjusted_for_installed_measures': False} + ] + ) + + plans_df = pd.DataFrame( + [ + {'id': 0, 'name': None, 'portfolio_id': test_portfolio_id, 'property_id': test_property_id, + 'scenario_id': 1060, 'created_at': '2026-02-19 16:14:45.560816', 'is_default': True, + 'valuation_increase_lower_bound': 0.0302, + 'valuation_increase_upper_bound': 0.07, 'valuation_increase_average': 0.048226666, 'plan_type': None, + 'post_sap_points': 71.5, 'post_epc_rating': 'Epc.C', 'post_co2_emissions': 4.1813498, + 'co2_savings': 0.71865046, 'post_energy_bill': 1447.5204, 'energy_bill_savings': 691.6662, + 'post_energy_consumption': 15303.688, 'energy_consumption_savings': 3276.7622, + 'valuation_post_retrofit': None, 'valuation_increase': None, 'cost_of_works': 6984.568, + 'contingency_cost': 1003.9568} + ] + ) + + plan_recs_df = pd.DataFrame( + [{'id': 0, 'plan_id': 0, 'recommendation_id': 0}] + ) + + recommendations_df = pd.DataFrame( + [{'id': 0, 'property_id': test_property_id, 'created_at': '2026-02-19 16:14:45.560816', + 'type': 'solar_pv', 'measure_type': 'solar_pv', + 'description': 'Fit solar', + 'estimated_cost': 10000, 'default': True, 'starting_u_value': None, 'new_u_value': None, 'sap_points': 1.5, + 'heat_demand': 14.9, 'kwh_savings': 1041.2, 'co2_equivalent_savings': 0.2, 'energy_savings': 14.9, + 'energy_cost_savings': 72.639015, 'property_valuation_increase': None, 'rental_yield_increase': None, + 'total_work_hours': 4.16, 'labour_days': 1.0, 'already_installed': False, 'plan_name': 'whatever'} + ] + ) + + recommendations_materials_df = pd.DataFrame( + [ + { + "id": 0, "recommendation_id": 0, "material_id": 0, "depth": None, "quantity": 1.0, + "quantity_unit": "part", + "estimated_cost": 10000, "created_at": '2026-02-19 16:14:45.560816', + "updated_at": '2026-02-19 16:14:45.560816', + } + ] + ) + + materials_df = pd.DataFrame( + [ + {'id': 0, 'type': 'solar_pv', 'description': 'Some solar product', + 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Test', + 'created_at': "'2026-02-19 16:14:45.560816", 'is_active': True, + 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, + 'total_cost': 10000, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None, + 'includes_scaffolding': True, 'includes_battery': True, 'battery_size': 5.8} + ] + ) + + # Load into db + # ------------------------------------------------- + # Insert Portfolio + # ------------------------------------------------- + for row in portfolio_df.itertuples(index=False): + db_session.add( + Portfolio( + id=row.id, + name=row.name, + status=PortfolioStatus[row.status.split(".")[-1]], + goal=PortfolioGoal[row.goal.split(".")[-1]], + ) + ) + db_session.flush() + + # ------------------------------------------------- + # Insert Property + # ------------------------------------------------- + for row in properties_df.itertuples(index=False): + prop = PropertyModel( + id=row.id, + portfolio_id=row.portfolio_id, + creation_status=PropertyCreationStatus[row.creation_status.split(".")[-1]], + status=PortfolioStatus[row.status.split(".")[-1]], + uprn=row.uprn, + property_type=row.property_type, + current_sap_points=row.current_sap_points, + current_epc_rating=Epc[row.current_epc_rating.split(".")[-1]], + ) + db_session.add(prop) + db_session.flush() + + # ------------------------------------------------- + # Insert EPC Details + # ------------------------------------------------- + for row in property_details_epc_df.itertuples(index=False): + epc = PropertyDetailsEpcModel( + property_id=row.property_id, + portfolio_id=row.portfolio_id, + full_address=row.full_address, + total_floor_area=row.total_floor_area, + walls=row.walls, + roof=row.roof, + windows=row.windows, + heating=row.heating, + solar_pv=row.solar_pv, + ) + db_session.add(epc) + db_session.flush() + + # ------------------------------------------------- + # Insert Plan (default) + # ------------------------------------------------- + for row in plans_df.itertuples(index=False): + plan = PlanModel( + id=row.id, + portfolio_id=row.portfolio_id, + property_id=row.property_id, + scenario_id=None, # default mode + is_default=row.is_default, + ) + db_session.add(plan) + db_session.flush() + + # ------------------------------------------------- + # IMPORTANT: Force recommendation to be solar_pv + # ------------------------------------------------- + recommendations_df.loc[0, "measure_type"] = "solar_pv" + + for row in recommendations_df.itertuples(index=False): + rec = Recommendation( + id=row.id, + property_id=row.property_id, + measure_type=row.measure_type, + estimated_cost=row.estimated_cost, + default=row.default, + already_installed=row.already_installed, + sap_points=row.sap_points, + type=row.type, + description=row.description + ) + db_session.add(rec) + db_session.flush() + + # ------------------------------------------------- + # Link Plan -> Recommendation + # ------------------------------------------------- + for row in plan_recs_df.itertuples(index=False): + db_session.add( + PlanRecommendations( + plan_id=row.plan_id, + recommendation_id=row.recommendation_id, + ) + ) + db_session.flush() + + # ------------------------------------------------- + # Insert Material (includes_battery=True) + # ------------------------------------------------- + for row in materials_df.itertuples(index=False): + material = Material( + id=row.id, + type=row.type, + description=row.description, + depth_unit=row.depth_unit, + cost_unit=row.cost_unit, + r_value_unit=row.r_value_unit, + thermal_conductivity_unit=row.thermal_conductivity_unit, + includes_battery=row.includes_battery, + is_active=row.is_active, + ) + db_session.add(material) + db_session.flush() + + # ------------------------------------------------- + # Link Recommendation -> Material + # ------------------------------------------------- + for row in recommendations_materials_df.itertuples(index=False): + db_session.add( + RecommendationMaterials( + recommendation_id=row.recommendation_id, + material_id=row.material_id, + depth=row.depth or 0.0, + quantity=row.quantity, + quantity_unit=row.quantity_unit, + estimated_cost=row.estimated_cost, + ) + ) + + db_session.commit() + + payload = ExportRequest.model_validate({ + "task_id": "test", + "subtask_id": "test", + "portfolio_id": test_portfolio_id, + "scenario_ids": [], + "default_plans_only": True, + }) + + result = process_export(payload, session=db_session) + + assert "default_plans" in result + + df = result["default_plans"] + + assert "solar_pv_with_battery" in df.columns + + # solar_pv should NOT exist + assert "solar_pv" not in df.columns + + assert df.shape[0] == 1, "Expected 1 property in the export, got {}".format(df.shape[0]) + + # Cost should land in correct column + assert df["solar_pv_with_battery"].iloc[0] == 10000