Skip to content

Commit d50dd5c

Browse files
authored
Merge pull request #492 from NREL/segmented_cycle_degr
Add segmented cycle degradation
2 parents 23b4d46 + 8273f69 commit d50dd5c

6 files changed

Lines changed: 150 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ Classify the change according to the following categories:
2525
### Deprecated
2626
### Removed
2727

28+
## Develop
29+
### Added
30+
- Added constraints in `src/constraints/battery_degradation.jl` to allow use of segmented cycle fade coefficients in the model.
31+
- Added **cycle_fade_fraction** as an input for portion of BESS that is tied to each cycle fade coefficient.
32+
- Added **total_residual_kwh** output which captures healthy residual battery capacity due to degradation and the replacement strategy
33+
### Changed
34+
- Changed **cycle_fade_coefficient** input to be a vector and accept vector of inputs.
35+
- Changed default inputs for degradation module to match parameters for NMC-Gr Kokam 75Ah cell.
36+
- Changed residual battery fraction calculation to calculate useful healthy capacity for residual value and capacity calculations.
37+
2838
## v0.53.2
2939
### Fixed
3040
- `PV` `size_class` and cost defaults not updating correctly when both `max_kw` and the site's land or roof space are input

src/constraints/battery_degradation.jl

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE.
22

33

4-
function add_degradation_variables(m, p)
4+
function add_degradation_variables(m, p, segments)
55
days = 1:365*p.s.financial.analysis_years
66
@variable(m, Eavg[days] >= 0)
7-
@variable(m, Eplus_sum[days] >= 0)
8-
@variable(m, Eminus_sum[days] >= 0)
7+
@variable(m, Eplus_sum[days, 1:segments] >= 0) # energy charged for each day for each segment level
8+
@variable(m, Eminus_sum[days, 1:segments] >= 0) # energy discharged for each day for each segment level
99
@variable(m, EFC[days] >= 0)
1010
@variable(m, SOH[days])
11+
@variable(m, dvSegmentChargePower[p.time_steps, 1:segments] >= 0) # charge power for each ts for each segment
12+
@variable(m, dvSegmentDischargePower[p.time_steps, 1:segments] >= 0); # discharge power for each ts for each segment
1113
end
1214

1315

1416
function constrain_degradation_variables(m, p; b="ElectricStorage")
1517
days = 1:365*p.s.financial.analysis_years
1618
ts_per_day = 24 / p.hours_per_time_step
1719
ts_per_year = ts_per_day * 365
20+
J = length(p.s.storage.attr[b].degradation.cycle_fade_coefficient); # Number of segments
1821
ts0 = Dict()
1922
tsF = Dict()
2023
for d in days
@@ -24,21 +27,41 @@ function constrain_degradation_variables(m, p; b="ElectricStorage")
2427
tsF[d] = Int(ts_per_day * 365)
2528
end
2629
end
30+
2731
@constraint(m, [d in days],
2832
m[:Eavg][d] == 1/ts_per_day * sum(m[:dvStoredEnergy][b, ts] for ts in ts0[d]:tsF[d])
2933
)
34+
3035
@constraint(m, [d in days],
31-
m[:Eplus_sum][d] ==
32-
p.hours_per_time_step * (
33-
sum(m[:dvProductionToStorage][b, t, ts] for t in p.techs.elec, ts in ts0[d]:tsF[d])
34-
+ sum(m[:dvGridToStorage][b, ts] for ts in ts0[d]:tsF[d])
35-
)
36+
m[:EFC][d] == sum(m[:Eplus_sum][d, j] + m[:Eminus_sum][d, j] for j in 1:J) / 2
3637
)
37-
@constraint(m, [d in days],
38-
m[:Eminus_sum][d] == p.hours_per_time_step * sum(m[:dvDischargeFromStorage][b, ts] for ts in ts0[d]:tsF[d])
38+
39+
# Power in equals power into storage from grid or local production
40+
@constraint(m, [ts in p.time_steps],
41+
sum(m[:dvSegmentChargePower][ts, j] for j in 1:J) == sum(
42+
m[:dvProductionToStorage][b, t, ts] for t in p.techs.elec) + m[:dvGridToStorage][b, ts]
3943
)
40-
@constraint(m, [d in days],
41-
m[:EFC][d] == (m[:Eplus_sum][d] + m[:Eminus_sum][d]) / 2
44+
45+
# Power out equals power discharged from storage to any destination
46+
@constraint(m, [ts in p.time_steps],
47+
sum(m[:dvSegmentDischargePower][ts, j] for j in 1:J) == m[:dvDischargeFromStorage][b, ts]);
48+
49+
# Balance charging with daily e_plus, here is only collect all power across the day, so don't need to times efficiency
50+
@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)
51+
@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);
52+
#[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
53+
54+
# energy limit, replace SOC limitation
55+
@constraint(
56+
m,
57+
[ts in p.time_steps, j in 1:J],
58+
m[:dvSegmentChargePower][ts, j]*p.hours_per_time_step <= p.s.storage.attr[b].degradation.cycle_fade_fraction[j]*m[:dvStorageEnergy][b]
59+
)
60+
61+
@constraint(
62+
m,
63+
[ts in p.time_steps, j in 1:J],
64+
m[:dvSegmentDischargePower][ts, j]*p.hours_per_time_step <= p.s.storage.attr[b].degradation.cycle_fade_fraction[j]*m[:dvStorageEnergy][b]
4265
)
4366
end
4467

@@ -54,7 +77,7 @@ function add_degradation(m, p; b="ElectricStorage")
5477
# Indices
5578
days = 1:365*p.s.financial.analysis_years
5679
months = 1:p.s.financial.analysis_years*12
57-
80+
J = length(p.s.storage.attr[b].degradation.cycle_fade_coefficient); # Number of segments
5881
strategy = p.s.storage.attr[b].degradation.maintenance_strategy
5982

6083
if isempty(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh)
@@ -74,15 +97,15 @@ function add_degradation(m, p; b="ElectricStorage")
7497
throw(@error("The degradation maintenance_cost_per_kwh must have a length of $(length(days)-1)."))
7598
end
7699

77-
add_degradation_variables(m, p)
100+
add_degradation_variables(m, p, J)
78101
constrain_degradation_variables(m, p, b=b)
79102

80103
@constraint(m, [d in 2:days[end]],
81104
m[:SOH][d] == m[:SOH][d-1] - p.hours_per_time_step * (
82105
p.s.storage.attr[b].degradation.calendar_fade_coefficient *
83106
p.s.storage.attr[b].degradation.time_exponent *
84107
m[:Eavg][d-1] * d^(p.s.storage.attr[b].degradation.time_exponent-1) +
85-
p.s.storage.attr[b].degradation.cycle_fade_coefficient * m[:EFC][d-1]
108+
sum(p.s.storage.attr[b].degradation.cycle_fade_coefficient[j] * m[:Eminus_sum][d-1, j] for j in 1:J)
86109
)
87110
)
88111
# NOTE SOH can be negative
@@ -170,7 +193,10 @@ function add_degradation(m, p; b="ElectricStorage")
170193
maint_cost = sum(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[day*i] for i in 1:batt_replace_count)
171194
replacement_costs[mth] = maint_cost
172195

173-
residual_factor = 1 - (p.s.financial.analysis_years*12/mth - floor(p.s.financial.analysis_years*12/mth))
196+
# Calculate fraction of time remaining after analysis period ends where Batt will be healthy ("useful")
197+
# Multiply by 0.2 to scale residual to BESS SOH (considered healthy if SOH is between 80% and 100%)
198+
# Total BESS capacity residual is (0.8 + residual useful fraction) * BESS capacity
199+
residual_factor = 0.2*(1 - (p.s.financial.analysis_years*12/mth - floor(p.s.financial.analysis_years*12/mth))) + 0.8
174200
residual_value = p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[end]*residual_factor
175201
residual_values[mth] = residual_value
176202
end
@@ -182,7 +208,7 @@ function add_degradation(m, p; b="ElectricStorage")
182208
@expression(m, residual_value, sum(residual_values[mth] * m[:dvSOHChangeTimesEnergy][mth] for mth in months))
183209

184210
elseif strategy == "augmentation"
185-
211+
@info "Augmentation BESS degradation costs."
186212
@expression(m, degr_cost,
187213
sum(
188214
p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[d-1] * (m[:SOH][d-1] - m[:SOH][d])

src/core/energy_storage/electric_storage.jl

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
Inputs used when `ElectricStorage.model_degradation` is `true`:
66
```julia
77
Base.@kwdef mutable struct Degradation
8-
calendar_fade_coefficient::Real = 2.55E-03
9-
cycle_fade_coefficient::Real = 9.83E-05
10-
time_exponent::Real = 0.42
8+
calendar_fade_coefficient::Real = 1.16E-03
9+
cycle_fade_coefficient::Vector{<:Real} = [2.46E-05]
10+
cycle_fade_fraction::Vector{<:Real} = [1.0]
11+
time_exponent::Real = 0.428
1112
installed_cost_per_kwh_declination_rate::Real = 0.05
1213
maintenance_strategy::String = "augmentation" # one of ["augmentation", "replacement"]
1314
maintenance_cost_per_kwh::Vector{<:Real} = Real[]
@@ -34,7 +35,6 @@ worth factor is used in the same manner irrespective of the `maintenance_strateg
3435
When modeling degradation the following ElectricStorage inputs are not used:
3536
- `replace_cost_per_kwh`
3637
- `battery_replacement_year`
37-
- `installed_cost_constant`
3838
- `replace_cost_constant`
3939
- `cost_constant_replacement_year`
4040
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
135135
...
136136
"model_degradation": true,
137137
"degradation": {
138-
"calendar_fade_coefficient": 2.86E-03,
139-
"cycle_fade_coefficient": 6.22E-05,
140-
"installed_cost_per_kwh_declination_rate": 0.06,
138+
"calendar_fade_coefficient": 1.16E-03,
139+
"cycle_fade_coefficient": [2.46E-05],
140+
"cycle_fade_fraction": [1.0],
141+
"time_exponent": 0.428
142+
"installed_cost_per_kwh_declination_rate": 0.05,
141143
"maintenance_strategy": "replacement",
142144
...
143145
}
@@ -149,9 +151,10 @@ Note that not all of the above inputs are necessary. When not providing `calenda
149151
150152
"""
151153
Base.@kwdef mutable struct Degradation
152-
calendar_fade_coefficient::Real = 2.55E-03
153-
cycle_fade_coefficient::Real = 9.83E-05
154-
time_exponent::Real = 0.42
154+
calendar_fade_coefficient::Real = 1.16E-03
155+
cycle_fade_coefficient::Vector{<:Real} = [2.46E-05]
156+
cycle_fade_fraction::Vector{<:Real} = [1.0]
157+
time_exponent::Real = 0.428
155158
installed_cost_per_kwh_declination_rate::Real = 0.05
156159
maintenance_strategy::String = "augmentation" # one of ["augmentation", "replacement"]
157160
maintenance_cost_per_kwh::Vector{<:Real} = Real[]
@@ -299,17 +302,6 @@ struct ElectricStorage <: AbstractElectricStorage
299302
@warn "Battery replacement costs (per_kwh) will not be considered because battery_replacement_year is greater than or equal to analysis_years."
300303
end
301304

302-
# copy the replace_costs in case we need to change them
303-
replace_cost_per_kw = s.replace_cost_per_kw
304-
replace_cost_per_kwh = s.replace_cost_per_kwh
305-
if s.model_degradation
306-
if haskey(d, :replace_cost_per_kwh) && d[:replace_cost_per_kwh] != 0.0
307-
@warn "Setting ElectricStorage replacement costs to zero. \nUsing degradation.maintenance_cost_per_kwh instead."
308-
end
309-
replace_cost_per_kwh = 0.0 # Always modeled using maintenance_cost_vector in degradation model.
310-
# replace_cost_per_kw is unchanged here.
311-
end
312-
313305
if s.min_duration_hours > s.max_duration_hours
314306
throw(@error("ElectricStorage min_duration_hours must be less than max_duration_hours."))
315307
end
@@ -323,7 +315,7 @@ struct ElectricStorage <: AbstractElectricStorage
323315

324316
net_present_cost_per_kw = effective_cost(;
325317
itc_basis = s.installed_cost_per_kw,
326-
replacement_cost = s.inverter_replacement_year >= f.analysis_years ? 0.0 : replace_cost_per_kw,
318+
replacement_cost = s.inverter_replacement_year >= f.analysis_years ? 0.0 : s.replace_cost_per_kw,
327319
replacement_year = s.inverter_replacement_year,
328320
discount_rate = f.owner_discount_rate_fraction,
329321
tax_rate = f.owner_tax_rate_fraction,
@@ -335,7 +327,7 @@ struct ElectricStorage <: AbstractElectricStorage
335327
)
336328
net_present_cost_per_kwh = effective_cost(;
337329
itc_basis = s.installed_cost_per_kwh,
338-
replacement_cost = s.battery_replacement_year >= f.analysis_years ? 0.0 : replace_cost_per_kwh,
330+
replacement_cost = s.battery_replacement_year >= f.analysis_years ? 0.0 : s.replace_cost_per_kwh,
339331
replacement_year = s.battery_replacement_year,
340332
discount_rate = f.owner_discount_rate_fraction,
341333
tax_rate = f.owner_tax_rate_fraction,
@@ -367,11 +359,17 @@ struct ElectricStorage <: AbstractElectricStorage
367359

368360
if haskey(d, :degradation)
369361
degr = Degradation(;dictkeys_tosymbols(d[:degradation])...)
362+
if length(degr.cycle_fade_coefficient) != length(degr.cycle_fade_fraction)
363+
throw(@error("The fields cycle_fade_coefficient and cycle_fade_fraction in ElectricStorage Degradation inputs must have equal length."))
364+
end
365+
if length(degr.cycle_fade_coefficient) > 1
366+
@info "Modeling segmented cycle fade battery degradation costing"
367+
end
370368
else
371369
degr = Degradation()
372370
end
373371

374-
# copy the replace_costs in case we need to change them
372+
# Handle replacement costs for degradation model.
375373
replace_cost_per_kw = s.replace_cost_per_kw
376374
replace_cost_per_kwh = s.replace_cost_per_kwh
377375
replace_cost_constant = s.replace_cost_constant

src/core/reopt.jl

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,15 +428,24 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs)
428428

429429
for b in p.s.storage.types.elec
430430
# ElectricStorageCapCost used for calculating O&M and is based on initial costs, not net present costs
431+
# If costing battery degradation, omit installed_cost_per_kwh here, its accounted for in degr_cost expression
431432
m[:ElectricStorageCapCost] += (
432-
sum( p.s.storage.attr[b].installed_cost_per_kw * m[:dvStoragePower][b] for b in p.s.storage.types.elec) +
433-
sum( p.s.storage.attr[b].installed_cost_per_kwh * m[:dvStorageEnergy][b] for b in p.s.storage.types.elec )
433+
p.s.storage.attr[b].installed_cost_per_kw * m[:dvStoragePower][b] +
434+
p.s.storage.attr[b].installed_cost_per_kwh * m[:dvStorageEnergy][b]
434435
)
435436
if (p.s.storage.attr[b].installed_cost_constant != 0) || (p.s.storage.attr[b].replace_cost_constant != 0)
436437
add_to_expression!(TotalStorageCapCosts, p.third_party_factor * sum(p.s.storage.attr[b].net_present_cost_cost_constant * m[:binIncludeStorageCostConstant][b] ))
437438
m[:ElectricStorageCapCost] += sum(p.s.storage.attr[b].installed_cost_constant * m[:binIncludeStorageCostConstant][b])
438439
end
439440
m[:ElectricStorageOMCost] += p.third_party_factor * p.pwf_om * p.s.storage.attr[b].om_cost_fraction_of_installed_cost * m[:ElectricStorageCapCost]
441+
442+
degr_bool = p.s.storage.attr[b].model_degradation
443+
if degr_bool
444+
@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."
445+
add_to_expression!(
446+
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]
447+
)
448+
end
440449
end
441450

442451
@expression(m, TotalPerUnitSizeOMCosts, p.third_party_factor * p.pwf_om *

src/results/electric_storage.jl

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
# The following results are reported if storage degradation is modeled:
1010
- `state_of_health`
1111
- `maintenance_cost`
12-
- `replacement_month`
12+
- `replacement_month` # only applies is maintenance_strategy = "replacement"
13+
- `residual_value`
14+
- `total_residual_kwh` # only applies is maintenance_strategy = "replacement"
1315
1416
!!! note "'Series' and 'Annual' energy outputs are average annual"
1517
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::
3638
p.s.storage.attr[b].installed_cost_constant
3739

3840
if p.s.storage.attr[b].model_degradation
39-
r["state_of_health"] = value.(m[:SOH]).data / value.(m[:dvStorageEnergy])["ElectricStorage"];
41+
r["state_of_health"] = round.(value.(m[:SOH]).data / value.(m[:dvStorageEnergy])["ElectricStorage"], digits=3)
4042
r["maintenance_cost"] = value(m[:degr_cost])
4143
if p.s.storage.attr[b].degradation.maintenance_strategy == "replacement"
4244
r["replacement_month"] = round(Int, value(
4345
sum(mth * m[:binSOHIndicatorChange][mth] for mth in 1:p.s.financial.analysis_years*12)
4446
))
47+
# Calculate total healthy BESS capacity at end of analysis period.
48+
# Determine fraction of useful life left assuming same replacement frequency.
49+
# Multiply by 0.2 to scale residual useful life since entire BESS is replaced when SOH drops below 80%.
50+
# Total BESS capacity residual is (0.8 + residual useful fraction) * BESS capacity
51+
# If not replacements happen then useful capacity is SOH[end]*BESS capacity.
52+
if iszero(r["replacement_month"])
53+
r["total_residual_kwh"] = r["state_of_health"][end]*r["size_kwh"]
54+
else
55+
# SOH[end] can be negative, so alternate method to calculate residual healthy SOH.
56+
total_replacements = (p.s.financial.analysis_years*12)/r["replacement_month"]
57+
r["total_residual_kwh"] = r["size_kwh"]*(
58+
0.2*(1 - (total_replacements - floor(total_replacements))) + 0.8
59+
)
60+
end
4561
end
4662
r["residual_value"] = value(m[:residual_value])
47-
end
63+
end
4864
else
4965
r["soc_series_fraction"] = []
5066
r["storage_to_load_series_kw"] = []

0 commit comments

Comments
 (0)