diff --git a/CHANGELOG.md b/CHANGELOG.md index 8985488ff..cf855d1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,8 @@ Classify the change according to the following categories: ##### Removed ### Patches -## Develop 2024-08-20 -### Major Updates +## v3.10.0 +### Minor Updates #### Added - Added new model **ElectricHeaterInputs** - Added new model **ElectricHeaterOutputs** @@ -36,6 +36,18 @@ Classify the change according to the following categories: - Added new model **ASHPSpaceHeaterInputs** - Added new model **ASHPSpaceHeaterOutputs** +## v3.9.4 +### Minor Updates +#### Added +- **portfolio_uuid** is now a field that can be added to API objects +- **PortfolioUnlinkedRuns** tracks which run_uuids were separated from their portfolios +- `/user//unlink_from_portfolio/` endpoint (calls `views.unlink_from_portfolio`) +- `/summary_by_runuuids/` endpoint (calls `views.summary_by_runuuids`) +- `/link_run_to_portfolios/` endpoint (calls `views.link_run_to_portfolios`) + +#### Changed +- `UnexpectedError`, added portfolio_uuid as a field that can be returned in case of errors + ## v3.9.3 ### Minor Updates #### Added diff --git a/julia_src/Manifest.toml b/julia_src/Manifest.toml index d94339424..feddf4486 100644 --- a/julia_src/Manifest.toml +++ b/julia_src/Manifest.toml @@ -917,11 +917,9 @@ uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" [[deps.REopt]] deps = ["ArchGDAL", "CSV", "CoolProp", "DataFrames", "Dates", "DelimitedFiles", "HTTP", "JLD", "JSON", "JuMP", "LinDistFlow", "LinearAlgebra", "Logging", "MathOptInterface", "Requires", "Roots", "Statistics", "TestEnv"] -git-tree-sha1 = "125328678059c4650d8024c0c25676527667eb22" -repo-rev = "add-ASHP" -repo-url = "https://github.com/NREL/REopt.jl.git" +git-tree-sha1 = "32499f329265d270e9f77c8831892772b5fbf28f" uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6" -version = "0.47.2" +version = "0.48.0" [[deps.Random]] deps = ["SHA"] diff --git a/julia_src/http.jl b/julia_src/http.jl index 6af9762ac..fc91cabc9 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -105,6 +105,31 @@ function reopt(req::HTTP.Request) else ghp_dict = Dict() end + if haskey(d, "ASHPSpaceHeater") + inputs_with_defaults_from_ashp = [ + :max_ton, :installed_cost_per_ton, :om_cost_per_ton, + :macrs_option_years, :macrs_bonus_fraction, :can_supply_steam_turbine, + :can_serve_process_heat, :can_serve_dhw, :can_serve_space_heating, :can_serve_cooling, + :back_up_temp_threshold_degF, :sizing_factor, :heating_cop_reference, :heating_cf_reference, + :heating_reference_temps_degF, :cooling_cop_reference, :cooling_cf_reference, + :cooling_reference_temps_degF + ] + ashp_dict = Dict(key=>getfield(model_inputs.s.ashp, key) for key in inputs_with_defaults_from_ashp) + else + ashp_dict = Dict() + end + if haskey(d, "ASHPWaterHeater") + inputs_with_defaults_from_ashp_wh = [ + :max_ton, :installed_cost_per_ton, :om_cost_per_ton, + :macrs_option_years, :macrs_bonus_fraction, :can_supply_steam_turbine, + :can_serve_process_heat, :can_serve_dhw, :can_serve_space_heating, :can_serve_cooling, + :back_up_temp_threshold_degF, :sizing_factor, :heating_cop_reference, :heating_cf_reference, + :heating_reference_temps_degF + ] + ashp_wh_dict = Dict(key=>getfield(model_inputs.s.ashp_wh, key) for key in inputs_with_defaults_from_ashp_wh) + else + ashp_wh_dict = Dict() + end if haskey(d, "CoolingLoad") inputs_with_defaults_from_chiller = [ :cop @@ -125,7 +150,9 @@ function reopt(req::HTTP.Request) "CHP" => chp_dict, "SteamTurbine" => steamturbine_dict, "GHP" => ghp_dict, - "ExistingChiller" => chiller_dict + "ExistingChiller" => chiller_dict, + "ASHPSpaceHeater" => ashp_dict, + "ASHPWaterHeater" => ashp_wh_dict ) catch e @error "Something went wrong in REopt optimization!" exception=(e, catch_backtrace()) @@ -525,12 +552,18 @@ function get_ashp_defaults(req::HTTP.Request) @info("ASHP load served not provided. Using default of SpaceHeating.") d["load_served"] = "SpaceHeating" end + if !("force_into_system" in keys(d)) + @info("ASHP force_into_system not provided. Using default of false.") + d["force_into_system"] = false + elseif typeof(d["force_into_system"]) == String + d["force_into_system"] = parse(Bool, d["force_into_system"]) + end @info "Getting default ASHP attributes..." error_response = Dict() try # Have to specify "REopt.get_existing..." because http function has the same name - defaults = reoptjl.get_ashp_defaults(d["load_served"]) + defaults = reoptjl.get_ashp_defaults(d["load_served"],d["force_into_system"]) catch e @error "Something went wrong in the get_ashp_defaults endpoint" exception=(e, catch_backtrace()) error_response["error"] = sprint(showerror, e) diff --git a/reo/exceptions.py b/reo/exceptions.py index 9405ae075..1fca1087a 100644 --- a/reo/exceptions.py +++ b/reo/exceptions.py @@ -159,11 +159,11 @@ class UnexpectedError(REoptError): __name__ = 'UnexpectedError' - def __init__(self, exc_type, exc_value, exc_traceback, task='', run_uuid='', user_uuid='', message=None): + def __init__(self, exc_type, exc_value, exc_traceback, task='', run_uuid='', user_uuid='', portfolio_uuid='', message=None): debug_msg = "exc_type: {}; exc_value: {}; exc_traceback: {}".format(exc_type, exc_value, exc_traceback) if message is None: message = "Unexpected Error." - super(UnexpectedError, self).__init__(task=task, name=self.__name__, run_uuid=run_uuid, user_uuid=user_uuid, + super(UnexpectedError, self).__init__(task=task, name=self.__name__, run_uuid=run_uuid, user_uuid=user_uuid, portfolio_uuid=portfolio_uuid, message=message, traceback=debug_msg) diff --git a/reoptjl/api.py b/reoptjl/api.py index e2a6264b7..6d75a30b0 100644 --- a/reoptjl/api.py +++ b/reoptjl/api.py @@ -95,6 +95,16 @@ def obj_create(self, bundle, **kwargs): else: webtool_uuid = None + if 'portfolio_uuid' in bundle.data.keys(): + + if type(bundle.data['portfolio_uuid']) == str: + if len(bundle.data['portfolio_uuid']) == len(run_uuid): + portfolio_uuid = bundle.data['portfolio_uuid'] + else: + portfolio_uuid = '' + else: + portfolio_uuid = None + meta = { "run_uuid": run_uuid, "api_version": 3, @@ -110,6 +120,9 @@ def obj_create(self, bundle, **kwargs): if api_key != "": bundle.data['APIMeta']['api_key'] = api_key + + if portfolio_uuid is not None: + bundle.data['APIMeta']['portfolio_uuid'] = portfolio_uuid log.addFilter(UUIDFilter(run_uuid)) diff --git a/reoptjl/migrations/0061_portfoliounlinkedruns_apimeta_portfolio_uuid_and_more.py b/reoptjl/migrations/0061_portfoliounlinkedruns_apimeta_portfolio_uuid_and_more.py new file mode 100644 index 000000000..d65de7888 --- /dev/null +++ b/reoptjl/migrations/0061_portfoliounlinkedruns_apimeta_portfolio_uuid_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.0.7 on 2024-08-20 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0060_processheatloadinputs_addressable_load_fraction_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PortfolioUnlinkedRuns', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('portfolio_uuid', models.UUIDField()), + ('user_uuid', models.UUIDField()), + ('run_uuid', models.UUIDField(unique=True)), + ], + ), + migrations.AddField( + model_name='apimeta', + name='portfolio_uuid', + field=models.TextField(blank=True, default='', help_text='The unique ID of a portfolio (set of associated runs) created by the REopt Webtool. Note that this ID can be shared by several REopt API Scenarios and one user can have one-to-many portfolio_uuid tied to them.'), + ), + migrations.AlterField( + model_name='apimeta', + name='reopt_version', + field=models.TextField(blank=True, default='', help_text='Version number of the Julia package for REopt that is used to solve the problem.', null=True), + ), + ] diff --git a/reoptjl/migrations/0070_merge_20240925_0600.py b/reoptjl/migrations/0070_merge_20240925_0600.py new file mode 100644 index 000000000..4d42e6343 --- /dev/null +++ b/reoptjl/migrations/0070_merge_20240925_0600.py @@ -0,0 +1,14 @@ +# Generated by Django 4.0.7 on 2024-09-25 06:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0061_portfoliounlinkedruns_apimeta_portfolio_uuid_and_more'), + ('reoptjl', '0069_financialoutputs_initial_capital_costs_after_incentives_without_macrs_and_more'), + ] + + operations = [ + ] diff --git a/reoptjl/models.py b/reoptjl/models.py index 074066c96..35c3d96d0 100644 --- a/reoptjl/models.py +++ b/reoptjl/models.py @@ -185,6 +185,12 @@ class APIMeta(BaseModel, models.Model): default="", help_text="NREL Developer API key of the user" ) + portfolio_uuid = models.TextField( + blank=True, + default="", + help_text=("The unique ID of a portfolio (set of associated runs) created by the REopt Webtool. Note that this ID can be shared by " + "several REopt API Scenarios and one user can have one-to-many portfolio_uuid tied to them.") + ) class UserUnlinkedRuns(models.Model): run_uuid = models.UUIDField(unique=True) @@ -196,6 +202,17 @@ def create(cls, **kwargs): obj.save() return obj +class PortfolioUnlinkedRuns(models.Model): + portfolio_uuid = models.UUIDField(unique=False) + user_uuid = models.UUIDField(unique=False) + run_uuid = models.UUIDField(unique=True) + + @classmethod + def create(cls, **kwargs): + obj = cls(**kwargs) + obj.save() + return obj + class UserProvidedMeta(BaseModel, models.Model): """ User provided values that are not necessary for running REopt @@ -5477,6 +5494,12 @@ def clean(self): if self.dict.get("min_allowable_ton") not in [None, "", []] and self.dict.get("min_allowable_peak_capacity_fraction") not in [None, "", []]: error_messages["bad inputs"] = "At most one of min_allowable_ton and min_allowable_peak_capacity_fraction may be input to model {}".format(self.key) + if len(self.dict.get("heating_cop_reference")) != len(self.dict.get("heating_cf_reference")) or len(self.dict.get("heating_cop_reference")) != len(self.dict.get("heating_reference_temps_degF")): + error_messages["mismatched length"] = "Model {} inputs heating_cop_reference, heating_cf_reference, and heating_reference_temps_degF must all have the same length.".format(self.key) + + if len(self.dict.get("cooling_cop_reference")) != len(self.dict.get("cooling_cf_reference")) or len(self.dict.get("cooling_cop_reference")) != len(self.dict.get("cooling_reference_temps_degF")): + error_messages["mismatched length"] = "Model {} inputs cooling_cop_reference, cooling_cf_reference, and cooling_reference_temps_degF must all have the same length.".format(self.key) + if error_messages: raise ValidationError(error_messages) @@ -5739,6 +5762,9 @@ def clean(self): if self.dict.get("min_allowable_ton") not in [None, "", []] and self.dict.get("min_allowable_peak_capacity_fraction") not in [None, "", []]: error_messages["bad inputs"] = "At most one of min_allowable_ton and min_allowable_peak_capacity_fraction may be input to model {}".format(self.key) + if len(self.dict.get("heating_cop_reference")) != len(self.dict.get("heating_cf_reference")) or len(self.dict.get("heating_cop_reference")) != len(self.dict.get("heating_reference_temps_degF")): + error_messages["mismatched length"] = "Model {} inputs heating_cop_reference, heating_cf_reference, and heating_reference_temps_degF must all have the same length.".format(self.key) + if error_messages: raise ValidationError(error_messages) diff --git a/reoptjl/src/process_results.py b/reoptjl/src/process_results.py index 87dc6a824..87023850b 100644 --- a/reoptjl/src/process_results.py +++ b/reoptjl/src/process_results.py @@ -8,7 +8,7 @@ REoptjlMessageOutputs, AbsorptionChillerOutputs, BoilerOutputs, SteamTurbineInputs, \ SteamTurbineOutputs, GHPInputs, GHPOutputs, ExistingChillerInputs, \ ElectricHeaterOutputs, ASHPSpaceHeaterOutputs, ASHPWaterHeaterOutputs, \ - SiteInputs + SiteInputs, ASHPSpaceHeaterInputs, ASHPWaterHeaterInputs import numpy as np import sys import traceback as tb @@ -138,6 +138,10 @@ def update_inputs_in_database(inputs_to_update: dict, run_uuid: str) -> None: ExistingChillerInputs.create(meta=meta, **inputs_to_update["ExistingChiller"]).save() else: ExistingChillerInputs.objects.filter(meta__run_uuid=run_uuid).update(**inputs_to_update["ExistingChiller"]) + if inputs_to_update["ASHPSpaceHeater"]: + ASHPSpaceHeaterInputs.objects.filter(meta__run_uuid=run_uuid).update(**inputs_to_update["ASHPSpaceHeater"]) + if inputs_to_update["ASHPWaterHeater"]: + ASHPWaterHeaterInputs.objects.filter(meta__run_uuid=run_uuid).update(**inputs_to_update["ASHPWaterHeater"]) except Exception as e: exc_type, exc_value, exc_traceback = sys.exc_info() debug_msg = "exc_type: {}; exc_value: {}; exc_traceback: {}".format( diff --git a/reoptjl/test/test_http_endpoints.py b/reoptjl/test/test_http_endpoints.py index 619d6dd97..d844fd513 100644 --- a/reoptjl/test/test_http_endpoints.py +++ b/reoptjl/test/test_http_endpoints.py @@ -380,7 +380,8 @@ def test_default_existing_chiller_cop(self): def test_get_ashp_defaults(self): inputs_dict = { - "load_served": "SpaceHeating" + "load_served": "SpaceHeating", + "force_into_system": "true" } # Call to the django view endpoint /get_existing_chiller_default_cop which calls the http.jl endpoint @@ -388,9 +389,12 @@ def test_get_ashp_defaults(self): view_response = json.loads(resp.content) self.assertEqual(view_response["installed_cost_per_ton"], 2250) + self.assertEqual(view_response["om_cost_per_ton"], 0.0) + self.assertEqual(view_response["sizing_factor"], 1.1) inputs_dict = { - "load_served": "DomesticHotWater" + "load_served": "DomesticHotWater", + "force_into_system": "false" } # Call to the django view endpoint /get_existing_chiller_default_cop which calls the http.jl endpoint @@ -398,5 +402,6 @@ def test_get_ashp_defaults(self): view_response = json.loads(resp.content) self.assertNotIn("cooling_cf_reference", view_response.keys()) + self.assertEqual(view_response["sizing_factor"], 1.0) diff --git a/reoptjl/test/test_validator.py b/reoptjl/test/test_validator.py index 7f73fdc06..8e0c1c6d3 100644 --- a/reoptjl/test/test_validator.py +++ b/reoptjl/test/test_validator.py @@ -344,3 +344,30 @@ def boiler_validation(self): # self.assertAlmostEqual(len(validator.models["ExistingBoiler"].fuel_cost_per_mmbtu), 8760) # self.assertAlmostEqual(sum(validator.models["ExistingBoiler"].fuel_cost_per_mmbtu), 8760*0.5) + def ashp_validation(self): + """ + Ensure that bad inputs are caught by clean() for ASHP systems. + """ + post_file = os.path.join('job', 'test', 'posts', 'all_inputs_test.json') + post = json.load(open(post_file, 'r')) + + post["APIMeta"]["run_uuid"] = uuid.uuid4() + post["ASHPSpaceHeater"]["cooling_cf_reference"] = [] + + validator = InputValidator(post) + validator.clean_fields() + validator.clean() + validator.cross_clean() + assert("mismatched length" in validator.validation_errors["ASHPSpaceHeater"].keys()) + + post = json.load(open(post_file, 'r')) + post["APIMeta"]["run_uuid"] = uuid.uuid4() + post["ASHPWaterHeater"]["min_allowable_ton"] = 1000 + post["ASHPWaterHeater"]["min_allowable_peak_capacity_fraction"] = 0.9 + + validator = InputValidator(post) + validator.clean_fields() + validator.clean() + validator.cross_clean() + assert("bad inputs" in validator.validation_errors["ASHPWaterHeater"].keys()) + \ No newline at end of file diff --git a/reoptjl/urls.py b/reoptjl/urls.py index 71f0f9a97..ab70aa413 100644 --- a/reoptjl/urls.py +++ b/reoptjl/urls.py @@ -17,11 +17,14 @@ re_path(r'^user/(?P[0-9a-f-]+)/summary/?$', views.summary), re_path(r'^user/(?P[0-9a-f-]+)/summary_by_chunk/(?P[0-9]+)/?$', views.summary_by_chunk), re_path(r'^user/(?P[0-9a-f-]+)/unlink/(?P[0-9a-f-]+)/?$', views.unlink), + re_path(r'^user/(?P[0-9a-f-]+)/unlink_from_portfolio/(?P[0-9a-f-]+)/(?P[0-9a-f-]+)/?$', views.unlink_from_portfolio), re_path(r'^ghp_efficiency_thermal_factors/?$', views.ghp_efficiency_thermal_factors), re_path(r'^peak_load_outage_times/?$', views.peak_load_outage_times), re_path(r'^invalid_urdb/?$', reoviews.invalid_urdb), re_path(r'^schedule_stats/?$', reoviews.schedule_stats), re_path(r'^get_existing_chiller_default_cop/?$', views.get_existing_chiller_default_cop), re_path(r'^job/generate_custom_comparison_table/?$', views.generate_custom_comparison_table), - re_path(r'^get_ashp_defaults/?$', views.get_ashp_defaults) + re_path(r'^get_ashp_defaults/?$', views.get_ashp_defaults), + re_path(r'^summary_by_runuuids/?$', views.summary_by_runuuids), + re_path(r'^link_run_to_portfolios/?$', views.link_run_uuids_to_portfolio_uuid) ] diff --git a/reoptjl/views.py b/reoptjl/views.py index 98a7ce0cc..c600f5aa4 100644 --- a/reoptjl/views.py +++ b/reoptjl/views.py @@ -1,5 +1,6 @@ # REoptĀ®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt_API/blob/master/LICENSE. from django.db import models +from django.db.models import Q import uuid from typing import List, Dict, Any import sys @@ -16,7 +17,8 @@ ColdThermalStorageInputs, ColdThermalStorageOutputs, AbsorptionChillerInputs, AbsorptionChillerOutputs,\ FinancialInputs, FinancialOutputs, UserUnlinkedRuns, BoilerInputs, BoilerOutputs, SteamTurbineInputs, \ SteamTurbineOutputs, GHPInputs, GHPOutputs, ProcessHeatLoadInputs, ElectricHeaterInputs, ElectricHeaterOutputs, \ - ASHPSpaceHeaterInputs, ASHPSpaceHeaterOutputs, ASHPWaterHeaterInputs, ASHPWaterHeaterOutputs + ASHPSpaceHeaterInputs, ASHPSpaceHeaterOutputs, ASHPWaterHeaterInputs, ASHPWaterHeaterOutputs, PortfolioUnlinkedRuns + import os import requests import numpy as np @@ -481,7 +483,15 @@ def absorption_chiller_defaults(request): return JsonResponse({"Error": "Unexpected error in absorption_chiller_defaults endpoint. Check log for more."}, status=500) def get_ashp_defaults(request): - inputs = {"load_served": request.GET.get("load_served")} + inputs = {} + if request.GET.get("load_served") not in [None, "", []]: + inputs["load_served"] = request.GET.get("load_served") + else: + return JsonResponse({"Error: Missing input load_served in get_ashp_defualts endpoint."}, status=400) + if request.GET.get("force_into_system") not in [None, "", []]: + inputs["force_into_system"] = request.GET.get("force_into_system") + else: + return JsonResponse({"Error: Missing input force_into_system in get_ashp_defualts endpoint."}, status=400) try: julia_host = os.environ.get('JULIA_HOST', "julia") http_jl_response = requests.get("http://" + julia_host + ":8081/get_ashp_defaults/", json=inputs) @@ -685,7 +695,151 @@ def get_existing_chiller_default_cop(request): log.debug(debug_msg) return JsonResponse({"Error": "Unexpected error in get_existing_chiller_default_cop endpoint. Check log for more."}, status=500) + +# Inputs: 1-many run_uuid strings as single comma separated array +# Output: list of JSON summaries +# This function will query requested UUIDs and return their summary back to requestor +def summary_by_runuuids(request): + + run_uuids = json.loads(request.body)['run_uuids'] + + if len(run_uuids) == 0: + return JsonResponse({'Error': 'Must provide one or more run_uuids'}, status=400) + + # Validate that user UUID is valid. + for r_uuid in run_uuids: + + if type(r_uuid) != str: + return JsonResponse({'Error': 'Provided run_uuids type error, must be string. ' + str(r_uuid)}, status=400) + + try: + uuid.UUID(r_uuid) # raises ValueError if not valid uuid + + except ValueError as e: + if e.args[0] == "badly formed hexadecimal UUID string": + return JsonResponse({"Error": str(e.message)}, status=404) + else: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='summary_by_runuuids', run_uuids=run_uuids) + err.save_to_db() + return JsonResponse({"Error": str(err.message)}, status=404) + try: + # Dictionary to store all results. Primary key = run_uuid and secondary key = data values from each uuid + summary_dict = dict() + + # Create Querysets: Select all objects associate with a user_uuid, Order by `created` column + scenarios = APIMeta.objects.filter(run_uuid__in=run_uuids).only( + 'run_uuid', + 'status', + 'created' + ).order_by("-created") + + if len(scenarios) > 0: # this should be either 0 or 1 as there are no duplicate run_uuids + + # Get summary information for all selected scenarios + summary_dict = queryset_for_summary(scenarios, summary_dict) + + # Create eventual response dictionary + return_dict = dict() + # return_dict['user_uuid'] = user_uuid # no user uuid + scenario_summaries = [] + for k in summary_dict.keys(): + scenario_summaries.append(summary_dict[k]) + + return_dict['scenarios'] = scenario_summaries + + response = JsonResponse(return_dict, status=200, safe=False) + return response + else: + response = JsonResponse({"Error": "No scenarios found for run_uuids '{}'".format(run_uuids)}, content_type='application/json', status=404) + return response + + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='summary_by_runuuids', run_uuids=run_uuids) + err.save_to_db() + return JsonResponse({"Error": err.message}, status=404) + +# Inputs: 1-many run_uuid strings as single comma separated array +# Inputs: 1-many portfolio_uuid strings as single comma separated array +# Output: 200 or OK +# This function link independent run_uuids to a portfolio_uuid. The portfolio ID doesnt have to exit, run_uuids must exist in DB. +def link_run_uuids_to_portfolio_uuid(request): + + request_body = json.loads(request.body) + run_uuids = request_body['run_uuids'] + por_uuids = request_body['portfolio_uuids'] + + if len(run_uuids) != len(por_uuids): + return JsonResponse({'Error': 'Must provide one or more run_uuids and the same number of portfolio_uuids'}, status=400) + + # Validate that all UUIDs are valid. + for r_uuid in run_uuids+por_uuids: + + if type(r_uuid) != str: + return JsonResponse({'Error': 'Provided uuid type error, must be string. ' + str(r_uuid)}, status=400) + + try: + uuid.UUID(r_uuid) # raises ValueError if not valid uuid + + except ValueError as e: + if e.args[0] == "badly formed hexadecimal UUID string": + return JsonResponse({"Error": str(e.message)}, status=404) + else: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='summary_by_runuuids', run_uuids=run_uuids) + err.save_to_db() + return JsonResponse({"Error": str(err.message)}, status=404) + + try: + + for r_uuid,p_uuid in zip(run_uuids, por_uuids): + + # Create Querysets: Select all objects associate with a user_uuid, Order by `created` column + scenario = APIMeta.objects.filter(run_uuid=r_uuid).only( + 'run_uuid', + 'portfolio_uuid' + ) + + if len(scenario) > 0: + + for s in scenario: + s.portfolio_uuid = p_uuid + s.save() + + # Existing portfolio runs could have been "unlinked" from portfolio + # so they are independent and show up in summary endpoint. If these runs + # are re-linked with a portfolio, their portfolio_id is updated above. + # BUT these runs could still show up under `Summary` results (niche case) + # because they are present in PortfolioUnlinkedRuns. + # Below, these runs are removed from PortfolioUnlinkedRuns + # so they are "linked" to a portfolio and do not show up under `Summary` + if PortfolioUnlinkedRuns.objects.filter(run_uuid=r_uuid).exists(): + obj = PortfolioUnlinkedRuns.objects.get(run_uuid=r_uuid) + obj.delete() + resp_str = ' and deleted run entry from PortfolioUnlinkedRuns' + else: + resp_str = '' + else: + # Stop processing on first bad run_uuid + response = JsonResponse({"Error": "No scenarios found for run_uuid '{}'".format(r_uuid)}, content_type='application/json', status=500) + return response + + response = JsonResponse({"Success": "All runs associated with given portfolios'{}'".format(resp_str)}, status=200, safe=False) + return response + + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='summary_by_runuuids', run_uuids=run_uuids) + err.save_to_db() + return JsonResponse({"Error": err.message}, status=404) + +# Inputs: 1 user_uuid +# Output: Return summary information for all runs associated with the user +# Output: Portfolio_uuid for returned runs must be "" (empty) or in unlinked portfolio runs (i.e. user unlinked a run from a portforlio) +# Output: Remove any user unlinked runs and finally order by `created` column +# Returns all user runs not actively tied to a portfolio def summary(request, user_uuid): """ Retrieve a summary of scenarios for given user_uuid @@ -734,16 +888,21 @@ def summary(request, user_uuid): # Dictionary to store all results. Primary key = run_uuid and secondary key = data values from each uuid summary_dict = dict() - # Create Querysets: Select all objects associate with a user_uuid, Order by `created` column - scenarios = APIMeta.objects.filter(user_uuid=user_uuid).only( + # Create Querysets: Select all objects associate with a user_uuid. portfolio_uuid must be "" (empty) or in unlinked portfolio runs + # Remove any unlinked runs and finally order by `created` column + api_metas = APIMeta.objects.filter( + Q(user_uuid=user_uuid), + Q(portfolio_uuid = "") | Q(run_uuid__in=[i.run_uuid for i in PortfolioUnlinkedRuns.objects.filter(user_uuid=user_uuid)]) + ).exclude( + run_uuid__in=[i.run_uuid for i in UserUnlinkedRuns.objects.filter(user_uuid=user_uuid)] + ).only( 'run_uuid', + 'user_uuid', + 'portfolio_uuid', 'status', 'created' ).order_by("-created") - unlinked_run_uuids = [i.run_uuid for i in UserUnlinkedRuns.objects.filter(user_uuid=user_uuid)] - api_metas = [s for s in scenarios if s.run_uuid not in unlinked_run_uuids] - if len(api_metas) > 0: summary_dict = queryset_for_summary(api_metas, summary_dict) response = JsonResponse(create_summary_dict(user_uuid,summary_dict), status=200, safe=False) @@ -758,7 +917,120 @@ def summary(request, user_uuid): err.save_to_db() return JsonResponse({"Error": err.message}, status=404) -# Query all django models for all run_uuids found for given user_uuid +# Same as Summary but by chunks +def summary_by_chunk(request, user_uuid, chunk): + + # Dictionary to store all results. Primary key = run_uuid and secondary key = data values from each uuid + summary_dict = dict() + + try: + uuid.UUID(user_uuid) # raises ValueError if not valid uuid + + except ValueError as e: + if e.args[0] == "badly formed hexadecimal UUID string": + return JsonResponse({"Error": str(e.message)}, status=404) + else: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='summary', user_uuid=user_uuid) + err.save_to_db() + return JsonResponse({"Error": str(err.message)}, status=404) + + try: + try: + # chunk size is an optional URL parameter which defines the number of chunks into which to + # divide all user summary results. It must be a positive integer. + default_chunk_size = 30 + chunk_size = int(request.GET.get('chunk_size') or default_chunk_size) + if chunk_size != float(request.GET.get('chunk_size') or default_chunk_size): + return JsonResponse({"Error": "Chunk size must be an integer."}, status=400) + except: + return JsonResponse({"Error": "Chunk size must be a positive integer."}, status=400) + + try: + # chunk is the 1-indexed indice of the chunks for which to return results. + # chunk is a mandatory input from URL, different from chunk_size. + # It must be a positive integer. + chunk = int(chunk) + if chunk < 1: + response = JsonResponse({"Error": "Chunks are 1-indexed, please provide a chunk index greater than or equal to 1"} + , content_type='application/json', status=400) + return response + except: + return JsonResponse({"Error": "Chunk number must be a 1-indexed integer."}, status=400) + + # Create Querysets: Select all objects associate with a user_uuid, portfolio_uuid="", Order by `created` column + api_metas = APIMeta.objects.filter( + Q(user_uuid=user_uuid), + Q(portfolio_uuid = "") | Q(run_uuid__in=[i.run_uuid for i in PortfolioUnlinkedRuns.objects.filter(user_uuid=user_uuid)]) + ).exclude( + run_uuid__in=[i.run_uuid for i in UserUnlinkedRuns.objects.filter(user_uuid=user_uuid)] + ).only( + 'run_uuid', + 'status', + 'created' + ).order_by("-created") + + total_scenarios = len(api_metas) + if total_scenarios == 0: + response = JsonResponse({"Error": "No scenarios found for user '{}'".format(user_uuid)}, content_type='application/json', status=404) + return response + + # Determine total number of chunks from current query of user results based on the chunk size + total_chunks = total_scenarios/float(chunk_size) + # If the last chunk is only patially full, i.e. there is a remainder, then add 1 so when it + # is converted to an integer the result will reflect the true total number of chunks + if total_chunks%1 > 0: + total_chunks = total_chunks + 1 + # Make sure total chunks is an integer + total_chunks = int(total_chunks) + + # Catch cases where user queries for a chunk that is more than the total chunks for the user + if chunk > total_chunks: + response = JsonResponse({"Error": "Chunk index {} is greater than the total number of chunks ({}) at a chunk size of {}".format( + chunk, total_chunks, chunk_size)}, content_type='application/json', status=400) + return response + + # Filter scenarios to the chunk + start_idx = max((chunk-1) * chunk_size, 0) + end_idx = min(chunk * chunk_size, total_scenarios) + api_metas_by_chunk = api_metas[start_idx: end_idx] + + summary_dict = queryset_for_summary(api_metas_by_chunk, summary_dict) + response = JsonResponse(create_summary_dict(user_uuid,summary_dict), status=200, safe=False) + return response + + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='summary', user_uuid=user_uuid) + err.save_to_db() + return JsonResponse({"Error": err.message}, status=404) + +# Take summary_dict and convert it to the desired format for response. Also add any missing key/val pairs +def create_summary_dict(user_uuid:str,summary_dict:dict): + + # if these keys are missing from a `scenario` we add 0s for them, all Floats. + optional_keys = ["npv_us_dollars", "net_capital_costs", "year_one_savings_us_dollars", "pv_kw", "wind_kw", "gen_kw", "batt_kw", "batt_kwh"] + + # Create eventual response dictionary + return_dict = dict() + return_dict['user_uuid'] = user_uuid + scenario_summaries = [] + for k in summary_dict.keys(): + + d = summary_dict[k] + + # for opt_key in optional_keys: + # if opt_key not in d.keys(): + # d[opt_key] = 0.0 + + scenario_summaries.append(d) + + return_dict['scenarios'] = scenario_summaries + + return return_dict + +# Query all django models for 1 or more run_uuids provided in inputs +# Return summary_dict which contains summary information for valid run_uuids def queryset_for_summary(api_metas,summary_dict:dict): # Loop over all the APIMetas associated with a user_uuid, do something if needed @@ -767,6 +1039,8 @@ def queryset_for_summary(api_metas,summary_dict:dict): summary_dict[str(m.run_uuid)] = dict() summary_dict[str(m.run_uuid)]['status'] = m.status summary_dict[str(m.run_uuid)]['run_uuid'] = str(m.run_uuid) + summary_dict[str(m.run_uuid)]['user_uuid'] = str(m.user_uuid) + summary_dict[str(m.run_uuid)]['portfolio_uuid'] = str(m.portfolio_uuid) summary_dict[str(m.run_uuid)]['created'] = str(m.created) run_uuids = summary_dict.keys() @@ -786,24 +1060,69 @@ def queryset_for_summary(api_metas,summary_dict:dict): utility = ElectricUtilityInputs.objects.filter(meta__run_uuid__in=run_uuids).only( 'meta__run_uuid', 'outage_start_time_step', + 'outage_end_time_step', + 'outage_durations', 'outage_start_time_steps' ) if len(utility) > 0: for m in utility: - if len(m.outage_start_time_steps) == 0: - summary_dict[str(m.meta.run_uuid)]['focus'] = "Financial" + + if m.outage_start_time_step is None: + if len(m.outage_start_time_steps) == 0: + summary_dict[str(m.meta.run_uuid)]['focus'] = "Financial" + else: + summary_dict[str(m.meta.run_uuid)]['focus'] = "Resilience" + summary_dict[str(m.meta.run_uuid)]['outage_duration'] = m.outage_durations[0] # all durations are same. else: + # outage start timestep was provided, is 1 or more + summary_dict[str(m.meta.run_uuid)]['outage_duration'] = m.outage_end_time_step - m.outage_start_time_step + 1 summary_dict[str(m.meta.run_uuid)]['focus'] = "Resilience" + + site = SiteOutputs.objects.filter(meta__run_uuid__in=run_uuids).only( + 'meta__run_uuid', + 'lifecycle_emissions_reduction_CO2_fraction' + ) + if len(site) > 0: + for m in site: + try: + summary_dict[str(m.meta.run_uuid)]['emission_reduction_pct'] = m.lifecycle_emissions_reduction_CO2_fraction + except: + summary_dict[str(m.meta.run_uuid)]['emission_reduction_pct'] = 0.0 + + + site_inputs = SiteInputs.objects.filter(meta__run_uuid__in=run_uuids).only( + 'meta__run_uuid', + 'renewable_electricity_min_fraction', + 'renewable_electricity_max_fraction' + ) + if len(site_inputs) > 0: + for m in site_inputs: + try: # can be NoneType + if m.renewable_electricity_min_fraction > 0: + summary_dict[str(m.meta.run_uuid)]['focus'] = "Clean-energy" + except: + pass # is NoneType + + try: # can be NoneType + if m.renewable_electricity_max_fraction > 0: + summary_dict[str(m.meta.run_uuid)]['focus'] = "Clean-energy" + except: + pass # is NoneType # Use settings to find out if it is an off-grid evaluation settings = Settings.objects.filter(meta__run_uuid__in=run_uuids).only( 'meta__run_uuid', - 'off_grid_flag' + 'off_grid_flag', + 'include_climate_in_objective', + 'include_health_in_objective' ) if len(settings) > 0: for m in settings: if m.off_grid_flag: summary_dict[str(m.meta.run_uuid)]['focus'] = "Off-grid" + + if m.include_climate_in_objective or m.include_health_in_objective: + summary_dict[str(m.meta.run_uuid)]['focus'] = "Clean-energy" tariffInputs = ElectricTariffInputs.objects.filter(meta__run_uuid__in=run_uuids).only( 'meta__run_uuid', @@ -850,7 +1169,13 @@ def queryset_for_summary(api_metas,summary_dict:dict): fin = FinancialOutputs.objects.filter(meta__run_uuid__in=run_uuids).only( 'meta__run_uuid', 'npv', - 'initial_capital_costs_after_incentives' + 'initial_capital_costs_after_incentives', + 'lcc', + 'replacements_present_cost_after_tax', + 'lifecycle_capital_costs_plus_om_after_tax', + 'lifecycle_generation_tech_capital_costs', + 'lifecycle_storage_capital_costs', + 'lifecycle_production_incentive_after_tax' ) if len(fin) > 0: for m in fin: @@ -859,7 +1184,11 @@ def queryset_for_summary(api_metas,summary_dict:dict): else: summary_dict[str(m.meta.run_uuid)]['npv_us_dollars'] = None summary_dict[str(m.meta.run_uuid)]['net_capital_costs'] = m.initial_capital_costs_after_incentives - + summary_dict[str(m.meta.run_uuid)]['lcc_us_dollars'] = m.lcc + summary_dict[str(m.meta.run_uuid)]['replacements_present_cost_after_tax'] = m.replacements_present_cost_after_tax + summary_dict[str(m.meta.run_uuid)]['lifecycle_capital_costs_plus_om_after_tax'] = m.lifecycle_capital_costs_plus_om_after_tax + summary_dict[str(m.meta.run_uuid)]['total_capital_costs'] = m.lifecycle_generation_tech_capital_costs + m.lifecycle_storage_capital_costs - m.lifecycle_production_incentive_after_tax + batt = ElectricStorageOutputs.objects.filter(meta__run_uuid__in=run_uuids).only( 'meta__run_uuid', 'size_kw', @@ -956,120 +1285,36 @@ def queryset_for_summary(api_metas,summary_dict:dict): if len(ashpWaterHeater) > 0: for m in ashpWaterHeater: summary_dict[str(m.meta.run_uuid)]['ASHPWater_heater_ton'] = m.size_ton - - return summary_dict - -def summary_by_chunk(request, user_uuid, chunk): - - # Dictionary to store all results. Primary key = run_uuid and secondary key = data values from each uuid - summary_dict = dict() - - try: - uuid.UUID(user_uuid) # raises ValueError if not valid uuid - - except ValueError as e: - if e.args[0] == "badly formed hexadecimal UUID string": - return JsonResponse({"Error": str(e.message)}, status=404) - else: - exc_type, exc_value, exc_traceback = sys.exc_info() - err = UnexpectedError(exc_type, exc_value, exc_traceback, task='summary', user_uuid=user_uuid) - err.save_to_db() - return JsonResponse({"Error": str(err.message)}, status=404) - - try: - try: - # chunk size is an optional URL parameter which defines the number of chunks into which to - # divide all user summary results. It must be a positive integer. - default_chunk_size = 30 - chunk_size = int(request.GET.get('chunk_size') or default_chunk_size) - if chunk_size != float(request.GET.get('chunk_size') or default_chunk_size): - return JsonResponse({"Error": "Chunk size must be an integer."}, status=400) - except: - return JsonResponse({"Error": "Chunk size must be a positive integer."}, status=400) - - try: - # chunk is the 1-indexed indice of the chunks for which to return results. - # chunk is a mandatory input from URL, different from chunk_size. - # It must be a positive integer. - chunk = int(chunk) - if chunk < 1: - response = JsonResponse({"Error": "Chunks are 1-indexed, please provide a chunk index greater than or equal to 1"} - , content_type='application/json', status=400) - return response - except: - return JsonResponse({"Error": "Chunk number must be a 1-indexed integer."}, status=400) - - # Create Querysets: Select all objects associate with a user_uuid, Order by `created` column - scenarios = APIMeta.objects.filter(user_uuid=user_uuid).only( - 'run_uuid', - 'status', - 'created' - ).order_by("-created") - - unlinked_run_uuids = [i.run_uuid for i in UserUnlinkedRuns.objects.filter(user_uuid=user_uuid)] - api_metas = [s for s in scenarios if s.run_uuid not in unlinked_run_uuids] - - total_scenarios = len(api_metas) - if total_scenarios == 0: - response = JsonResponse({"Error": "No scenarios found for user '{}'".format(user_uuid)}, content_type='application/json', status=404) - return response - - # Determine total number of chunks from current query of user results based on the chunk size - total_chunks = total_scenarios/float(chunk_size) - # If the last chunk is only patially full, i.e. there is a remainder, then add 1 so when it - # is converted to an integer the result will reflect the true total number of chunks - if total_chunks%1 > 0: - total_chunks = total_chunks + 1 - # Make sure total chunks is an integer - total_chunks = int(total_chunks) - - # Catch cases where user queries for a chunk that is more than the total chunks for the user - if chunk > total_chunks: - response = JsonResponse({"Error": "Chunk index {} is greater than the total number of chunks ({}) at a chunk size of {}".format( - chunk, total_chunks, chunk_size)}, content_type='application/json', status=400) - return response - - # Filter scenarios to the chunk - start_idx = max((chunk-1) * chunk_size, 0) - end_idx = min(chunk * chunk_size, total_scenarios) - api_metas_by_chunk = api_metas[start_idx: end_idx] - - summary_dict = queryset_for_summary(api_metas_by_chunk, summary_dict) - response = JsonResponse(create_summary_dict(user_uuid,summary_dict), status=200, safe=False) - return response - - except Exception as e: - exc_type, exc_value, exc_traceback = sys.exc_info() - err = UnexpectedError(exc_type, exc_value, exc_traceback, task='summary', user_uuid=user_uuid) - err.save_to_db() - return JsonResponse({"Error": err.message}, status=404) - -# Take summary_dict and convert it to the desired format for response. Also add any missing key/val pairs -def create_summary_dict(user_uuid:str,summary_dict:dict): - - # if these keys are missing from a `scenario` we add 0s for them, all Floats. - optional_keys = ["npv_us_dollars", "net_capital_costs", "year_one_savings_us_dollars", "pv_kw", "wind_kw", "gen_kw", "batt_kw", "batt_kwh"] - - # Create eventual response dictionary - return_dict = dict() - return_dict['user_uuid'] = user_uuid - scenario_summaries = [] - for k in summary_dict.keys(): - - d = summary_dict[k] - - # for opt_key in optional_keys: - # if opt_key not in d.keys(): - # d[opt_key] = 0.0 - - scenario_summaries.append(d) + hottes = HotThermalStorageOutputs.objects.filter(meta__run_uuid__in=run_uuids).only( + 'meta__run_uuid', + 'size_gal' + ) + if len(hottes) > 0: + for m in hottes: + summary_dict[str(m.meta.run_uuid)]['hottes_gal'] = m.size_gal - return_dict['scenarios'] = scenario_summaries + coldtes = ColdThermalStorageOutputs.objects.filter(meta__run_uuid__in=run_uuids).only( + 'meta__run_uuid', + 'size_gal' + ) + if len(coldtes) > 0: + for m in coldtes: + summary_dict[str(m.meta.run_uuid)]['coldtes_gal'] = m.size_gal - return return_dict + abschillTon = AbsorptionChillerOutputs.objects.filter(meta__run_uuid__in=run_uuids).only( + 'meta__run_uuid', + 'size_ton' + ) + if len(abschillTon) > 0: + for m in abschillTon: + summary_dict[str(m.meta.run_uuid)]['absorpchl_ton'] = m.size_ton + + return summary_dict -# Unlink a user_uuid from a run_uuid. +# Inputs: user_uuid and run_uuid to unlink from the user +# Outputs: 200 or OK +# add an entry to the PortfolioUnlinkedRuns for the given portfolio_uuid and run_uuid, indicating they have been unlinked def unlink(request, user_uuid, run_uuid): """ @@ -1116,6 +1361,62 @@ def unlink(request, user_uuid, run_uuid): err.save_to_db() return JsonResponse({"Error": err.message}, status=404) +# Inputs: user_uuid, portfolio_uuid, and run_uuid to unlink from the portfolio +# Outputs: 200 or OK +# add an entry to the PortfolioUnlinkedRuns for the given portfolio_uuid and run_uuid, indicating they have been unlinked +def unlink_from_portfolio(request, user_uuid, portfolio_uuid, run_uuid): + + """ + add an entry to the PortfolioUnlinkedRuns for the given portfolio_uuid and run_uuid + """ + content = {'user_uuid': user_uuid, 'portfolio_uuid': portfolio_uuid, 'run_uuid': run_uuid} + for name, check_id in content.items(): + try: + uuid.UUID(check_id) # raises ValueError if not valid uuid + except ValueError as e: + if e.args[0] == "badly formed hexadecimal UUID string": + return JsonResponse({"Error": "{} {}".format(name, e.args[0]) }, status=400) + else: + exc_type, exc_value, exc_traceback = sys.exc_info() + if name == 'user_uuid': + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='unlink', user_uuid=check_id) + if name == 'portfolio_uuid': + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='unlink', portfolio_uuid=check_id) + if name == 'run_uuid': + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='unlink', run_uuid=check_id) + err.save_to_db() + return JsonResponse({"Error": str(err.message)}, status=400) + + try: + if not APIMeta.objects.filter(portfolio_uuid=portfolio_uuid).exists(): + return JsonResponse({"Error": "Portfolio {} does not exist".format(portfolio_uuid)}, status=400) + + + runs = APIMeta.objects.filter(run_uuid=run_uuid) + if len(runs) == 0: + return JsonResponse({"Error": "Run {} does not exist".format(run_uuid)}, status=400) + else: + if runs[0].portfolio_uuid != portfolio_uuid: + return JsonResponse({"Error": "Run {} is not associated with portfolio {}".format(run_uuid, portfolio_uuid)}, status=400) + elif runs[0].user_uuid != user_uuid: + return JsonResponse({"Error": "Run {} is not associated with user {}".format(run_uuid, user_uuid)}, status=400) + else: + pass + + # Run exists and is tied to porfolio provided in request, hence unlink now. + if not PortfolioUnlinkedRuns.objects.filter(run_uuid=run_uuid).exists(): + PortfolioUnlinkedRuns.create(**content) + return JsonResponse({"Success": "run_uuid {} unlinked from portfolio_uuid {}".format(run_uuid, portfolio_uuid)}, + status=201) + else: + return JsonResponse({"Nothing changed": "run_uuid {} is already unlinked from portfolio_uuid {}".format(run_uuid, portfolio_uuid)}, + status=208) + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='unlink', portfolio_uuid=portfolio_uuid) + err.save_to_db() + return JsonResponse({"Error": err.message}, status=404) + def avert_emissions_profile(request): try: inputs = { diff --git a/resilience_stats/migrations/0016_alter_erpmeta_reopt_version.py b/resilience_stats/migrations/0016_alter_erpmeta_reopt_version.py index 2941eaab9..002f175fa 100644 --- a/resilience_stats/migrations/0016_alter_erpmeta_reopt_version.py +++ b/resilience_stats/migrations/0016_alter_erpmeta_reopt_version.py @@ -1,4 +1,4 @@ -# Generated by Django 4.0.7 on 2024-09-04 21:37 +# Generated by Django 4.0.7 on 2024-08-20 16:09 from django.db import migrations, models