diff --git a/julia_src/Manifest.toml b/julia_src/Manifest.toml index f5919635b..4ddbe58fb 100644 --- a/julia_src/Manifest.toml +++ b/julia_src/Manifest.toml @@ -966,7 +966,9 @@ version = "1.11.0" [[deps.REopt]] deps = ["ArchGDAL", "CSV", "CoolProp", "DataFrames", "Dates", "DelimitedFiles", "HTTP", "JLD", "JSON", "JuMP", "LinDistFlow", "LinearAlgebra", "Logging", "MathOptInterface", "Requires", "Roots", "Statistics", "TestEnv"] -git-tree-sha1 = "57012c263c02d4d1362b4e248accbe3974e17c74" +git-tree-sha1 = "a1e6fee498f2b4d4c35f45e35dce3063aaee6139" +repo-rev = "electric_storage_size_class" +repo-url = "https://github.com/NatLabRockies/REopt.jl.git" uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6" version = "0.61.1" diff --git a/julia_src/http.jl b/julia_src/http.jl index 3f352618d..cadf25c59 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -278,7 +278,7 @@ function reopt(req::HTTP.Request) end if haskey(d, "ElectricStorage") inputs_with_defaults_from_julia_electric_storage = [ - :macrs_option_years, :macrs_bonus_fraction, :total_itc_fraction, :internal_efficiency_fraction + :macrs_option_years, :macrs_bonus_fraction, :total_itc_fraction, :internal_efficiency_fraction, :size_class, :installed_cost_per_kw, :installed_cost_per_kwh, :installed_cost_constant ] electric_storage_dict = Dict(key=>getfield(model_inputs.s.storage.attr["ElectricStorage"], key) for key in inputs_with_defaults_from_julia_electric_storage) else @@ -875,6 +875,51 @@ function pv_cost_defaults(req::HTTP.Request) end +function electric_storage_cost_defaults(req::HTTP.Request) + d = JSON.parse(String(req.body)) + float_vals = ["installed_cost_per_kw", "installed_cost_per_kwh", + "installed_cost_constant", "min_kw", "max_kw", + "electric_load_annual_peak", "electric_load_average"] + int_vals = ["size_class"] + string_vals = [] + bool_vals = [] + all_vals = vcat(int_vals, string_vals, float_vals, bool_vals) + # Process .json inputs and convert to correct type if needed + for k in all_vals + if !isnothing(get(d, k, nothing)) + # TODO improve this by checking if the type is not the expected type, as opposed to just not string + if k in float_vals && typeof(d[k]) == String + d[k] = parse(Float64, d[k]) + elseif k in int_vals && typeof(d[k]) == String + d[k] = parse(Int64, d[k]) + elseif k in bool_vals && typeof(d[k]) == String + d[k] = parse(Bool, d[k]) + end + end + end + + @info "Getting ElectricStorage cost defaults..." + data = Dict() + error_response = Dict() + try + data["installed_cost_per_kw"], data["installed_cost_per_kwh"], data["installed_cost_constant"], data["size_class"], data["size_kw_for_size_class"], data["electric_load_based_sizing_kw"], data["size_class_bounds_kw"] = reoptjl.get_electric_storage_cost_params(; + (Symbol(k) => v for (k, v) in pairs(d))... + ) + catch e + @error "Something went wrong in the electric_storage_cost_defaults" exception=(e, catch_backtrace()) + error_response["error"] = sprint(showerror, e) + end + if isempty(error_response) + @info "ElectricStorage cost defaults determined." + response = data + return HTTP.Response(200, JSON.json(response)) + else + @info "An error occurred in the electric_storage_cost_defaults endpoint" + return HTTP.Response(500, JSON.json(error_response)) + end +end + + function job_no_xpress(req::HTTP.Request) error_response = Dict("error" => "V1 and V2 not available without Xpress installation.") return HTTP.Response(500, JSON.json(error_response)) @@ -965,5 +1010,6 @@ HTTP.register!(ROUTER, "GET", "/health", health) HTTP.register!(ROUTER, "GET", "/get_existing_chiller_default_cop", get_existing_chiller_default_cop) HTTP.register!(ROUTER, "GET", "/get_ashp_defaults", get_ashp_defaults) HTTP.register!(ROUTER, "GET", "/pv_cost_defaults", pv_cost_defaults) +HTTP.register!(ROUTER, "GET", "/electric_storage_cost_defaults", electric_storage_cost_defaults) HTTP.register!(ROUTER, "GET", "/get_load_metrics", get_load_metrics) HTTP.serve(ROUTER, "0.0.0.0", 8081, reuseaddr=true) diff --git a/reoptjl/migrations/0126_electricstorageinputs_size_class_and_more.py b/reoptjl/migrations/0126_electricstorageinputs_size_class_and_more.py new file mode 100644 index 000000000..ba50fb8d5 --- /dev/null +++ b/reoptjl/migrations/0126_electricstorageinputs_size_class_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.26 on 2026-09-10 17:12 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0125_chpoutputs_annual_thermal_curtailed_mmbtu_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='electricstorageinputs', + name='size_class', + field=models.IntegerField(blank=True, help_text='ElectricStorage size class. Must be an integer value between 1 and 3. Default is calculated per ratio of annual peak and average load of given load profile.', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]), + ), + migrations.AlterField( + model_name='electricstorageinputs', + name='installed_cost_constant', + field=models.FloatField(blank=True, help_text='Fixed upfront cost for battery installation, independent of size.', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000.0)]), + ), + migrations.AlterField( + model_name='electricstorageinputs', + name='installed_cost_per_kw', + field=models.FloatField(blank=True, help_text='Total upfront battery power capacity costs (e.g. inverter and balance of power systems)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10000.0)]), + ), + migrations.AlterField( + model_name='electricstorageinputs', + name='installed_cost_per_kwh', + field=models.FloatField(blank=True, help_text='Total upfront battery costs', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10000.0)]), + ), + ] diff --git a/reoptjl/migrations/0127_alter_electricstorageinputs_om_cost_fraction_of_installed_cost_and_more.py b/reoptjl/migrations/0127_alter_electricstorageinputs_om_cost_fraction_of_installed_cost_and_more.py new file mode 100644 index 000000000..9a539aa62 --- /dev/null +++ b/reoptjl/migrations/0127_alter_electricstorageinputs_om_cost_fraction_of_installed_cost_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.26 on 2026-09-16 16:54 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0126_electricstorageinputs_size_class_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='electricstorageinputs', + name='om_cost_fraction_of_installed_cost', + field=models.FloatField(blank=True, default=0.04, help_text='Annual O&M cost as a fraction of installed cost.', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1.0)]), + ), + migrations.AlterField( + model_name='electricstorageinputs', + name='size_class', + field=models.IntegerField(blank=True, help_text='ElectricStorage size class. Must be an integer value between 1 and 4. Default is calculated per ratio of annual peak and average load of given load profile.', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4)]), + ), + ] diff --git a/reoptjl/migrations/0128_alter_windinputs_om_cost_per_kw.py b/reoptjl/migrations/0128_alter_windinputs_om_cost_per_kw.py new file mode 100644 index 000000000..824b085f2 --- /dev/null +++ b/reoptjl/migrations/0128_alter_windinputs_om_cost_per_kw.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.26 on 2026-09-21 16:34 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0127_alter_electricstorageinputs_om_cost_fraction_of_installed_cost_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='windinputs', + name='om_cost_per_kw', + field=models.FloatField(blank=True, default=43, help_text='Annual operations and maintenance costs in $/kW', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000.0)]), + ), + ] diff --git a/reoptjl/models.py b/reoptjl/models.py index 32f69e66d..33900daf0 100644 --- a/reoptjl/models.py +++ b/reoptjl/models.py @@ -76,10 +76,10 @@ class MACRS_YEARS_CHOICES(models.IntegerChoices): } WIND_COST_DEFAULTS = { # size_class_to_installed_cost - "residential" : 7692.0, - "commercial" : 5776.0, - "medium" : 3807.0, - "large" : 2896.0 + "residential" : 8960.0, + "commercial" : 6782.0, + "medium" : 4368.0, + "large" : 3477.0 } def at_least_one_set(model, possible_sets): @@ -3411,7 +3411,7 @@ class WIND_SIZE_CLASS_CHOICES(models.TextChoices): help_text="Installed cost in $/kW. Default cost is determined based on size_class." ) om_cost_per_kw = models.FloatField( - default=42, + default=43, validators=[ MinValueValidator(0), MaxValueValidator(1.0e3) @@ -3668,6 +3668,15 @@ class ElectricStorageInputs(BaseModel, models.Model): primary_key=True ) + size_class = models.IntegerField( + validators=[ + MinValueValidator(1), + MaxValueValidator(4) + ], + null=True, + blank=True, + help_text="ElectricStorage size class. Must be an integer value between 1 and 4. Default is calculated per ratio of annual peak and average load of given load profile." + ) ELECTRICSTORAGE_DISPATCH_STRATEGY = models.TextChoices('ELECTRICSTORAGE_DISPATCH_STRATEGY', ( "optimized", "peak_shaving_look_ahead", @@ -3812,29 +3821,29 @@ class ElectricStorageInputs(BaseModel, models.Model): "that energy at the export_rate_beyond_net_metering_limit).") ) installed_cost_per_kw = models.FloatField( - default=968.0, validators=[ MinValueValidator(0), MaxValueValidator(1.0e4) ], + null=True, blank=True, help_text="Total upfront battery power capacity costs (e.g. inverter and balance of power systems)" ) installed_cost_per_kwh = models.FloatField( - default=253.0, validators=[ MinValueValidator(0), MaxValueValidator(1.0e4) ], + null=True, blank=True, help_text="Total upfront battery costs" ) installed_cost_constant = models.FloatField( - default=222115.0, validators=[ MinValueValidator(0), MaxValueValidator(1.0e9) ], + null=True, blank=True, help_text="Fixed upfront cost for battery installation, independent of size." ) @@ -3893,7 +3902,7 @@ class ElectricStorageInputs(BaseModel, models.Model): help_text="Number of years from start of analysis period to apply replace_cost_constant." ) om_cost_fraction_of_installed_cost = models.FloatField( - default=0.025, + default=0.04, validators=[ MinValueValidator(0), MaxValueValidator(1.0) diff --git a/reoptjl/test/test_http_endpoints.py b/reoptjl/test/test_http_endpoints.py index 07a70ae56..db3597a3f 100644 --- a/reoptjl/test/test_http_endpoints.py +++ b/reoptjl/test/test_http_endpoints.py @@ -539,6 +539,18 @@ def test_pv_cost_defaults(self): resp = self.api_client.get(f'/v3/pv_cost_defaults', data=inputs_dict) view_response = json.loads(resp.content) - self.assertEqual(view_response["size_class"], 3) + self.assertEqual(view_response["size_class"], 3) + + def test_electric_storage_cost_defaults(self): + inputs_dict = { + "electric_load_annual_peak": 8760000.0, + "electric_load_average": 8760 + } + + # Call to the django view endpoint /get_electric_storage_defaults which calls the http.jl endpoint + resp = self.api_client.get(f'/v3/electric_storage_cost_defaults', data=inputs_dict) + view_response = json.loads(resp.content) + + self.assertEqual(view_response["size_class"], 4) diff --git a/reoptjl/test/test_job_endpoint.py b/reoptjl/test/test_job_endpoint.py index 82e3f671e..f3e3022a0 100644 --- a/reoptjl/test/test_job_endpoint.py +++ b/reoptjl/test/test_job_endpoint.py @@ -420,4 +420,46 @@ def test_pv_cost_defaults_update_from_julia(self): r = json.loads(resp.content) self.assertEqual(r["inputs"]["PV"]["size_class"], 2) - self.assertAlmostEqual(r["inputs"]["PV"]["installed_cost_per_kw"], 2914.6, delta=0.05 * 2914.6) \ No newline at end of file + self.assertAlmostEqual(r["inputs"]["PV"]["installed_cost_per_kw"], 2914.6, delta=0.05 * 2914.6) + + def test_electric_storage_cost_defaults_update_from_julia(self): + + input_data = dict() + + input_data["Financial"] = dict() + input_data["Financial"]["analysis_years"] = 20 + input_data["Financial"]["offtaker_discount_rate_fraction"] = 0.06 + input_data["Financial"]["offtaker_tax_rate_fraction"] = 0.26 + input_data["Financial"]["elec_cost_escalation_rate_fraction"] = 0.03 + input_data["Financial"]["om_cost_escalation_rate_fraction"] = 0.02 + + input_data["ElectricStorage"] = dict() + input_data["ElectricStorage"]["total_itc_fraction"] = 0.0 + input_data["ElectricStorage"]["macrs_option_years"] = 0 + input_data["ElectricStorage"]["macrs_bonus_fraction"] = 0.0 + + input_data["ElectricLoad"] = dict() + # Average load is 250 kw + input_data["ElectricLoad"]["loads_kw"] = [250.0] * 8760 + # Peak load determines size class. We use size class bounds to vary size class. + input_data["ElectricLoad"]["loads_kw"][6000] = 250.0 + 39 + input_data["ElectricLoad"]["year"] = 2025 + + input_data["Site"] = dict() + input_data["Site"]["longitude"] = -118.1164613 + input_data["Site"]["latitude"] = 34.5794343 + + input_data["ElectricTariff"] = dict() + input_data["ElectricTariff"]["blended_annual_energy_rate"] = 0.12 + input_data["ElectricTariff"]["blended_annual_demand_rate"] = 12.0 + + resp = self.api_client.post('/stable/job/', format='json', data=input_data) + self.assertHttpCreated(resp) + r = json.loads(resp.content) + run_uuid = r.get('run_uuid') + + resp = self.api_client.get(f'/stable/job/{run_uuid}/results') + r = json.loads(resp.content) + + self.assertEqual(r["inputs"]["ElectricStorage"]["size_class"], 1) + self.assertAlmostEqual(r["inputs"]["ElectricStorage"]["installed_cost_per_kw"], 705) \ No newline at end of file diff --git a/reoptjl/test/test_validator.py b/reoptjl/test/test_validator.py index 7465fb94b..12f98a063 100644 --- a/reoptjl/test/test_validator.py +++ b/reoptjl/test/test_validator.py @@ -102,7 +102,7 @@ def test_off_grid_defaults_overrides(self): self.assertAlmostEqual(validator.models["Wind"].operating_reserve_required_fraction, 0.5) self.assertAlmostEqual(validator.models["PV"].operating_reserve_required_fraction, 0.25) - self.assertEqual(validator.models["Wind"].installed_cost_per_kw, 5776.0) # set based on size_class (commercial) + self.assertEqual(validator.models["Wind"].installed_cost_per_kw, 6782.0) # set based on size_class (commercial) self.assertAlmostEqual(validator.models["ElectricLoad"].operating_reserve_required_fraction, 0.1) self.assertAlmostEqual(validator.models["ElectricLoad"].critical_load_fraction, 1.0) diff --git a/reoptjl/urls.py b/reoptjl/urls.py index afe2e08d6..a840d4d73 100644 --- a/reoptjl/urls.py +++ b/reoptjl/urls.py @@ -27,6 +27,7 @@ re_path(r'^job/generate_results_table/?$', views.generate_results_table), re_path(r'^get_ashp_defaults/?$', views.get_ashp_defaults), re_path(r'^pv_cost_defaults/?$', views.pv_cost_defaults), + re_path(r'^electric_storage_cost_defaults/?$', views.electric_storage_cost_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), re_path(r'^get_load_metrics/?$', views.get_load_metrics), diff --git a/reoptjl/views.py b/reoptjl/views.py index c3af61e3e..ca6ca76ef 100644 --- a/reoptjl/views.py +++ b/reoptjl/views.py @@ -599,6 +599,43 @@ def pv_cost_defaults(request): log.debug(debug_msg) return JsonResponse({"Error": "Unexpected error in pv_cost_defaults endpoint. Check log for more."}, status=500) +def electric_storage_cost_defaults(request): + + if request.method == "POST": + inputs = json.loads(request.body) + else: + inputs = { + "installed_cost_per_kw": request.GET.get("installed_cost_per_kw") or None, + "installed_cost_per_kwh": request.GET.get("installed_cost_per_kwh") or None, + "installed_cost_constant" : request.GET.get("installed_cost_constant") or None, + "size_class" : request.GET.get("size_class") or None, + "min_kw": request.GET.get("min_kw") or 0, + "max_kw": request.GET.get("max_kw") or 1.0e9, + "electric_load_annual_peak": request.GET.get("electric_load_annual_peak") or 0, + "electric_load_average": request.GET.get("electric_load_average") or 0 + } + + try: + julia_host = os.environ.get('JULIA_HOST', "julia") + http_jl_response = requests.get("http://" + julia_host + ":8081/electric_storage_cost_defaults/", json=inputs) + response = JsonResponse( + http_jl_response.json() + ) + return response + + except ValueError as e: + return JsonResponse({"Error": str(e.args[0])}, status=500) + + except KeyError as e: + return JsonResponse({"Error. Missing": str(e.args[0])}, status=500) + + except Exception: + exc_type, exc_value, exc_traceback = sys.exc_info() + debug_msg = "exc_type: {}; exc_value: {}; exc_traceback: {}".format(str(exc_type), exc_value.args[0], + tb.format_tb(exc_traceback)) + log.debug(debug_msg) + return JsonResponse({"Error": "Unexpected error in electric_storage_cost_defaults endpoint. Check log for more."}, status=500) + def simulated_load(request): try: # Build inputs dictionary to send to http.jl /simulated_load endpoint