From 776f3a48e5fbacd87a10b642940598a75d64f6ef Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 18 Aug 2023 17:21:11 +0100 Subject: [PATCH] Added in portfolio aggregation method --- .../app/db/functions/portfolio_functions.py | 39 +++++++++ backend/app/plan/router.py | 82 +++++++++++-------- model_data/optimiser/optimiser_functions.py | 6 +- 3 files changed, 88 insertions(+), 39 deletions(-) create mode 100644 backend/app/db/functions/portfolio_functions.py diff --git a/backend/app/db/functions/portfolio_functions.py b/backend/app/db/functions/portfolio_functions.py new file mode 100644 index 00000000..9da590dd --- /dev/null +++ b/backend/app/db/functions/portfolio_functions.py @@ -0,0 +1,39 @@ +from sqlalchemy.orm import sessionmaker +from sqlalchemy import func +from backend.app.db.connection import db_engine +from backend.app.db.models.recommendations import Plan, PlanRecommendations, Recommendation +from backend.app.db.models.portfolio import Portfolio + + +def aggregate_portfolio_recommendations(portfolio_id: int): + Session = sessionmaker(bind=db_engine) + with Session() as session: + # Aggregate multiple fields + aggregates = ( + session.query( + func.sum(Recommendation.estimated_cost).label("cost"), + # For future usage we will aggregate multiple fields in this step + # func.sum(Recommendation.heat_demand).label("total_heat_demand"), + # func.sum(Recommendation.energy_savings).label("total_energy_savings") + ) + .join(PlanRecommendations, PlanRecommendations.recommendation_id == Recommendation.id) + .join(Plan, Plan.id == PlanRecommendations.plan_id) + .filter(Plan.portfolio_id == portfolio_id, Plan.is_default == True, Recommendation.default == True) + .one() + ) + + aggregates_dict = { + "cost": aggregates.cost or 0, + # "total_heat_demand": aggregates.total_heat_demand or 0, + # "total_energy_savings": aggregates.total_energy_savings or 0 + } + + # Get the portfolio and update the fields + portfolio = session.query(Portfolio).filter_by(id=portfolio_id).one() + # Update the data + for key, value in aggregates_dict.items(): + setattr(portfolio, key, value) + + # Merge the updated portfolio back into the session + session.merge(portfolio) + session.commit() diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 79300d1b..3b9c5242 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -22,6 +22,7 @@ from backend.app.db.functions.materials_functions import get_materials from backend.app.db.functions.recommendations_functions import ( create_plan, create_recommendation, create_recommendation_material, create_plan_recommendations ) +from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations from model_data.optimiser.GainOptimiser import GainOptimiser from model_data.optimiser.CostOptimiser import CostOptimiser @@ -105,23 +106,23 @@ def filter_materials(materials): return materials_by_type -def insert_temp_recommendation_id(recommendations_to_upload): +def insert_temp_recommendation_id(property_recommendations): """ Creates a temporary recommendation id which is needed for filtering recommendations between default and no, after the optimiser has been run - :param recommendations_to_upload: nested list of recommendations, grouped by types + :param property_recommendations: nested list of recommendations, grouped by types :return: Updated recommendations_to_upload, where where recommendation has a "recommendation_id" integer inserted """ idx = 0 - for recs in recommendations_to_upload: + for recs in property_recommendations: for rec in recs: rec["recommendation_id"] = idx idx += 1 - return recommendations_to_upload + return property_recommendations @router.post("/trigger") @@ -197,6 +198,8 @@ async def trigger_plan(body: PlanTriggerRequest): logger.info("Getting components and properties recommendations") + # TODO: Move this to a class. We probably was a Recommender class which takes the injects the optimisers + # in as a dependency and then the optimisers can take the input measures in as part of the setup() method recommendations = {} for p in input_properties: property_recommendations = [] @@ -264,37 +267,14 @@ async def trigger_plan(body: PlanTriggerRequest): if wall_recomender.recommendations: property_recommendations.append(wall_recomender.recommendations) - recommendations[p.id] = property_recommendations + # Use the optimiser to pick the default recommendations and decide if we need certain + # recommendations to get to the goal + property_recommendations = insert_temp_recommendation_id(property_recommendations) - # Once we're done, we'll store: - # 1) the property data - # 2) the property details (epc) - # 3) the recommendations - - logger.info("Uploading recommendations to the database") - # Upload property data - for p in input_properties: - property_details_epc = p.get_property_details_epc(portfolio_id=body.portfolio_id, rating_lookup=rating_lookup) - create_property_details_epc(property_details_epc) - - property_data = p.get_full_property_data() - update_property_data(property_id=p.id, portfolio_id=body.portfolio_id, property_data=property_data) - - # Upload recommendations - - # TODO: We start off by optimising the recommendations - - recommendations_to_upload = recommendations[p.id] - - if not recommendations_to_upload: + if not property_recommendations: continue - recommendations_to_upload = insert_temp_recommendation_id(recommendations_to_upload) - - # Optimise the recommendations - - # We need to format the recommendations for the optimiser - input_measures = prepare_input_measures(recommendations_to_upload, body.goal) + input_measures = prepare_input_measures(property_recommendations, body.goal) if body.budget: optimiser = GainOptimiser(input_measures, max_cost=body.budget) @@ -315,19 +295,41 @@ async def trigger_plan(body: PlanTriggerRequest): selected_recommendations = {r["id"] for r in solution} # We'll use the set of selected recommendations to filter the recommendations to upload - recommendations_to_upload = [ + property_recommendations = [ [ {**rec, "default": True if rec["recommendation_id"] in selected_recommendations else False} for rec in recommendations_by_type ] - for recommendations_by_type in recommendations_to_upload + for recommendations_by_type in property_recommendations ] # We'll also unlist the recommendations so they're a bit easier to handle from here onwards - recommendations_to_upload = [ - rec for recommendations_by_type in recommendations_to_upload for rec in recommendations_by_type + property_recommendations = [ + rec for recommendations_by_type in property_recommendations for rec in recommendations_by_type ] + recommendations[p.id] = property_recommendations + + # Once we're done, we'll store: + # 1) the property data + # 2) the property details (epc) + # 3) the recommendations + + logger.info("Uploading recommendations to the database") + # Upload property data + for p in input_properties: + property_details_epc = p.get_property_details_epc(portfolio_id=body.portfolio_id, rating_lookup=rating_lookup) + create_property_details_epc(property_details_epc) + + property_data = p.get_full_property_data() + update_property_data(property_id=p.id, portfolio_id=body.portfolio_id, property_data=property_data) + + # Upload recommendations + recommendations_to_upload = recommendations.get(p.id, []) + + if not recommendations_to_upload: + continue + # Create a plan new_plan_id = create_plan( { @@ -371,4 +373,12 @@ async def trigger_plan(body: PlanTriggerRequest): recommendation_ids=uploaded_recommendation_ids ) + logger.info("Creating portfolio aggregations") + # We implement this in the simplest way possible which will be just to query the database for all + # recommendations associated to the portfolio and then aggregate them. This is not the most efficient + # way to do this, but it's the simplest and will be a process that we can re-use since when we change a + # recommendation from being default to not default, we'll need to re-run this process to re-calculate the + # the portfolion level impact + aggregate_portfolio_recommendations(portfolio_id=body.portfolio_id) + return Response(status_code=200) diff --git a/model_data/optimiser/optimiser_functions.py b/model_data/optimiser/optimiser_functions.py index b1796065..869880cf 100644 --- a/model_data/optimiser/optimiser_functions.py +++ b/model_data/optimiser/optimiser_functions.py @@ -1,8 +1,8 @@ -def prepare_input_measures(recommendations_to_upload, goal): +def prepare_input_measures(property_recommendations, goal): """ Basic function to convert recommendations_to_upload to a format that is suitable for the optimiser - large - :param recommendations_to_upload: object containing the recommendations, created in the plan trigger api + :param property_recommendations: object containing the recommendations, created in the plan trigger api :param goal: goal to be optimised for, should be one of the keys in gain_map. E.g. if the gain is SAP points, the goal should reflect that desired gain :return: Nested list of input measures @@ -17,7 +17,7 @@ def prepare_input_measures(recommendations_to_upload, goal): raise NotImplementedError("Not implemented this gain type - investigate me") input_measures = [] - for recs in recommendations_to_upload: + for recs in property_recommendations: input_measures.append( [ {