diff --git a/CHANGELOG.md b/CHANGELOG.md index 82be3af5c..9f9c7960d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,16 @@ Classify the change according to the following categories: ### Deprecated ### Removed +## Develop +### Added +- Added constraints in `src/constraints/battery_degradation.jl` to allow use of segmented cycle fade coefficients in the model. +- Added **cycle_fade_fraction** as an input for portion of BESS that is tied to each cycle fade coefficient. +- Added **total_residual_kwh** output which captures healthy residual battery capacity due to degradation and the replacement strategy +### Changed +- Changed **cycle_fade_coefficient** input to be a vector and accept vector of inputs. +- Changed default inputs for degradation module to match parameters for NMC-Gr Kokam 75Ah cell. +- Changed residual battery fraction calculation to calculate useful healthy capacity for residual value and capacity calculations. + ## v0.53.2 ### Fixed - `PV` `size_class` and cost defaults not updating correctly when both `max_kw` and the site's land or roof space are input diff --git a/src/constraints/battery_degradation.jl b/src/constraints/battery_degradation.jl index 4b31e5297..dda3cb797 100644 --- a/src/constraints/battery_degradation.jl +++ b/src/constraints/battery_degradation.jl @@ -1,13 +1,15 @@ # REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. -function add_degradation_variables(m, p) +function add_degradation_variables(m, p, segments) days = 1:365*p.s.financial.analysis_years @variable(m, Eavg[days] >= 0) - @variable(m, Eplus_sum[days] >= 0) - @variable(m, Eminus_sum[days] >= 0) + @variable(m, Eplus_sum[days, 1:segments] >= 0) # energy charged for each day for each segment level + @variable(m, Eminus_sum[days, 1:segments] >= 0) # energy discharged for each day for each segment level @variable(m, EFC[days] >= 0) @variable(m, SOH[days]) + @variable(m, dvSegmentChargePower[p.time_steps, 1:segments] >= 0) # charge power for each ts for each segment + @variable(m, dvSegmentDischargePower[p.time_steps, 1:segments] >= 0); # discharge power for each ts for each segment end @@ -15,6 +17,7 @@ function constrain_degradation_variables(m, p; b="ElectricStorage") days = 1:365*p.s.financial.analysis_years ts_per_day = 24 / p.hours_per_time_step ts_per_year = ts_per_day * 365 + J = length(p.s.storage.attr[b].degradation.cycle_fade_coefficient); # Number of segments ts0 = Dict() tsF = Dict() for d in days @@ -24,21 +27,41 @@ function constrain_degradation_variables(m, p; b="ElectricStorage") tsF[d] = Int(ts_per_day * 365) end end + @constraint(m, [d in days], m[:Eavg][d] == 1/ts_per_day * sum(m[:dvStoredEnergy][b, ts] for ts in ts0[d]:tsF[d]) ) + @constraint(m, [d in days], - m[:Eplus_sum][d] == - p.hours_per_time_step * ( - sum(m[:dvProductionToStorage][b, t, ts] for t in p.techs.elec, ts in ts0[d]:tsF[d]) - + sum(m[:dvGridToStorage][b, ts] for ts in ts0[d]:tsF[d]) - ) + m[:EFC][d] == sum(m[:Eplus_sum][d, j] + m[:Eminus_sum][d, j] for j in 1:J) / 2 ) - @constraint(m, [d in days], - m[:Eminus_sum][d] == p.hours_per_time_step * sum(m[:dvDischargeFromStorage][b, ts] for ts in ts0[d]:tsF[d]) + + # Power in equals power into storage from grid or local production + @constraint(m, [ts in p.time_steps], + sum(m[:dvSegmentChargePower][ts, j] for j in 1:J) == sum( + m[:dvProductionToStorage][b, t, ts] for t in p.techs.elec) + m[:dvGridToStorage][b, ts] ) - @constraint(m, [d in days], - m[:EFC][d] == (m[:Eplus_sum][d] + m[:Eminus_sum][d]) / 2 + + # Power out equals power discharged from storage to any destination + @constraint(m, [ts in p.time_steps], + sum(m[:dvSegmentDischargePower][ts, j] for j in 1:J) == m[:dvDischargeFromStorage][b, ts]); + + # Balance charging with daily e_plus, here is only collect all power across the day, so don't need to times efficiency + @constraint(m, [d in days, j in 1:J], m[:Eplus_sum][d, j] == sum(m[:dvSegmentChargePower][ts0[d]:tsF[d], j])*p.hours_per_time_step) + @constraint(m, [d in days, j in 1:J], m[:Eminus_sum][d, j] == sum(m[:dvSegmentDischargePower][ts0[d]:tsF[d], j])*p.hours_per_time_step); + #[az] we may want to adjust the notation to "ts, j for ts in ts0[d]:tsF[d] so it reads the same as the other constraints in REopt + + # energy limit, replace SOC limitation + @constraint( + m, + [ts in p.time_steps, j in 1:J], + m[:dvSegmentChargePower][ts, j]*p.hours_per_time_step <= p.s.storage.attr[b].degradation.cycle_fade_fraction[j]*m[:dvStorageEnergy][b] + ) + + @constraint( + m, + [ts in p.time_steps, j in 1:J], + m[:dvSegmentDischargePower][ts, j]*p.hours_per_time_step <= p.s.storage.attr[b].degradation.cycle_fade_fraction[j]*m[:dvStorageEnergy][b] ) end @@ -54,7 +77,7 @@ function add_degradation(m, p; b="ElectricStorage") # Indices days = 1:365*p.s.financial.analysis_years months = 1:p.s.financial.analysis_years*12 - + J = length(p.s.storage.attr[b].degradation.cycle_fade_coefficient); # Number of segments strategy = p.s.storage.attr[b].degradation.maintenance_strategy if isempty(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh) @@ -74,7 +97,7 @@ function add_degradation(m, p; b="ElectricStorage") throw(@error("The degradation maintenance_cost_per_kwh must have a length of $(length(days)-1).")) end - add_degradation_variables(m, p) + add_degradation_variables(m, p, J) constrain_degradation_variables(m, p, b=b) @constraint(m, [d in 2:days[end]], @@ -82,7 +105,7 @@ function add_degradation(m, p; b="ElectricStorage") p.s.storage.attr[b].degradation.calendar_fade_coefficient * p.s.storage.attr[b].degradation.time_exponent * m[:Eavg][d-1] * d^(p.s.storage.attr[b].degradation.time_exponent-1) + - p.s.storage.attr[b].degradation.cycle_fade_coefficient * m[:EFC][d-1] + sum(p.s.storage.attr[b].degradation.cycle_fade_coefficient[j] * m[:Eminus_sum][d-1, j] for j in 1:J) ) ) # NOTE SOH can be negative @@ -170,7 +193,10 @@ function add_degradation(m, p; b="ElectricStorage") maint_cost = sum(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[day*i] for i in 1:batt_replace_count) replacement_costs[mth] = maint_cost - residual_factor = 1 - (p.s.financial.analysis_years*12/mth - floor(p.s.financial.analysis_years*12/mth)) + # Calculate fraction of time remaining after analysis period ends where Batt will be healthy ("useful") + # Multiply by 0.2 to scale residual to BESS SOH (considered healthy if SOH is between 80% and 100%) + # Total BESS capacity residual is (0.8 + residual useful fraction) * BESS capacity + residual_factor = 0.2*(1 - (p.s.financial.analysis_years*12/mth - floor(p.s.financial.analysis_years*12/mth))) + 0.8 residual_value = p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[end]*residual_factor residual_values[mth] = residual_value end @@ -182,7 +208,7 @@ function add_degradation(m, p; b="ElectricStorage") @expression(m, residual_value, sum(residual_values[mth] * m[:dvSOHChangeTimesEnergy][mth] for mth in months)) elseif strategy == "augmentation" - + @info "Augmentation BESS degradation costs." @expression(m, degr_cost, sum( p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[d-1] * (m[:SOH][d-1] - m[:SOH][d]) diff --git a/src/core/energy_storage/electric_storage.jl b/src/core/energy_storage/electric_storage.jl index 76d321b2c..48d792161 100644 --- a/src/core/energy_storage/electric_storage.jl +++ b/src/core/energy_storage/electric_storage.jl @@ -5,9 +5,10 @@ Inputs used when `ElectricStorage.model_degradation` is `true`: ```julia Base.@kwdef mutable struct Degradation - calendar_fade_coefficient::Real = 2.55E-03 - cycle_fade_coefficient::Real = 9.83E-05 - time_exponent::Real = 0.42 + calendar_fade_coefficient::Real = 1.16E-03 + cycle_fade_coefficient::Vector{<:Real} = [2.46E-05] + cycle_fade_fraction::Vector{<:Real} = [1.0] + time_exponent::Real = 0.428 installed_cost_per_kwh_declination_rate::Real = 0.05 maintenance_strategy::String = "augmentation" # one of ["augmentation", "replacement"] maintenance_cost_per_kwh::Vector{<:Real} = Real[] @@ -34,7 +35,6 @@ worth factor is used in the same manner irrespective of the `maintenance_strateg When modeling degradation the following ElectricStorage inputs are not used: - `replace_cost_per_kwh` - `battery_replacement_year` - - `installed_cost_constant` - `replace_cost_constant` - `cost_constant_replacement_year` They are replaced by the `maintenance_cost_per_kwh` vector. @@ -135,9 +135,11 @@ The following shows how one would use the degradation model in REopt via the [Sc ... "model_degradation": true, "degradation": { - "calendar_fade_coefficient": 2.86E-03, - "cycle_fade_coefficient": 6.22E-05, - "installed_cost_per_kwh_declination_rate": 0.06, + "calendar_fade_coefficient": 1.16E-03, + "cycle_fade_coefficient": [2.46E-05], + "cycle_fade_fraction": [1.0], + "time_exponent": 0.428 + "installed_cost_per_kwh_declination_rate": 0.05, "maintenance_strategy": "replacement", ... } @@ -149,9 +151,10 @@ Note that not all of the above inputs are necessary. When not providing `calenda """ Base.@kwdef mutable struct Degradation - calendar_fade_coefficient::Real = 2.55E-03 - cycle_fade_coefficient::Real = 9.83E-05 - time_exponent::Real = 0.42 + calendar_fade_coefficient::Real = 1.16E-03 + cycle_fade_coefficient::Vector{<:Real} = [2.46E-05] + cycle_fade_fraction::Vector{<:Real} = [1.0] + time_exponent::Real = 0.428 installed_cost_per_kwh_declination_rate::Real = 0.05 maintenance_strategy::String = "augmentation" # one of ["augmentation", "replacement"] maintenance_cost_per_kwh::Vector{<:Real} = Real[] @@ -299,17 +302,6 @@ struct ElectricStorage <: AbstractElectricStorage @warn "Battery replacement costs (per_kwh) will not be considered because battery_replacement_year is greater than or equal to analysis_years." end - # copy the replace_costs in case we need to change them - replace_cost_per_kw = s.replace_cost_per_kw - replace_cost_per_kwh = s.replace_cost_per_kwh - if s.model_degradation - if haskey(d, :replace_cost_per_kwh) && d[:replace_cost_per_kwh] != 0.0 - @warn "Setting ElectricStorage replacement costs to zero. \nUsing degradation.maintenance_cost_per_kwh instead." - end - replace_cost_per_kwh = 0.0 # Always modeled using maintenance_cost_vector in degradation model. - # replace_cost_per_kw is unchanged here. - end - if s.min_duration_hours > s.max_duration_hours throw(@error("ElectricStorage min_duration_hours must be less than max_duration_hours.")) end @@ -323,7 +315,7 @@ struct ElectricStorage <: AbstractElectricStorage net_present_cost_per_kw = effective_cost(; itc_basis = s.installed_cost_per_kw, - replacement_cost = s.inverter_replacement_year >= f.analysis_years ? 0.0 : replace_cost_per_kw, + replacement_cost = s.inverter_replacement_year >= f.analysis_years ? 0.0 : s.replace_cost_per_kw, replacement_year = s.inverter_replacement_year, discount_rate = f.owner_discount_rate_fraction, tax_rate = f.owner_tax_rate_fraction, @@ -335,7 +327,7 @@ struct ElectricStorage <: AbstractElectricStorage ) net_present_cost_per_kwh = effective_cost(; itc_basis = s.installed_cost_per_kwh, - replacement_cost = s.battery_replacement_year >= f.analysis_years ? 0.0 : replace_cost_per_kwh, + replacement_cost = s.battery_replacement_year >= f.analysis_years ? 0.0 : s.replace_cost_per_kwh, replacement_year = s.battery_replacement_year, discount_rate = f.owner_discount_rate_fraction, tax_rate = f.owner_tax_rate_fraction, @@ -367,11 +359,17 @@ struct ElectricStorage <: AbstractElectricStorage if haskey(d, :degradation) degr = Degradation(;dictkeys_tosymbols(d[:degradation])...) + if length(degr.cycle_fade_coefficient) != length(degr.cycle_fade_fraction) + throw(@error("The fields cycle_fade_coefficient and cycle_fade_fraction in ElectricStorage Degradation inputs must have equal length.")) + end + if length(degr.cycle_fade_coefficient) > 1 + @info "Modeling segmented cycle fade battery degradation costing" + end else degr = Degradation() end - # copy the replace_costs in case we need to change them + # Handle replacement costs for degradation model. replace_cost_per_kw = s.replace_cost_per_kw replace_cost_per_kwh = s.replace_cost_per_kwh replace_cost_constant = s.replace_cost_constant diff --git a/src/core/reopt.jl b/src/core/reopt.jl index 0f8460eee..5b90675cb 100644 --- a/src/core/reopt.jl +++ b/src/core/reopt.jl @@ -428,15 +428,24 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) for b in p.s.storage.types.elec # ElectricStorageCapCost used for calculating O&M and is based on initial costs, not net present costs + # If costing battery degradation, omit installed_cost_per_kwh here, its accounted for in degr_cost expression m[:ElectricStorageCapCost] += ( - sum( p.s.storage.attr[b].installed_cost_per_kw * m[:dvStoragePower][b] for b in p.s.storage.types.elec) + - sum( p.s.storage.attr[b].installed_cost_per_kwh * m[:dvStorageEnergy][b] for b in p.s.storage.types.elec ) + p.s.storage.attr[b].installed_cost_per_kw * m[:dvStoragePower][b] + + p.s.storage.attr[b].installed_cost_per_kwh * m[:dvStorageEnergy][b] ) if (p.s.storage.attr[b].installed_cost_constant != 0) || (p.s.storage.attr[b].replace_cost_constant != 0) add_to_expression!(TotalStorageCapCosts, p.third_party_factor * sum(p.s.storage.attr[b].net_present_cost_cost_constant * m[:binIncludeStorageCostConstant][b] )) m[:ElectricStorageCapCost] += sum(p.s.storage.attr[b].installed_cost_constant * m[:binIncludeStorageCostConstant][b]) end m[:ElectricStorageOMCost] += p.third_party_factor * p.pwf_om * p.s.storage.attr[b].om_cost_fraction_of_installed_cost * m[:ElectricStorageCapCost] + + degr_bool = p.s.storage.attr[b].model_degradation + if degr_bool + @info "Battery energy capacity degradation costs for $b are being modeled using REopt's Degradation model. ElectricStorageOMCost will include costs to be incurred for power electronics and the cost constant." + add_to_expression!( + m[:ElectricStorageOMCost], -1.0 * p.third_party_factor * p.pwf_om * p.s.storage.attr[b].om_cost_fraction_of_installed_cost * p.s.storage.attr[b].installed_cost_per_kwh * m[:dvStorageEnergy][b] + ) + end end @expression(m, TotalPerUnitSizeOMCosts, p.third_party_factor * p.pwf_om * diff --git a/src/results/electric_storage.jl b/src/results/electric_storage.jl index 1810011e8..809b55816 100644 --- a/src/results/electric_storage.jl +++ b/src/results/electric_storage.jl @@ -9,7 +9,9 @@ # The following results are reported if storage degradation is modeled: - `state_of_health` - `maintenance_cost` -- `replacement_month` +- `replacement_month` # only applies is maintenance_strategy = "replacement" +- `residual_value` +- `total_residual_kwh` # only applies is maintenance_strategy = "replacement" !!! note "'Series' and 'Annual' energy outputs are average annual" REopt performs load balances using average annual production values for technologies that include degradation. @@ -36,15 +38,29 @@ function add_electric_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d:: p.s.storage.attr[b].installed_cost_constant if p.s.storage.attr[b].model_degradation - r["state_of_health"] = value.(m[:SOH]).data / value.(m[:dvStorageEnergy])["ElectricStorage"]; + r["state_of_health"] = round.(value.(m[:SOH]).data / value.(m[:dvStorageEnergy])["ElectricStorage"], digits=3) r["maintenance_cost"] = value(m[:degr_cost]) if p.s.storage.attr[b].degradation.maintenance_strategy == "replacement" r["replacement_month"] = round(Int, value( sum(mth * m[:binSOHIndicatorChange][mth] for mth in 1:p.s.financial.analysis_years*12) )) + # Calculate total healthy BESS capacity at end of analysis period. + # Determine fraction of useful life left assuming same replacement frequency. + # Multiply by 0.2 to scale residual useful life since entire BESS is replaced when SOH drops below 80%. + # Total BESS capacity residual is (0.8 + residual useful fraction) * BESS capacity + # If not replacements happen then useful capacity is SOH[end]*BESS capacity. + if iszero(r["replacement_month"]) + r["total_residual_kwh"] = r["state_of_health"][end]*r["size_kwh"] + else + # SOH[end] can be negative, so alternate method to calculate residual healthy SOH. + total_replacements = (p.s.financial.analysis_years*12)/r["replacement_month"] + r["total_residual_kwh"] = r["size_kwh"]*( + 0.2*(1 - (total_replacements - floor(total_replacements))) + 0.8 + ) + end end r["residual_value"] = value(m[:residual_value]) - end + end else r["soc_series_fraction"] = [] r["storage_to_load_series_kw"] = [] diff --git a/test/runtests.jl b/test/runtests.jl index 82402bc3d..f4ff88fb8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1219,50 +1219,51 @@ else # run HiGHS tests Commented out of this testset due to solve time constraints using open-source solvers. This test has been validated via local testing. =# - @testset "Battery degradation replacement strategy" begin - # Replacement - nothing - # d = JSON.parsefile("scenarios/batt_degradation.json"); - - # d["ElectricStorage"]["macrs_option_years"] = 0 - # d["ElectricStorage"]["macrs_bonus_fraction"] = 0.0 - # d["ElectricStorage"]["macrs_itc_reduction"] = 0.0 - # d["ElectricStorage"]["total_itc_fraction"] = 0.0 - # d["ElectricStorage"]["replace_cost_per_kwh"] = 0.0 - # d["ElectricStorage"]["replace_cost_per_kw"] = 0.0 - # d["Financial"] = Dict( - # "offtaker_tax_rate_fraction" => 0.0, - # "owner_tax_rate_fraction" => 0.0 - # ) - # d["ElectricStorage"]["degradation"]["installed_cost_per_kwh_declination_rate"] = 0.2 - - # d["Settings"] = Dict{Any,Any}("add_soc_incentive" => false) - - # s = Scenario(d) - # p = REoptInputs(s) - # for t in 1:4380 - # p.s.electric_tariff.energy_rates[2*t-1] = 0 - # p.s.electric_tariff.energy_rates[2*t] = 10.0 - # end - # m = Model(optimizer_with_attributes(Xpress.Optimizer, "OUTPUTLOG" => 0)) - # results = run_reopt(m, p) - - # @test results["ElectricStorage"]["size_kw"] ≈ 11.13 atol=0.05 - # @test results["ElectricStorage"]["size_kwh"] ≈ 14.07 atol=0.05 - # @test results["ElectricStorage"]["replacement_month"] == 8 - # @test results["ElectricStorage"]["maintenance_cost"] ≈ 32820.9 atol=1 - # @test results["ElectricStorage"]["state_of_health"][8760] ≈ -6.8239 atol=0.001 - # @test results["ElectricStorage"]["residual_value"] ≈ 2.61 atol=0.1 - # @test sum(results["ElectricStorage"]["storage_to_load_series_kw"]) ≈ 43800 atol=1.0 #battery should serve all load, every other period - - - # # Validate model decision variables make sense. - # replace_month = Int(value.(m[:months_to_first_replacement]))+1 - # @test replace_month ≈ results["ElectricStorage"]["replacement_month"] - # @test sum(value.(m[:binSOHIndicator])[replace_month:end]) ≈ 0.0 - # @test sum(value.(m[:binSOHIndicatorChange])) ≈ value.(m[:binSOHIndicatorChange])[replace_month] ≈ 1.0 - # @test value.(m[:binSOHIndicator])[end] ≈ 0.0 - end + # @testset "Battery degradation replacement strategy" begin + # # Replacement + # d = JSON.parsefile("scenarios/batt_degradation.json"); + + # d["ElectricStorage"]["macrs_option_years"] = 0 + # d["ElectricStorage"]["macrs_bonus_fraction"] = 0.0 + # d["ElectricStorage"]["macrs_itc_reduction"] = 0.0 + # d["ElectricStorage"]["total_itc_fraction"] = 0.0 + # d["ElectricStorage"]["replace_cost_per_kwh"] = 0.0 + # d["ElectricStorage"]["replace_cost_per_kw"] = 0.0 + # d["Financial"] = Dict( + # "offtaker_tax_rate_fraction" => 0.0, + # "owner_tax_rate_fraction" => 0.0 + # ) + # d["ElectricStorage"]["degradation"]["installed_cost_per_kwh_declination_rate"] = 0.2 + # d["Settings"] = Dict{Any,Any}("add_soc_incentive" => false) + + # s = Scenario(d) + # p = REoptInputs(s) + # for t in 1:4380 + # p.s.electric_tariff.energy_rates[2*t-1] = 0 + # p.s.electric_tariff.energy_rates[2*t] = 10.0 + # end + # m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false)) + # build_reopt!(m,p) + # fix(m[:binSOHIndicatorChange][29], 1.0) # Fix to simplify solving with HiGHS + # optimize!(m) + # results = reopt_results(m, p) + + # @test results["ElectricStorage"]["size_kw"] ≈ 11.13 atol=0.05 + # @test results["ElectricStorage"]["size_kwh"] ≈ 13.35 atol=0.05 + # @test results["ElectricStorage"]["replacement_month"] == 29 + # @test results["ElectricStorage"]["maintenance_cost"] ≈ 3481.2 atol=1 + # @test results["ElectricStorage"]["state_of_health"][8760] ≈ -0.972 atol=0.1 + # @test results["ElectricStorage"]["residual_value"] ≈ 2.53 atol=0.1 + # @test sum(results["ElectricStorage"]["storage_to_load_series_kw"]) ≈ 43800 atol=1.0 #battery should serve all load, every other period + + + # # Validate model decision variables make sense. + # replace_month = Int(value.(m[:months_to_first_replacement]))+1 + # @test replace_month ≈ results["ElectricStorage"]["replacement_month"] + # @test sum(value.(m[:binSOHIndicator])[replace_month:end]) ≈ 0.0 + # @test sum(value.(m[:binSOHIndicatorChange])) ≈ value.(m[:binSOHIndicatorChange])[replace_month] ≈ 1.0 + # @test value.(m[:binSOHIndicator])[end] ≈ 0.0 + # end @testset "Solar and ElectricStorage w/BAU and degradation" begin m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false))