Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion julia_src/Manifest.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 47 additions & 1 deletion julia_src/http.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"] = 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))
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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)]),
),
Comment on lines +14 to +18

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not want to add this standalone for electric storage. best to leave out size class or add for all techs together which could cause infeasibilities with existing scenario.

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)]),
),
]
Original file line number Diff line number Diff line change
@@ -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)]),
),
]
17 changes: 13 additions & 4 deletions reoptjl/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 43 additions & 1 deletion reoptjl/test/test_job_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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"]["PV"]["installed_cost_per_kw"], 705)
1 change: 1 addition & 0 deletions reoptjl/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
37 changes: 37 additions & 0 deletions reoptjl/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +602 to +606
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
Comment on lines +621 to +624

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
Expand Down
Loading