Skip to content

Commit b22d9aa

Browse files
authored
Merge pull request #463 from NREL/develop
v0.48.2 Dec 2024
2 parents ec0a146 + 534e9f4 commit b22d9aa

15 files changed

+332
-159
lines changed

CHANGELOG.md

+16
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ Classify the change according to the following categories:
2525
### Deprecated
2626
### Removed
2727

28+
29+
## v0.48.2
30+
### Added
31+
- Battery residual value if choosing replacement strategy for degradation
32+
- Add new **ElectricStorage** parameters **max_duration_hours** and **min_duration_hours** to bound the energy duration of battery storage
33+
### Changed
34+
- Revised the battery degradation model, refactoring some methods to increase model-building efficiency and reformulating indicator constraints as big-M constraints with smaller big-M's to reduce solve time.
35+
- Edited several documentation entries and docstrings for clarity.
36+
### Removed
37+
- 80% scaling of battery maintenance costs when using augmentation strategy
38+
### Fixed
39+
- Fixed conditions for which a warning is presented indicating that the wholesale benefit threshold is met.
40+
- When setting **thermal_production_series_mmbtu_per_hour** output in **ExistingBoiler**, sum over heating loads instead of time steps
41+
2842
## v0.48.1
2943
### Changed
3044
- Replace all `1/p.s.settings.time_steps_per_hour` with `p.hours_per_time_step` for simplicity/consistency
@@ -48,6 +62,7 @@ Classify the change according to the following categories:
4862
- Added new file `src/core/ASHP.jl` with new technology **ASHP**, which uses electricity as input and provides heating and/or cooling as output; load balancing and technology-specific constraints have been updated and added accordingly
4963
- In `src/core/existing_chiller.jl`, Added new atttribute **retire_in_optimal** to the **ExistingChiller** struct
5064
- Financial output **initial_capital_costs_after_incentives_without_macrs** which has "net year one" CapEx after incentives except for MACRS, which helps with users defining their own "simple payback period"
65+
5166
### Changed
5267
- Improve the full test suite reporting with a verbose summary table, and update the structure to reflect long-term open-source solver usage.
5368
- Removed MacOS from the runner list and just run with Windows OS, since MacOS commonly freezes and gets cancelled. We have not seen Windows OS pass while other OS's fail.
@@ -64,6 +79,7 @@ Classify the change according to the following categories:
6479
- An issue with setup_boiler_inputs in reopt_inputs.jl.
6580
- Fuel costs in proforma.jl were not consistent with the optimization costs, so that was corrected so that they are only added to the offtaker cashflows and not the owner/developer cashflows for third party.
6681

82+
6783
## v0.47.2
6884
### Fixed
6985
- Increased the big-M bound on maximum net metering benefit to prevent artificially low export benefits.

Manifest.toml

+11-11
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
julia_version = "1.8.3"
44
manifest_format = "2.0"
5-
project_hash = "2e3f73051e60a5c2ffca2d998f6043d51c7f6c2a"
5+
project_hash = "6787ce6711da7a7bf012a2defdfff479e23ec7c6"
66

77
[[deps.AbstractFFTs]]
88
deps = ["ChainRulesCore", "LinearAlgebra", "Test"]
@@ -57,9 +57,9 @@ version = "1.21.4+0"
5757

5858
[[deps.Bzip2_jll]]
5959
deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"]
60-
git-tree-sha1 = "19a35467a82e236ff51bc17a3a44b69ef35185a2"
60+
git-tree-sha1 = "8873e196c2eb87962a2048b3b8e08946535864a1"
6161
uuid = "6e34b625-4abd-537c-b88f-471c36dfa7a0"
62-
version = "1.0.8+0"
62+
version = "1.0.8+2"
6363

6464
[[deps.CEnum]]
6565
git-tree-sha1 = "eb4cb44a499229b3b8426dcfb5dd85333951ff90"
@@ -513,9 +513,9 @@ version = "1.9.4+0"
513513

514514
[[deps.MPICH_jll]]
515515
deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "MPIPreferences", "TOML"]
516-
git-tree-sha1 = "8a5b4d2220377d1ece13f49438d71ad20cf1ba83"
516+
git-tree-sha1 = "2ee75365ca243c1a39d467e35ffd3d4d32eef11e"
517517
uuid = "7cb0a576-ebde-5e09-9194-50597f1243b4"
518-
version = "4.1.2+0"
518+
version = "4.1.2+1"
519519

520520
[[deps.MPIPreferences]]
521521
deps = ["Libdl", "Preferences"]
@@ -525,9 +525,9 @@ version = "0.1.9"
525525

526526
[[deps.MPItrampoline_jll]]
527527
deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "MPIPreferences", "TOML"]
528-
git-tree-sha1 = "6979eccb6a9edbbb62681e158443e79ecc0d056a"
528+
git-tree-sha1 = "8eeb3c73bbc0ca203d0dc8dad4008350bbe5797b"
529529
uuid = "f1f71cc9-e9ae-5b93-9b94-4fe0e1ad3748"
530-
version = "5.3.1+0"
530+
version = "5.3.1+1"
531531

532532
[[deps.MacroTools]]
533533
deps = ["Markdown", "Random"]
@@ -569,9 +569,9 @@ version = "1.4.1"
569569

570570
[[deps.MicrosoftMPI_jll]]
571571
deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"]
572-
git-tree-sha1 = "a7023883872e52bc29bcaac74f19adf39347d2d5"
572+
git-tree-sha1 = "bc95bf4149bf535c09602e3acdf950d9b4376227"
573573
uuid = "9237b28f-5490-5468-be7b-bb81f5f5e6cf"
574-
version = "10.1.4+0"
574+
version = "10.1.4+3"
575575

576576
[[deps.Missings]]
577577
deps = ["DataAPI"]
@@ -942,9 +942,9 @@ version = "100.700.100+0"
942942

943943
[[deps.libpng_jll]]
944944
deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg", "Zlib_jll"]
945-
git-tree-sha1 = "94d180a6d2b5e55e447e2d27a29ed04fe79eb30c"
945+
git-tree-sha1 = "f7c281e9c61905521993a987d38b5ab1d4b53bef"
946946
uuid = "b53b4c65-9356-5827-b1ea-8c7a1a84506f"
947-
version = "1.6.38+0"
947+
version = "1.6.38+1"
948948

949949
[[deps.nghttp2_jll]]
950950
deps = ["Artifacts", "Libdl"]

Project.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "REopt"
22
uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6"
33
authors = ["Nick Laws", "Hallie Dunham <[email protected]>", "Bill Becker <[email protected]>", "Bhavesh Rathod <[email protected]>", "Alex Zolan <[email protected]>", "Amanda Farthing <[email protected]>"]
4-
version = "0.48.1"
4+
version = "0.48.2"
55

66
[deps]
77
ArchGDAL = "c9ce4bd3-c3d5-55b8-8973-c0e20141b8c3"

src/constraints/battery_degradation.jl

+96-75
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,39 @@ function add_degradation_variables(m, p)
77
@variable(m, Eplus_sum[days] >= 0)
88
@variable(m, Eminus_sum[days] >= 0)
99
@variable(m, EFC[days] >= 0)
10+
@variable(m, SOH[days])
1011
end
1112

1213

1314
function constrain_degradation_variables(m, p; b="ElectricStorage")
1415
days = 1:365*p.s.financial.analysis_years
1516
ts_per_day = 24 / p.hours_per_time_step
1617
ts_per_year = ts_per_day * 365
18+
ts0 = Dict()
19+
tsF = Dict()
1720
for d in days
18-
ts0 = Int((ts_per_day * (d - 1) + 1) % ts_per_year)
19-
tsF = Int(ts_per_day * d % ts_per_year)
20-
if tsF == 0
21-
tsF = Int(ts_per_day * 365)
21+
ts0[d] = Int((ts_per_day * (d - 1) + 1) % ts_per_year)
22+
tsF[d] = Int(ts_per_day * d % ts_per_year)
23+
if tsF[d] == 0
24+
tsF[d] = Int(ts_per_day * 365)
2225
end
23-
@constraint(m,
24-
m[:Eavg][d] == 1/ts_per_day * sum(m[:dvStoredEnergy][b, ts] for ts in ts0:tsF)
25-
)
26-
@constraint(m,
27-
m[:Eplus_sum][d] ==
28-
p.hours_per_time_step * (
29-
sum(m[:dvProductionToStorage][b, t, ts] for t in p.techs.elec, ts in ts0:tsF)
30-
+ sum(m[:dvGridToStorage][b, ts] for ts in ts0:tsF)
31-
)
32-
)
33-
@constraint(m,
34-
m[:Eminus_sum][d] == p.hours_per_time_step * sum(m[:dvDischargeFromStorage][b, ts] for ts in ts0:tsF)
35-
)
36-
@constraint(m,
37-
m[:EFC][d] == (m[:Eplus_sum][d] + m[:Eminus_sum][d]) / 2
38-
)
3926
end
27+
@constraint(m, [d in days],
28+
m[:Eavg][d] == 1/ts_per_day * sum(m[:dvStoredEnergy][b, ts] for ts in ts0[d]:tsF[d])
29+
)
30+
@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+
)
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])
39+
)
40+
@constraint(m, [d in days],
41+
m[:EFC][d] == (m[:Eplus_sum][d] + m[:Eminus_sum][d]) / 2
42+
)
4043
end
4144

4245

@@ -47,33 +50,35 @@ NOTE the average SOC and EFC variables are in absolute units. For example, the S
4750
at the battery capacity in kWh.
4851
"""
4952
function add_degradation(m, p; b="ElectricStorage")
53+
54+
# Indices
5055
days = 1:365*p.s.financial.analysis_years
56+
months = 1:p.s.financial.analysis_years*12
57+
5158
strategy = p.s.storage.attr[b].degradation.maintenance_strategy
5259

5360
if isempty(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh)
54-
function pwf(day::Int)
61+
# Correctly account for discount rate and install cost declination rate for days over analysis period
62+
function pwf_bess_replacements(day::Int)
5563
(1-p.s.storage.attr[b].degradation.installed_cost_per_kwh_declination_rate)^(day/365) /
5664
(1+p.s.financial.owner_discount_rate_fraction)^(day/365)
5765
end
58-
# for the augmentation strategy the maintenance cost curve (function of time) starts at
59-
# 80% of the installed cost since we are not replacing the entire battery
60-
f = strategy == "augmentation" ? 0.8 : 1.0
61-
p.s.storage.attr[b].degradation.maintenance_cost_per_kwh = [ f *
62-
p.s.storage.attr[b].installed_cost_per_kwh * pwf(d) for d in days[1:end-1]
66+
p.s.storage.attr[b].degradation.maintenance_cost_per_kwh = [
67+
p.s.storage.attr[b].installed_cost_per_kwh * pwf_bess_replacements(d) for d in days[1:end-1]
6368
]
6469
end
6570

66-
@assert(length(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh) == length(days) - 1,
67-
"The degradation maintenance_cost_per_kwh must have a length of $(length(days)-1)."
68-
)
69-
70-
@variable(m, SOH[days])
71+
# Under augmentation scenario, each day's battery augmentation cost is calculated using day-1 value from maintenance_cost_per_kwh vector
72+
# Therefore, on last day, day-1's maintenance cost is utilized.
73+
if length(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh) != length(days) - 1
74+
throw(@error("The degradation maintenance_cost_per_kwh must have a length of $(length(days)-1)."))
75+
end
7176

7277
add_degradation_variables(m, p)
7378
constrain_degradation_variables(m, p, b=b)
7479

7580
@constraint(m, [d in 2:days[end]],
76-
SOH[d] == SOH[d-1] - p.hours_per_time_step * (
81+
m[:SOH][d] == m[:SOH][d-1] - p.hours_per_time_step * (
7782
p.s.storage.attr[b].degradation.calendar_fade_coefficient *
7883
p.s.storage.attr[b].degradation.time_exponent *
7984
m[:Eavg][d-1] * d^(p.s.storage.attr[b].degradation.time_exponent-1) +
@@ -82,7 +87,7 @@ function add_degradation(m, p; b="ElectricStorage")
8287
)
8388
# NOTE SOH can be negative
8489

85-
@constraint(m, SOH[1] == m[:dvStorageEnergy][b])
90+
@constraint(m, m[:SOH][1] == m[:dvStorageEnergy][b])
8691
# NOTE SOH is _not_ normalized, and has units of kWh
8792

8893
if strategy == "replacement"
@@ -100,29 +105,9 @@ function add_degradation(m, p; b="ElectricStorage")
100105
The first month that the battery is replaced is determined by d_0p8, which is the integer
101106
number of days that the SOH is at least 80% of the purchased capacity.
102107
We define a binary for each month and only allow one month to be chosen.
103-
=#
104-
105-
# define d_0p8
106-
@warn "Adding binary and indicator constraints for
107-
ElectricStorage.degradation.maintenance_strategy = \"replacement\".
108-
Not all solvers support indicators and some are slow with integers."
109-
# TODO import the latest battery degradation model in the degradation branch
110-
@variable(m, soh_indicator[days], Bin)
111-
@constraint(m, [d in days],
112-
soh_indicator[d] => {SOH[d] >= 0.8*m[:dvStorageEnergy][b]}
113-
)
114-
@expression(m, d_0p8, sum(soh_indicator[d] for d in days))
115-
116-
# define binaries for the finding the month that battery must be replaced
117-
months = 1:p.s.financial.analysis_years*12
118-
@variable(m, bmth[months], Bin)
119-
# can only pick one month (or no month if SOH is >= 80% in last day)
120-
@constraint(m, sum(bmth[mth] for mth in months) == 1-soh_indicator[length(days)])
121-
# the month picked is at most the month in which the SOH hits 80%
122-
@constraint(m, sum(mth*bmth[mth] for mth in months) <= d_0p8 / 30.42)
123-
# 30.42 is the average number of days in a month
124108
125-
#=
109+
# maintenance_cost_per_kwh must have length == length(days) - 1, i.e. starts on day 2
110+
126111
number of replacments as function of d_0p8
127112
^
128113
|
@@ -139,41 +124,77 @@ function add_degradation(m, p; b="ElectricStorage")
139124
140125
The above curve is multiplied by the maintenance_cost_per_kwh to create the cost coefficients
141126
=#
142-
c = zeros(length(months)) # initialize cost coefficients
143-
N = 365*p.s.financial.analysis_years
127+
128+
@warn "Adding binary decision variables for
129+
ElectricStorage.degradation.maintenance_strategy = \"replacement\".
130+
Some solvers are slow with integers."
131+
132+
@variable(m, binSOHIndicator[months], Bin) # track SOH levels, should be 1 if SOH >= 80%, 0 otherwise
133+
@variable(m, binSOHIndicatorChange[months], Bin) # track which month SOH indicator drops to < 80%
134+
@variable(m, 0 <= dvSOHChangeTimesEnergy[months]) # track the kwh to be replaced in a replacement month
135+
136+
# the big M
137+
if p.s.storage.attr[b].max_kwh == 1.0e6 || p.s.storage.attr[b].max_kwh == 0
138+
# Under default max_kwh (i.e. not modeling large batteries) or max_kwh = 0
139+
bigM_StorageEnergy = 24*maximum(p.s.electric_load.loads_kw)
140+
else
141+
# Select the larger value of maximum electric load or provided max_kwh size.
142+
bigM_StorageEnergy = max(24*maximum(p.s.electric_load.loads_kw), p.s.storage.attr[b].max_kwh)
143+
end
144+
145+
# HEALTHY: if binSOHIndicator is 1, then SOH >= 80%. If binSOHIndicator is 0 and SOH >= very negative number
146+
@constraint(m, [mth in months], m[:SOH][Int(round(30.4167*mth))] >= 0.8*m[:dvStorageEnergy][b] - bigM_StorageEnergy * (1-binSOHIndicator[mth]))
147+
148+
# UNHEALTHY: if binSOHIndicator is 1, then SOH <= large number. If binSOHIndicator is 0 and SOH <= 80%
149+
@constraint(m, [mth in months], m[:SOH][Int(round(30.4167*mth))] <= 0.8*m[:dvStorageEnergy][b] + bigM_StorageEnergy * (binSOHIndicator[mth]))
150+
151+
# binSOHIndicatorChange[mth] = binSOHIndicator[mth-1] - binSOHIndicator[mth].
152+
# If replacement month is x, then binSOHIndicatorChange[x] = 1. All other binSOHIndicatorChange values will be 0s (either 1-1 or 0-0)
153+
@constraint(m, m[:binSOHIndicatorChange][1] == 1 - m[:binSOHIndicator][1])
154+
@constraint(m, [mth in 2:months[end]], m[:binSOHIndicatorChange][mth] == m[:binSOHIndicator][mth-1] - m[:binSOHIndicator][mth])
155+
156+
@expression(m, months_to_first_replacement, sum(m[:binSOHIndicator][mth] for mth in months))
157+
158+
# -> linearize the product of binSOHIndicatorChange & m[:dvStorageEnergy][b]
159+
@constraint(m, [mth in months], m[:dvSOHChangeTimesEnergy][mth] >= m[:dvStorageEnergy][b] - bigM_StorageEnergy * (1 - m[:binSOHIndicatorChange][mth]))
160+
@constraint(m, [mth in months], m[:dvSOHChangeTimesEnergy][mth] <= m[:dvStorageEnergy][b] + bigM_StorageEnergy * (1 - m[:binSOHIndicatorChange][mth]))
161+
@constraint(m, [mth in months], m[:dvSOHChangeTimesEnergy][mth] <= bigM_StorageEnergy * m[:binSOHIndicatorChange][mth])
162+
163+
replacement_costs = zeros(length(months)) # initialize cost coefficients
164+
residual_values = zeros(length(months)) # initialize cost coefficients for residual_value
165+
N = 365*p.s.financial.analysis_years # number of days
166+
144167
for mth in months
145-
day = Int(round((mth-1)*30.42 + 15, digits=0))
146-
c[mth] = p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[day] *
147-
ceil(N/day - 1)
168+
day = Int(round((mth-1)*30.4167 + 15, digits=0))
169+
batt_replace_count = Int(ceil(N/day - 1)) # number of battery replacements in analysis period if they periodically happened on "day"
170+
maint_cost = sum(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[day*i] for i in 1:batt_replace_count)
171+
replacement_costs[mth] = maint_cost
172+
173+
residual_factor = 1 - (p.s.financial.analysis_years*12/mth - floor(p.s.financial.analysis_years*12/mth))
174+
residual_value = p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[end]*residual_factor
175+
residual_values[mth] = residual_value
148176
end
149177

150-
# linearize the product of bmth & m[:dvStorageEnergy][b]
151-
M = p.s.storage.attr[b].max_kwh # the big M
152-
@variable(m, 0 <= bmth_BkWh[months])
153-
@constraint(m, [mth in months], bmth_BkWh[mth] <= m[:dvStorageEnergy][b])
154-
@constraint(m, [mth in months], bmth_BkWh[mth] <= M * bmth[mth])
155-
@constraint(m, [mth in months], bmth_BkWh[mth] >= m[:dvStorageEnergy][b] - M*(1-bmth[mth]))
178+
# create replacement cost expression for objective
179+
@expression(m, degr_cost, sum(replacement_costs[mth] * m[:dvSOHChangeTimesEnergy][mth] for mth in months))
156180

157-
# add replacment cost to objective
158-
@expression(m, degr_cost,
159-
sum(c[mth] * bmth_BkWh[mth] for mth in months)
160-
)
181+
# create residual value expression for objective
182+
@expression(m, residual_value, sum(residual_values[mth] * m[:dvSOHChangeTimesEnergy][mth] for mth in months))
161183

162184
elseif strategy == "augmentation"
163185

164186
@expression(m, degr_cost,
165187
sum(
166-
p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[d-1] * (SOH[d-1] - SOH[d])
188+
p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[d-1] * (m[:SOH][d-1] - m[:SOH][d])
167189
for d in days[2:end]
168190
)
169191
)
170-
# add augmentation cost to objective
171-
# maintenance_cost_per_kwh must have length == length(days) - 1, i.e. starts on day 2
192+
193+
# No lifetime based residual value assigned to battery under the augmentation strategy
194+
@expression(m, residual_value, 0.0)
172195
else
173196
throw(@error("Battery maintenance strategy $strategy is not supported. Choose from augmentation and replacement."))
174197
end
175-
176-
@objective(m, Min, m[:Costs] + m[:degr_cost])
177198

178199
# NOTE adding to Costs expression does not modify the objective function
179200
end

src/constraints/storage_constraints.jl

+13
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ function add_storage_size_constraints(m, p, b; _n="")
2121
@constraint(m,
2222
m[Symbol("dvStoragePower"*_n)][b] <= p.s.storage.attr[b].max_kw
2323
)
24+
25+
# Constraint (4c)-3: Limit on ElectricStorage Energy Capacity based on Duration Hours
26+
if p.s.storage.attr[b] isa ElectricStorage
27+
@constraint(m,
28+
m[Symbol("dvStorageEnergy"*_n)][b] <= m[Symbol("dvStoragePower"*_n)][b] * p.s.storage.attr[b].max_duration_hours
29+
)
30+
31+
@constraint(m,
32+
m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoragePower"*_n)][b] * p.s.storage.attr[b].min_duration_hours
33+
)
34+
end
35+
36+
2437
end
2538

2639

0 commit comments

Comments
 (0)