diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f9c7960d..c9ab3928b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ Classify the change according to the following categories: ### Deprecated ### Removed +## fixed-bess-soc +### Added +- Add **ElectricStorage** input field `fixed_soc_series_fraction` to allow users to fix the SOC timeseries + +## test-runners ## Develop ### Added - Added constraints in `src/constraints/battery_degradation.jl` to allow use of segmented cycle fade coefficients in the model. diff --git a/src/constraints/storage_constraints.jl b/src/constraints/storage_constraints.jl index 2021b7ffa..ac1a7f8b1 100644 --- a/src/constraints/storage_constraints.jl +++ b/src/constraints/storage_constraints.jl @@ -44,7 +44,7 @@ function add_general_storage_dispatch_constraints(m, p, b; _n="") @constraint(m, m[Symbol("dvStoredEnergy"*_n)][b, 0] == m[:dvStoredEnergy][b, maximum(p.time_steps)] ) - else + elseif !hasproperty(p.s.storage.attr[b], :fixed_soc_series_fraction) || isnothing(p.s.storage.attr[b].fixed_soc_series_fraction) @constraint(m, m[Symbol("dvStoredEnergy"*_n)][b, 0] == p.s.storage.attr[b].soc_init_fraction * m[Symbol("dvStorageEnergy"*_n)][b] ) @@ -130,6 +130,7 @@ function add_elec_storage_dispatch_constraints(m, p, b; _n="") end end + # Constrain average state of charge if p.s.storage.attr[b].minimum_avg_soc_fraction > 0 avg_soc = sum(m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) / (8760. / p.hours_per_time_step) @@ -137,6 +138,17 @@ function add_elec_storage_dispatch_constraints(m, p, b; _n="") sum(m[Symbol("dvStorageEnergy"*_n)][b]) ) end + + # Constrain to fixed_soc_series_fraction + if hasproperty(p.s.storage.attr[b], :fixed_soc_series_fraction) && !isnothing(p.s.storage.attr[b].fixed_soc_series_fraction) + @constraint(m, [ts in p.time_steps], + # Allow for a 1 pct point buffer on user-provided fixed_soc_series_fraction + m[Symbol("dvStoredEnergy"*_n)][b, ts] <= (0.02 + p.s.storage.attr[b].fixed_soc_series_fraction[ts]) * m[Symbol("dvStorageEnergy"*_n)][b] + ) + @constraint(m, [ts in p.time_steps], + m[Symbol("dvStoredEnergy"*_n)][b, ts] >= (-0.02 + p.s.storage.attr[b].fixed_soc_series_fraction[ts]) * m[Symbol("dvStorageEnergy"*_n)][b] + ) + end end function add_elec_storage_cost_constant_constraints(m, p, b; _n="") diff --git a/src/core/energy_storage/electric_storage.jl b/src/core/energy_storage/electric_storage.jl index 48d792161..c626ab541 100644 --- a/src/core/energy_storage/electric_storage.jl +++ b/src/core/energy_storage/electric_storage.jl @@ -199,8 +199,10 @@ end degradation::Dict = Dict() minimum_avg_soc_fraction::Float64 = 0.0 optimize_soc_init_fraction::Bool = false # If true, soc_init_fraction will not apply. Model will optimize initial SOC and constrain initial SOC = final SOC. + fixed_soc_series_fraction::Union{Nothing, Array{<:Real,1}} = nothing # If provided, SOC (as fraction of total energy capacity) will not be optimized and will instead be fixed to the values provided here +- 0.02 (this buffer is to avoid infeasible solutions) min_duration_hours::Real = 0.0 # Minimum amount of time storage can discharge at its rated power capacity max_duration_hours::Real = 100000.0 # Maximum amount of time storage can discharge at its rated power capacity (ratio of ElectricStorage size_kwh to size_kw) + ``` """ Base.@kwdef struct ElectricStorageDefaults @@ -241,6 +243,7 @@ Base.@kwdef struct ElectricStorageDefaults optimize_soc_init_fraction::Bool = false min_duration_hours::Real = 0.0 max_duration_hours::Real = 100000.0 + fixed_soc_series_fraction::Union{Nothing, Array{<:Real,1}} = nothing end @@ -290,8 +293,10 @@ struct ElectricStorage <: AbstractElectricStorage optimize_soc_init_fraction::Bool min_duration_hours::Real max_duration_hours::Real + fixed_soc_series_fraction::Union{Nothing, Array{<:Real,1}} + + function ElectricStorage(d::Dict, f::Financial) - function ElectricStorage(d::Dict, f::Financial) s = ElectricStorageDefaults(;d...) if s.inverter_replacement_year >= f.analysis_years @@ -306,6 +311,16 @@ struct ElectricStorage <: AbstractElectricStorage throw(@error("ElectricStorage min_duration_hours must be less than max_duration_hours.")) end + # Copy SOC input in case we need to change them + soc_min_fraction = s.soc_min_fraction + optimize_soc_init_fraction = s.optimize_soc_init_fraction + if !isnothing(s.fixed_soc_series_fraction) + @warn "Fixing ElectricStorage soc_series_fraction to the provided fixed_soc_series_fraction. Other SOC inputs will be ignored." + soc_min_fraction = 0.0 + optimize_soc_init_fraction = false + error_if_series_vals_not_0_to_1(s.fixed_soc_series_fraction, "ElectricStorage", "fixed_soc_series_fraction") + end + macrs_schedule = [0.0] if s.macrs_option_years == 5 || s.macrs_option_years == 7 macrs_schedule = s.macrs_option_years == 7 ? f.macrs_seven_year : f.macrs_five_year @@ -392,7 +407,7 @@ struct ElectricStorage <: AbstractElectricStorage s.internal_efficiency_fraction, s.inverter_efficiency_fraction, s.rectifier_efficiency_fraction, - s.soc_min_fraction, + soc_min_fraction, s.soc_min_applies_during_outages, s.soc_init_fraction, s.can_grid_charge, @@ -421,9 +436,10 @@ struct ElectricStorage <: AbstractElectricStorage s.model_degradation, degr, s.minimum_avg_soc_fraction, - s.optimize_soc_init_fraction, + optimize_soc_init_fraction, s.min_duration_hours, - s.max_duration_hours + s.max_duration_hours, + s.fixed_soc_series_fraction ) end end diff --git a/src/core/utils.jl b/src/core/utils.jl index 95ef48e17..3d2b8ca6a 100644 --- a/src/core/utils.jl +++ b/src/core/utils.jl @@ -174,7 +174,8 @@ function dictkeys_tosymbols(d::Dict) #for ERP "pv_production_factor_series", "wind_production_factor_series", "battery_starting_soc_series_fraction", - "monthly_mmbtu", "monthly_tonhour" + "monthly_mmbtu", "monthly_tonhour", + "fixed_soc_series_fraction" ] && !isnothing(v) try v = convert(Array{Real, 1}, v) diff --git a/src/mpc/structs.jl b/src/mpc/structs.jl index 340bb07c0..dbb159b9b 100644 --- a/src/mpc/structs.jl +++ b/src/mpc/structs.jl @@ -227,6 +227,10 @@ Base.@kwdef struct MPCElectricStorage < AbstractElectricStorage soc_init_fraction::Float64 = 0.5 can_grid_charge::Bool = true grid_charge_efficiency::Float64 = 0.96 * 0.975^2 + max_kw::Float64 = size_kw + max_kwh::Float64 = size_kwh + minimum_avg_soc_fraction::Float64 = 0.0 + fixed_soc_series_fraction::Union{Nothing, Array{<:Real,1}} = nothing end ``` """ @@ -242,6 +246,7 @@ Base.@kwdef struct MPCElectricStorage <: AbstractElectricStorage max_kw::Float64 = size_kw max_kwh::Float64 = size_kwh minimum_avg_soc_fraction::Float64 = 0.0 + fixed_soc_series_fraction::Union{Nothing, Array{<:Real,1}} = nothing end diff --git a/test/runtests.jl b/test/runtests.jl index f4ff88fb8..35a84be80 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3798,6 +3798,26 @@ else # run HiGHS tests empty!(m2) GC.gc() end + + @testset "Fixed ElectricStorage state of charge" begin + post_name = "fixed_pv_bess" + post = JSON.parsefile("./scenarios/$post_name.json") + + # Get optimal SOC + m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(m1 , post) + lcc1 = results["Financial"]["lcc"] + soc_series = results["ElectricStorage"]["soc_series_fraction"] + + # Fix soc_series to optimal from previous run + m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + post["ElectricStorage"]["fixed_soc_series_fraction"] = soc_series + results = run_reopt(m1 , post) + lcc2 = results["Financial"]["lcc"] + + @test lcc1 ≈ lcc2 rtol=0.001 + @test maximum(abs.(soc_series - results["ElectricStorage"]["soc_series_fraction"])) <= 0.0200001 + end @testset "Existing HVAC (Boiler and Chiller) Costs for BAU" begin """ diff --git a/test/scenarios/fixed_pv_bess.json b/test/scenarios/fixed_pv_bess.json new file mode 100644 index 000000000..755e07fbb --- /dev/null +++ b/test/scenarios/fixed_pv_bess.json @@ -0,0 +1,31 @@ +{ + "Site": { + "longitude": -118.1164613, + "latitude": 34.5794343 + }, + "ElectricLoad": { + "doe_reference_name": "RetailStore", + "annual_kwh": 876000.0 + }, + "ElectricTariff": { + "blended_annual_energy_rate": 0.10, + "blended_annual_demand_rate": 0 + }, + "Financial": { + "elec_cost_escalation_rate_fraction": 0.026, + "offtaker_discount_rate_fraction": 0.081, + "analysis_years": 20, + "offtaker_tax_rate_fraction": 0.4, + "om_cost_escalation_rate_fraction": 0.025 + }, + "PV" : { + "min_kw": 100, + "max_kw": 100 + }, + "ElectricStorage" : { + "min_kw": 100, + "max_kw": 100, + "min_kwh": 200, + "max_kwh": 200 + } +} \ No newline at end of file