diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dcb82778..c7556a65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,21 @@ Classify the change according to the following categories: ##### Removed ### Patches -## develop +## v3.13.0 ### Minor Updates -TODO: aggregate CHANGELOG updates from feature branches +#### Added +- Added **Financial** inputs **min_initial_capital_costs_before_incentives** and **max_initial_capital_costs_before_incentives** +- Added **CHP** output **initial_capital_costs** +- Added new **GHPInputs** fields for advanced ground heat exchanger sizing and configuration. +- Added **hybrid_ghx_sizing_method** input to allow user selection of GHX sizing approach. +- Added new outputs to **GHPOutputs** for reporting system sizes and borehole count. +- Added tests for new GHP input and output fields. +#### Changed +- Updated **PV.installed_cost_per_kw** and **PV.om_cost_per_kw** default values to reflect latest cost data. +- Updated **ElectricStorage.installed_cost_per_kw**, **ElectricStorage.installed_cost_per_kwh**, **ElectricStorage.replace_cost_per_kw**, and **ElectricStorage.replace_cost_per_kwh** default values to reflect latest cost data. +- Updated **ElectricStorage** cost defaults in `reoptjl/models.py` +- Updated GHP input validation and defaults in `reoptjl/models.py` and `validators.py`. +- Updated `/ghpghx` endpoint to support new GHP input fields. ## v3.12.3 ### Minor Updates @@ -53,7 +65,7 @@ TODO: aggregate CHANGELOG updates from feature branches ## v3.12.0 ### Major Updates -### Added +#### Added - Add inputs: - **ElectricUtility.cambium_cef_metric** to utilize clean energy data from NREL's Cambium database - **ElectricUtility.renewable_energy_fraction_series** to supply a custom grid clean or renewable energy scalar or series diff --git a/julia_src/Manifest.toml b/julia_src/Manifest.toml index fc3e1581e..2ce97d73a 100644 --- a/julia_src/Manifest.toml +++ b/julia_src/Manifest.toml @@ -922,9 +922,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 = "9946abe774e30d82f786e68296ad1fdf8bb7dba4" +git-tree-sha1 = "1b2ede642ebd6b9471500fbbd0b54d806edb669a" uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6" -version = "0.51.1" +version = "0.53.0" [[deps.Random]] deps = ["SHA"] diff --git a/julia_src/http.jl b/julia_src/http.jl index ebbfe6ea2..cd12baa0e 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -144,6 +144,14 @@ function reopt(req::HTTP.Request) else site_dict = Dict() end + if haskey(d, "PV") + inputs_with_defaults_from_pv = [ + :size_class, :installed_cost_per_kw, :om_cost_per_kw + ] + pv_dict = Dict(key=>getfield(model_inputs.s.pvs[1], key) for key in inputs_with_defaults_from_pv) + else + pv_dict = Dict() + end inputs_with_defaults_set_in_julia = Dict( "Financial" => Dict(key=>getfield(model_inputs.s.financial, key) for key in inputs_with_defaults_from_easiur), "ElectricUtility" => Dict(key=>getfield(model_inputs.s.electric_utility, key) for key in inputs_with_defaults_from_avert_or_cambium), @@ -153,7 +161,8 @@ function reopt(req::HTTP.Request) "GHP" => ghp_dict, "ExistingChiller" => chiller_dict, "ASHPSpaceHeater" => ashp_dict, - "ASHPWaterHeater" => ashp_wh_dict + "ASHPWaterHeater" => ashp_wh_dict, + "PV" => pv_dict ) catch e @error "Something went wrong in REopt optimization!" exception=(e, catch_backtrace()) @@ -579,6 +588,51 @@ function get_ashp_defaults(req::HTTP.Request) end end +function pv_cost_defaults(req::HTTP.Request) + d = JSON.parse(String(req.body)) + float_vals = ["electric_load_annual_kwh", "site_land_acres", + "site_roof_squarefeet", "min_kw", "max_kw", + "kw_per_square_foot", "acres_per_kw", + "capacity_factor_estimate", "fraction_of_annual_kwh_to_size_pv"] + int_vals = ["size_class", "array_type"] + string_vals = ["location"] + 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 PV cost defaults..." + data = Dict() + error_response = Dict() + try + data["installed_cost_per_kw"], data["om_cost_per_kw"], data["size_class"], tech_sizes_for_cost_curve, data["size_kw_for_size_class"] = reoptjl.get_pv_cost_params(; + (Symbol(k) => v for (k, v) in pairs(d))... + ) + catch e + @error "Something went wrong in the pv_cost_defaults" exception=(e, catch_backtrace()) + error_response["error"] = sprint(showerror, e) + end + if isempty(error_response) + @info "PV cost defaults determined." + response = data + return HTTP.Response(200, JSON.json(response)) + else + @info "An error occured in the pv_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.") @@ -607,4 +661,5 @@ HTTP.register!(ROUTER, "GET", "/ground_conductivity", ground_conductivity) 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.serve(ROUTER, "0.0.0.0", 8081, reuseaddr=true) diff --git a/reoptjl/migrations/0081_ghpinputs_load_served_by_ghp_and_more.py b/reoptjl/migrations/0081_ghpinputs_load_served_by_ghp_and_more.py new file mode 100644 index 000000000..d6a2f6a59 --- /dev/null +++ b/reoptjl/migrations/0081_ghpinputs_load_served_by_ghp_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.0.7 on 2025-04-22 16:44 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0080_electricloadoutputs_annual_electric_load_with_thermal_conversions_kwh_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='ghpinputs', + name='load_served_by_ghp', + field=models.TextField(blank=True, default='nonpeak', help_text='How to split between load served by GHP and load served by backup system'), + ), + migrations.AddField( + model_name='ghpinputs', + name='max_number_of_boreholes', + field=models.FloatField(blank=True, help_text='Maximum number of boreholes for GHX', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), + ), + migrations.AddField( + model_name='ghpinputs', + name='max_ton', + field=models.FloatField(blank=True, help_text='Maximum thermal power size constraint for GHP [ton]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), + ), + ] diff --git a/reoptjl/migrations/0084_merge_20250423_2110.py b/reoptjl/migrations/0084_merge_20250423_2110.py new file mode 100644 index 000000000..8ea012a9b --- /dev/null +++ b/reoptjl/migrations/0084_merge_20250423_2110.py @@ -0,0 +1,14 @@ +# Generated by Django 4.0.7 on 2025-04-23 21:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0081_ghpinputs_load_served_by_ghp_and_more'), + ('reoptjl', '0083_electricutilityoutputs_peak_grid_demand_kw_and_more'), + ] + + operations = [ + ] diff --git a/reoptjl/migrations/0085_electricstorageinputs_cost_constant_replacement_year_and_more.py b/reoptjl/migrations/0085_electricstorageinputs_cost_constant_replacement_year_and_more.py new file mode 100644 index 000000000..e7ef9d064 --- /dev/null +++ b/reoptjl/migrations/0085_electricstorageinputs_cost_constant_replacement_year_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.0.7 on 2025-05-19 20:33 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0084_merge_20250424_1814'), + ] + + operations = [ + migrations.AddField( + model_name='electricstorageinputs', + name='cost_constant_replacement_year', + field=models.IntegerField(blank=True, default=10, help_text='Number of years from start of analysis period to apply replace_cost_constant.', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(75)]), + ), + migrations.AddField( + model_name='electricstorageinputs', + name='installed_cost_constant', + field=models.FloatField(blank=True, default=0.0, help_text='Fixed upfront cost for battery installation, independent of size.', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000.0)]), + ), + migrations.AddField( + model_name='electricstorageinputs', + name='replace_cost_constant', + field=models.FloatField(blank=True, default=0.0, help_text='Fixed replacement cost for battery, independent of size.', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000.0)]), + ), + ] diff --git a/reoptjl/migrations/0085_financialinputs_max_initial_capital_costs_before_incentives_and_more.py b/reoptjl/migrations/0085_financialinputs_max_initial_capital_costs_before_incentives_and_more.py new file mode 100644 index 000000000..5a02b6559 --- /dev/null +++ b/reoptjl/migrations/0085_financialinputs_max_initial_capital_costs_before_incentives_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.7 on 2025-05-08 04:49 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0084_merge_20250424_1814'), + ] + + operations = [ + migrations.AddField( + model_name='financialinputs', + name='max_initial_capital_costs_before_incentives', + field=models.FloatField(blank=True, help_text='Maximum up-front capital cost for all technologies, excluding replacement costs and incentives [\$].', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000000.0)]), + ), + migrations.AddField( + model_name='financialinputs', + name='min_initial_capital_costs_before_incentives', + field=models.FloatField(blank=True, help_text='Minimum up-front capital cost for all technologies, excluding replacement costs and incentives [\$].', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000000.0)]), + ), + ] diff --git a/reoptjl/migrations/0085_ghpoutputs_annual_thermal_production_mmbtu_and_more.py b/reoptjl/migrations/0085_ghpoutputs_annual_thermal_production_mmbtu_and_more.py new file mode 100644 index 000000000..fe4a5e62b --- /dev/null +++ b/reoptjl/migrations/0085_ghpoutputs_annual_thermal_production_mmbtu_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.7 on 2025-04-24 14:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0084_merge_20250423_2110'), + ] + + operations = [ + migrations.AddField( + model_name='ghpoutputs', + name='annual_thermal_production_mmbtu', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='ghpoutputs', + name='annual_thermal_production_tonhour', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/reoptjl/migrations/0085_pvinputs_size_class_pvoutputs_installed_cost_per_kw_and_more.py b/reoptjl/migrations/0085_pvinputs_size_class_pvoutputs_installed_cost_per_kw_and_more.py new file mode 100644 index 000000000..d8d18ecdc --- /dev/null +++ b/reoptjl/migrations/0085_pvinputs_size_class_pvoutputs_installed_cost_per_kw_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.0.7 on 2025-05-09 22:46 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0084_merge_20250424_1814'), + ] + + operations = [ + migrations.AddField( + model_name='pvinputs', + name='size_class', + field=models.IntegerField(blank=True, help_text='PV size class. Must be an integer value between 1 and 5. Default is 2, representing commercial-scale', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]), + ), + migrations.AddField( + model_name='pvoutputs', + name='installed_cost_per_kw', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='pvoutputs', + name='om_cost_per_kw', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='pvinputs', + name='installed_cost_per_kw', + field=models.FloatField(blank=True, help_text='Installed PV cost in $/kW', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000.0)]), + ), + migrations.AlterField( + model_name='pvinputs', + name='om_cost_per_kw', + field=models.FloatField(blank=True, help_text='Annual PV operations and maintenance costs in $/kW', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000.0)]), + ), + ] diff --git a/reoptjl/migrations/0086_chpoutputs_initial_capital_costs.py b/reoptjl/migrations/0086_chpoutputs_initial_capital_costs.py new file mode 100644 index 000000000..5107d9a19 --- /dev/null +++ b/reoptjl/migrations/0086_chpoutputs_initial_capital_costs.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.7 on 2025-05-08 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0085_financialinputs_max_initial_capital_costs_before_incentives_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='chpoutputs', + name='initial_capital_costs', + field=models.FloatField(blank=True, help_text='Initial capital costs of the CHP system, before incentives [\\$]', null=True), + ), + ] diff --git a/reoptjl/migrations/0086_electricstorageinputs_om_cost_fraction_of_installed_cost_and_more.py b/reoptjl/migrations/0086_electricstorageinputs_om_cost_fraction_of_installed_cost_and_more.py new file mode 100644 index 000000000..22718b0c6 --- /dev/null +++ b/reoptjl/migrations/0086_electricstorageinputs_om_cost_fraction_of_installed_cost_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.0.7 on 2025-06-04 00:05 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0085_electricstorageinputs_cost_constant_replacement_year_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='electricstorageinputs', + name='om_cost_fraction_of_installed_cost', + field=models.FloatField(blank=True, default=0.025, 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='installed_cost_constant', + field=models.FloatField(blank=True, default=222115.0, help_text='Fixed upfront cost for battery installation, independent of size.', 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, default=905.0, help_text='Total upfront battery power capacity costs (e.g. inverter and balance of power systems)', 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, default=237.0, help_text='Total upfront battery costs', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10000.0)]), + ), + migrations.AlterField( + model_name='electricstorageinputs', + name='replace_cost_per_kw', + field=models.FloatField(blank=True, default=0.0, help_text='Battery power capacity replacement cost at time of replacement year', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10000.0)]), + ), + migrations.AlterField( + model_name='electricstorageinputs', + name='replace_cost_per_kwh', + field=models.FloatField(blank=True, default=0.0, help_text='Battery energy capacity replacement cost at time of replacement year', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10000.0)]), + ), + ] diff --git a/reoptjl/migrations/0087_merge_20250604_0342.py b/reoptjl/migrations/0087_merge_20250604_0342.py new file mode 100644 index 000000000..edafd4e60 --- /dev/null +++ b/reoptjl/migrations/0087_merge_20250604_0342.py @@ -0,0 +1,14 @@ +# Generated by Django 4.0.7 on 2025-06-04 03:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0086_chpoutputs_initial_capital_costs'), + ('reoptjl', '0086_electricstorageinputs_om_cost_fraction_of_installed_cost_and_more'), + ] + + operations = [ + ] diff --git a/reoptjl/migrations/0088_financialoutputs_initial_capital_costs_after_incentives_bau_and_more.py b/reoptjl/migrations/0088_financialoutputs_initial_capital_costs_after_incentives_bau_and_more.py new file mode 100644 index 000000000..65c442ae1 --- /dev/null +++ b/reoptjl/migrations/0088_financialoutputs_initial_capital_costs_after_incentives_bau_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.7 on 2025-06-04 04:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0087_merge_20250604_0342'), + ] + + operations = [ + migrations.AddField( + model_name='financialoutputs', + name='initial_capital_costs_after_incentives_bau', + field=models.FloatField(blank=True, help_text='Up-front capital costs for BAU technologies such as ExistingBoiler and ExistingChiller, in present value.', null=True), + ), + migrations.AddField( + model_name='financialoutputs', + name='lifecycle_capital_costs_bau', + field=models.FloatField(blank=True, help_text='Net capital costs for BAU technologies such as ExistingBoiler and ExistingChiller, in present value.', null=True), + ), + ] diff --git a/reoptjl/migrations/0089_alter_electricstorageinputs_installed_cost_per_kw_and_more.py b/reoptjl/migrations/0089_alter_electricstorageinputs_installed_cost_per_kw_and_more.py new file mode 100644 index 000000000..54f0e618c --- /dev/null +++ b/reoptjl/migrations/0089_alter_electricstorageinputs_installed_cost_per_kw_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.7 on 2025-06-04 16:59 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0088_financialoutputs_initial_capital_costs_after_incentives_bau_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='electricstorageinputs', + name='installed_cost_per_kw', + field=models.FloatField(blank=True, default=968.0, help_text='Total upfront battery power capacity costs (e.g. inverter and balance of power systems)', 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, default=253.0, help_text='Total upfront battery costs', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10000.0)]), + ), + ] diff --git a/reoptjl/migrations/0090_existingboilerinputs_installed_cost_dollars_and_more.py b/reoptjl/migrations/0090_existingboilerinputs_installed_cost_dollars_and_more.py new file mode 100644 index 000000000..8d6357e79 --- /dev/null +++ b/reoptjl/migrations/0090_existingboilerinputs_installed_cost_dollars_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 4.0.7 on 2025-06-04 19:28 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0089_alter_electricstorageinputs_installed_cost_per_kw_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='existingboilerinputs', + name='installed_cost_dollars', + field=models.FloatField(blank=True, default=0.0, help_text='Cost incurred in BAU scenario, as well as Optimal if needed still, in dollars', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), + ), + migrations.AddField( + model_name='existingboilerinputs', + name='installed_cost_per_mmbtu_per_hour', + field=models.FloatField(blank=True, default=0.0, help_text="Thermal power capacity-based cost incurred in BAU and only based on what's needed in Optimal scenario", null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), + ), + migrations.AddField( + model_name='existingboileroutputs', + name='size_mmbtu_per_hour_bau', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='existingchillerinputs', + name='installed_cost_dollars', + field=models.FloatField(blank=True, default=0.0, help_text='Cost incurred in BAU scenario, as well as Optimal if needed still, in dollars', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), + ), + migrations.AddField( + model_name='existingchillerinputs', + name='installed_cost_per_ton', + field=models.FloatField(blank=True, default=0.0, help_text="Thermal power capacity-based cost incurred in BAU and only based on what's needed in Optimal scenario", null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), + ), + migrations.AddField( + model_name='existingchilleroutputs', + name='size_ton', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='existingchilleroutputs', + name='size_ton_bau', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/reoptjl/migrations/0091_merge_20250604_2023.py b/reoptjl/migrations/0091_merge_20250604_2023.py new file mode 100644 index 000000000..8c83be0da --- /dev/null +++ b/reoptjl/migrations/0091_merge_20250604_2023.py @@ -0,0 +1,14 @@ +# Generated by Django 4.0.7 on 2025-06-04 20:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0085_ghpoutputs_annual_thermal_production_mmbtu_and_more'), + ('reoptjl', '0090_existingboilerinputs_installed_cost_dollars_and_more'), + ] + + operations = [ + ] diff --git a/reoptjl/migrations/0092_merge_20250613_0525.py b/reoptjl/migrations/0092_merge_20250613_0525.py new file mode 100644 index 000000000..9d52ffd7d --- /dev/null +++ b/reoptjl/migrations/0092_merge_20250613_0525.py @@ -0,0 +1,14 @@ +# Generated by Django 4.0.7 on 2025-06-13 05:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0085_pvinputs_size_class_pvoutputs_installed_cost_per_kw_and_more'), + ('reoptjl', '0091_merge_20250604_2023'), + ] + + operations = [ + ] diff --git a/reoptjl/models.py b/reoptjl/models.py index fdef159a8..2d48782ea 100644 --- a/reoptjl/models.py +++ b/reoptjl/models.py @@ -786,6 +786,24 @@ class FinancialInputs(BaseModel, models.Model): default=0.0, help_text=("Only applicable when off_grid_flag is true. These per year costs are considered tax deductible for owner.") ) + min_initial_capital_costs_before_incentives = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(1e12) + ], + blank=True, + null=True, + help_text=("Minimum up-front capital cost for all technologies, excluding replacement costs and incentives [\$].") + ) + max_initial_capital_costs_before_incentives = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(1e12) + ], + blank=True, + null=True, + help_text=("Maximum up-front capital cost for all technologies, excluding replacement costs and incentives [\$].") + ) CO2_cost_per_tonne = models.FloatField( validators=[ MinValueValidator(0), @@ -964,6 +982,10 @@ class FinancialOutputs(BaseModel, models.Model): null=True, blank=True, help_text="Net capital costs for all technologies, in present value, including replacement costs and incentives." ) + lifecycle_capital_costs_bau = models.FloatField( + null=True, blank=True, + help_text="Net capital costs for BAU technologies such as ExistingBoiler and ExistingChiller, in present value." + ) microgrid_upgrade_cost = models.FloatField( null=True, blank=True, help_text=("Cost to make a distributed energy system islandable from the grid. Determined by multiplying the " @@ -978,6 +1000,10 @@ class FinancialOutputs(BaseModel, models.Model): null=True, blank=True, help_text="Up-front capital costs for all technologies, in present value, excluding replacement costs, including incentives." ) + initial_capital_costs_after_incentives_bau = models.FloatField( + null=True, blank=True, + help_text="Up-front capital costs for BAU technologies such as ExistingBoiler and ExistingChiller, in present value." + ) capital_costs_after_non_discounted_incentives_without_macrs = models.FloatField( null=True, blank=True, help_text="Capital costs for all technologies, including present value of replacement costs and incentives except for MACRS." @@ -2725,21 +2751,30 @@ class PV_LOCATION_CHOICES(models.TextChoices): blank=True, help_text="Maximum PV size constraint for optimization (upper bound on additional capacity beyond existing_kw). Set to zero to disable PV" ) + size_class = models.IntegerField( + validators=[ + MinValueValidator(1), + MaxValueValidator(5) + ], + null=True, + blank=True, + help_text="PV size class. Must be an integer value between 1 and 5. Default is 2, representing commercial-scale" + ) installed_cost_per_kw = models.FloatField( - default=1790, validators=[ MinValueValidator(0), MaxValueValidator(1.0e5) ], + null=True, blank=True, help_text="Installed PV cost in $/kW" ) om_cost_per_kw = models.FloatField( - default=18, validators=[ MinValueValidator(0), MaxValueValidator(1.0e3) ], + null=True, blank=True, help_text="Annual PV operations and maintenance costs in $/kW" ) @@ -3067,6 +3102,8 @@ class PVOutputs(BaseModel, models.Model): help_text="PV description for distinguishing between multiple PV models" ) size_kw = models.FloatField(null=True, blank=True) + installed_cost_per_kw = models.FloatField(null=True, blank=True) + om_cost_per_kw = models.FloatField(null=True, blank=True) lifecycle_om_cost_after_tax = models.FloatField(null=True, blank=True) lifecycle_om_cost_after_tax_bau = models.FloatField(null=True, blank=True) lifecycle_om_cost_bau = models.FloatField(null=True, blank=True) @@ -3515,7 +3552,7 @@ class ElectricStorageInputs(BaseModel, models.Model): help_text="Flag to set whether the battery can be charged from the grid, or just onsite generation." ) installed_cost_per_kw = models.FloatField( - default=910.0, + default=968.0, validators=[ MinValueValidator(0), MaxValueValidator(1.0e4) @@ -3524,7 +3561,7 @@ class ElectricStorageInputs(BaseModel, models.Model): help_text="Total upfront battery power capacity costs (e.g. inverter and balance of power systems)" ) installed_cost_per_kwh = models.FloatField( - default=455.0, + default=253.0, validators=[ MinValueValidator(0), MaxValueValidator(1.0e4) @@ -3532,8 +3569,17 @@ class ElectricStorageInputs(BaseModel, models.Model): blank=True, help_text="Total upfront battery costs" ) + installed_cost_constant = models.FloatField( + default=222115.0, + validators=[ + MinValueValidator(0), + MaxValueValidator(1.0e9) + ], + blank=True, + help_text="Fixed upfront cost for battery installation, independent of size." + ) replace_cost_per_kw = models.FloatField( - default=715.0, + default=0.0, validators=[ MinValueValidator(0), MaxValueValidator(1.0e4) @@ -3542,7 +3588,7 @@ class ElectricStorageInputs(BaseModel, models.Model): help_text="Battery power capacity replacement cost at time of replacement year" ) replace_cost_per_kwh = models.FloatField( - default=318.0, + default=0.0, validators=[ MinValueValidator(0), MaxValueValidator(1.0e4) @@ -3550,6 +3596,15 @@ class ElectricStorageInputs(BaseModel, models.Model): blank=True, help_text="Battery energy capacity replacement cost at time of replacement year" ) + replace_cost_constant = models.FloatField( + default=0.0, + validators=[ + MinValueValidator(0), + MaxValueValidator(1.0e9) + ], + blank=True, + help_text="Fixed replacement cost for battery, independent of size." + ) inverter_replacement_year = models.IntegerField( default=10, validators=[ @@ -3568,6 +3623,24 @@ class ElectricStorageInputs(BaseModel, models.Model): blank=True, help_text="Number of years from start of analysis period to replace battery" ) + cost_constant_replacement_year = models.IntegerField( + default=10, + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_YEARS) + ], + blank=True, + 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, + validators=[ + MinValueValidator(0), + MaxValueValidator(1.0) + ], + blank=True, + help_text="Annual O&M cost as a fraction of installed cost." + ) macrs_option_years = models.IntegerField( default=MACRS_YEARS_CHOICES.SEVEN, choices=MACRS_YEARS_CHOICES.choices, @@ -4728,6 +4801,10 @@ class CHPOutputs(BaseModel, models.Model): models.FloatField(null=True, blank=True), default = list, ) + initial_capital_costs = models.FloatField( + null=True, blank=True, + help_text="Initial capital costs of the CHP system, before incentives [\$]" + ) def clean(): pass @@ -4987,6 +5064,28 @@ class ExistingChillerInputs(BaseModel, models.Model): help_text="Boolean indicator if the existing chiller is unavailable in the optimal case (still used in BAU)" ) + installed_cost_per_ton = models.FloatField( + default=0.0, + null=True, + blank=True, + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + help_text="Thermal power capacity-based cost incurred in BAU and only based on what's needed in Optimal scenario" + ) + + installed_cost_dollars = models.FloatField( + default=0.0, + null=True, + blank=True, + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + help_text="Cost incurred in BAU scenario, as well as Optimal if needed still, in dollars" + ) + def clean(self): pass @@ -5002,6 +5101,9 @@ class ExistingChillerOutputs(BaseModel, models.Model): primary_key=True ) + size_ton = models.FloatField(null=True, blank=True) + size_ton_bau = models.FloatField(null=True, blank=True) + thermal_to_storage_series_ton = ArrayField( models.FloatField( blank=True @@ -5202,6 +5304,29 @@ class ExistingBoilerInputs(BaseModel, models.Model): help_text="Existing boiler fuel type, one of natural_gas, landfill_bio_gas, propane, diesel_oil" ) + + installed_cost_per_mmbtu_per_hour = models.FloatField( + default=0.0, + null=True, + blank=True, + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + help_text="Thermal power capacity-based cost incurred in BAU and only based on what's needed in Optimal scenario" + ) + + installed_cost_dollars = models.FloatField( + default=0.0, + null=True, + blank=True, + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + help_text="Cost incurred in BAU scenario, as well as Optimal if needed still, in dollars" + ) + can_supply_steam_turbine = models.BooleanField( default=False, blank=True, @@ -5268,6 +5393,7 @@ class ExistingBoilerOutputs(BaseModel, models.Model): ) size_mmbtu_per_hour = models.FloatField(null=True, blank=True) + size_mmbtu_per_hour_bau = models.FloatField(null=True, blank=True) annual_fuel_consumption_mmbtu = models.FloatField(null=True, blank=True) annual_fuel_consumption_mmbtu_bau = models.FloatField(null=True, blank=True) @@ -8386,6 +8512,32 @@ class GHPInputs(BaseModel, models.Model): blank=True, help_text="Maximum utility rebate" ) + max_ton = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + help_text=("Maximum thermal power size constraint for GHP [ton]") + ) + + max_number_of_boreholes = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + help_text=("Maximum number of boreholes for GHX") + ) + + load_served_by_ghp = models.TextField( + null=False, + blank=True, + default="nonpeak", + help_text="How to split between load served by GHP and load served by backup system" + ) def clean(self): @@ -8429,6 +8581,8 @@ class GHPOutputs(BaseModel, models.Model): thermal_to_dhw_load_series_mmbtu_per_hour = ArrayField(models.FloatField(null=True, blank=True), default=list, null=True, blank=True) thermal_to_load_series_ton = ArrayField(models.FloatField(null=True, blank=True), default=list, null=True, blank=True) avoided_capex_by_ghp_present_value = models.FloatField(null=True, blank=True) + annual_thermal_production_mmbtu = models.FloatField(null=True, blank=True) + annual_thermal_production_tonhour = models.FloatField(null=True, blank=True) def get_input_dict_from_run_uuid(run_uuid:str): """ diff --git a/reoptjl/src/process_results.py b/reoptjl/src/process_results.py index 5b5732d69..eee8c1942 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, ASHPSpaceHeaterInputs, ASHPWaterHeaterInputs + SiteInputs, ASHPSpaceHeaterInputs, ASHPWaterHeaterInputs, PVInputs import numpy as np import sys import traceback as tb @@ -146,6 +146,9 @@ def update_inputs_in_database(inputs_to_update: dict, run_uuid: str) -> None: if inputs_to_update["ASHPWaterHeater"]: prune_update_fields(ASHPWaterHeaterInputs, inputs_to_update["ASHPWaterHeater"]) ASHPWaterHeaterInputs.objects.filter(meta__run_uuid=run_uuid).update(**inputs_to_update["ASHPWaterHeater"]) + if inputs_to_update["PV"]: + prune_update_fields(PVInputs, inputs_to_update["PV"]) + PVInputs.objects.filter(meta__run_uuid=run_uuid).update(**inputs_to_update["PV"]) 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/posts/all_inputs_test.json b/reoptjl/test/posts/all_inputs_test.json index 6811af43c..7bfb1c979 100644 --- a/reoptjl/test/posts/all_inputs_test.json +++ b/reoptjl/test/posts/all_inputs_test.json @@ -26,7 +26,9 @@ "PM25_onsite_fuelburn_cost_per_tonne": null, "NOx_cost_escalation_rate_fraction": null, "SO2_cost_escalation_rate_fraction": null, - "PM25_cost_escalation_rate_fraction": null + "PM25_cost_escalation_rate_fraction": null, + "min_initial_capital_costs_before_incentives": null, + "max_initial_capital_costs_before_incentives": null }, "ElectricLoad": { "annual_kwh": 190000.0, @@ -167,10 +169,14 @@ "can_grid_charge": true, "installed_cost_per_kw": 840.0, "installed_cost_per_kwh": 420.0, + "installed_cost_constant": 0.0, "replace_cost_per_kw": 410.0, "replace_cost_per_kwh": 200.0, + "replace_cost_constant": 0.0, "inverter_replacement_year": 10, "battery_replacement_year": 10, + "cost_constant_replacement_year": 10, + "om_cost_fraction_of_installed_cost": 0.0, "macrs_option_years": 7, "macrs_bonus_fraction": 1.0, "macrs_itc_reduction": 0.5, diff --git a/reoptjl/test/posts/outage.json b/reoptjl/test/posts/outage.json index 1a212b871..5658437c5 100644 --- a/reoptjl/test/posts/outage.json +++ b/reoptjl/test/posts/outage.json @@ -75,8 +75,11 @@ "macrs_bonus_fraction": 1.0, "installed_cost_per_kw": 840.0, "installed_cost_per_kwh": 420.0, + "installed_cost_constant": 0.0, "replace_cost_per_kw": 410.0, - "replace_cost_per_kwh": 200.0 + "replace_cost_per_kwh": 200.0, + "replace_cost_constant": 0.0, + "om_cost_fraction_of_installed_cost": 0.0 }, "Financial": { "value_of_lost_load_per_kwh": 100.0, diff --git a/reoptjl/test/posts/pv_batt_emissions.json b/reoptjl/test/posts/pv_batt_emissions.json index fc926b1a9..4853c5b43 100644 --- a/reoptjl/test/posts/pv_batt_emissions.json +++ b/reoptjl/test/posts/pv_batt_emissions.json @@ -35,8 +35,11 @@ "macrs_bonus_fraction": 0.4, "replace_cost_per_kw": 460.0, "replace_cost_per_kwh": 230.0, + "replace_cost_constant": 0.0, "installed_cost_per_kw": 1000.0, "installed_cost_per_kwh": 500.0, + "installed_cost_constant": 0.0, + "om_cost_fraction_of_installed_cost": 0.0, "total_itc_fraction": 0.0 }, "ElectricTariff": { diff --git a/reoptjl/test/posts/pv_cost_update.json b/reoptjl/test/posts/pv_cost_update.json new file mode 100644 index 000000000..a82875ad6 --- /dev/null +++ b/reoptjl/test/posts/pv_cost_update.json @@ -0,0 +1,18 @@ +{ + "Site": { + "latitude": 41.8809434, + "longitude": -72.2600655, + "land_acres": 0.5 + }, + "ElectricLoad": { + "annual_kwh": 2000000.0, + "doe_reference_name": "LargeOffice" + }, + "ElectricTariff": { + "blended_annual_demand_rate": 20.0, + "blended_annual_energy_rate": 0.12 + }, + "PV": { + "array_type": 0 + } +} \ No newline at end of file diff --git a/reoptjl/test/test_http_endpoints.py b/reoptjl/test/test_http_endpoints.py index 8b0e25ad6..8104a6197 100644 --- a/reoptjl/test/test_http_endpoints.py +++ b/reoptjl/test/test_http_endpoints.py @@ -404,4 +404,17 @@ def test_get_ashp_defaults(self): self.assertNotIn("cooling_cf_reference", view_response.keys()) self.assertEqual(view_response["sizing_factor"], 1.0) + def test_pv_cost_defaults(self): + inputs_dict = { + "electric_load_annual_kwh": 2000000.0, + "array_type": 1, + "site_roof_squarefeet": 50000 + } + + # Call to the django view endpoint /get_existing_chiller_default_cop which calls the http.jl endpoint + 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) + diff --git a/reoptjl/test/test_job_endpoint.py b/reoptjl/test/test_job_endpoint.py index 5dfe67391..97502460d 100644 --- a/reoptjl/test/test_job_endpoint.py +++ b/reoptjl/test/test_job_endpoint.py @@ -326,4 +326,19 @@ def test_ashp_defaults_update_from_julia(self): r = json.loads(resp.content) self.assertEquals(r["inputs"]["ASHPSpaceHeater"]["om_cost_per_ton"], 0.0) - self.assertEquals(r["inputs"]["ASHPSpaceHeater"]["sizing_factor"], 1.1) \ No newline at end of file + self.assertEquals(r["inputs"]["ASHPSpaceHeater"]["sizing_factor"], 1.1) + + def test_pv_cost_defaults_update_from_julia(self): + # Test that the inputs_with_defaults_set_in_julia feature worked for PV + post_file = os.path.join('reoptjl', 'test', 'posts', 'pv_cost_update.json') + post = json.load(open(post_file, 'r')) + resp = self.api_client.post('/stable/job/', format='json', data=post) + 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.assertEquals(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 diff --git a/reoptjl/testing.txt b/reoptjl/testing.txt new file mode 100644 index 000000000..e69de29bb diff --git a/reoptjl/urls.py b/reoptjl/urls.py index 89617a7cf..73685f76c 100644 --- a/reoptjl/urls.py +++ b/reoptjl/urls.py @@ -25,6 +25,7 @@ re_path(r'^get_existing_chiller_default_cop/?$', views.get_existing_chiller_default_cop), 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'^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 b8b8901fb..d0797ccbd 100644 --- a/reoptjl/views.py +++ b/reoptjl/views.py @@ -514,6 +514,49 @@ def get_ashp_defaults(request): log.debug(debug_msg) return JsonResponse({"Error": "Unexpected error in get_ashp_defaults endpoint. Check log for more."}, status=500) +def pv_cost_defaults(request): + + if request.method == "POST": + inputs = json.loads(request.body) + else: + inputs = { + "electric_load_annual_kwh": request.GET.get("electric_load_annual_kwh"), + "site_land_acres": request.GET.get("site_land_acres"), + "site_roof_squarefeet" : request.GET.get("site_roof_squarefeet"), + "min_kw": request.GET.get("min_kw"), + "max_kw": request.GET.get("max_kw"), + "kw_per_square_foot": request.GET.get("kw_per_square_foot"), + "acres_per_kw": request.GET.get("acres_per_kw"), + "size_class": request.GET.get("size_class"), + "array_type": request.GET.get("array_type"), + "location": request.GET.get("location"), + "capacity_factor_estimate": request.GET.get("capacity_factor_estimate"), + "fraction_of_annual_kwh_to_size_pv": request.GET.get("fraction_of_annual_kwh_to_size_pv") + } + + inputs = {k: v for k, v in inputs.items() if v is not None} + + try: + julia_host = os.environ.get('JULIA_HOST', "julia") + http_jl_response = requests.get("http://" + julia_host + ":8081/pv_cost_defaults/", json=inputs) + response = JsonResponse( + http_jl_response.json() + ) + return response + + except ValueError as e: + print(e.args) + 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(exc_type, exc_value.args[0], + tb.format_tb(exc_traceback)) + log.debug(debug_msg) + return JsonResponse({"Error": "Unexpected error in pv_cost_defaults endpoint. Check log for more."}, status=500) def simulated_load(request): try: