Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
247f776
taken from h2-tldrd-mpc
adfarth Mar 10, 2025
690aadc
rename to clarify
adfarth Mar 10, 2025
19499d9
Merge branch 'develop' into fixed-bess-soc
adfarth Mar 17, 2025
564c89b
temp print statements
adfarth Mar 17, 2025
51fc70a
rm printlns
Mar 17, 2025
4cfb537
Update electric_storage.jl
Mar 17, 2025
6295eb1
update description and type
Mar 17, 2025
383f03d
allow for 0.02 buffer in soc
adfarth Mar 18, 2025
05e659a
add test
adfarth Mar 18, 2025
3d6530c
Update runtests.jl
adfarth Mar 19, 2025
d458a9c
Merge branch 'develop' into fixed-bess-soc
adfarth Apr 11, 2025
c661d5c
Update CHANGELOG.md
adfarth Apr 11, 2025
828a084
Merge branch 'develop' into fixed-bess-soc
adfarth Apr 15, 2025
ae46755
reduce to 1 pct rmv init_soc constraint
adfarth Apr 22, 2025
87c357f
Update runtests.jl
adfarth Apr 22, 2025
f044684
back to 2 pct
adfarth Apr 23, 2025
8a07806
Update runtests.jl
adfarth Apr 24, 2025
ad2563a
Merge branch 'develop' into fixed-bess-soc
adfarth Apr 24, 2025
24e89ca
Merge branch 'develop' into fixed-bess-soc
adfarth May 12, 2025
deb2cc6
Merge branch 'develop' into fixed-bess-soc
adfarth May 27, 2025
c26364b
Merge branch 'develop' into fixed-bess-soc
adfarth Jul 7, 2025
5d8f3e4
rm some fields of MPCElectricStorage from docstring
hdunham Mar 20, 2026
1909b02
Merge branch 'develop' into fixed-bess-soc
lixiangk1 Apr 3, 2026
a2555b8
Merge branch 'develop' into fixed-bess-soc
lixiangk1 Apr 16, 2026
a0f411d
updates from bess-export branch
adfarth Apr 16, 2026
8a89e6b
update tolerance type
adfarth Apr 16, 2026
77b2a69
update type in mpc
adfarth Apr 17, 2026
a13e311
account for julia float pt behavior in test
adfarth Apr 20, 2026
0f6d48f
Update runtests.jl
adfarth Apr 20, 2026
e74734b
Update CHANGELOG.md
adfarth Apr 21, 2026
9ab2de4
Merge branch 'develop' into fixed-bess-soc
adfarth Apr 21, 2026
be310d6
add check on length
adfarth Apr 22, 2026
38daa44
update test and add check
adfarth Apr 22, 2026
5980d84
reduce default tolerance
adfarth Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ Classify the change according to the following categories:
### Deprecated
### Removed

## hrly-gen-costs
## fixed-bess-soc
### Added
- Add **ElectricStorage** inputs field **fixed_soc_series_fraction** and **fixed_soc_series_fraction_tolerance** to allow users to fix the SOC timeseries within a chosen absolute tolerance
### Changed
- **ElectricStorage** **state_of_health** to **state_of_health_series_fraction**

## develop
### Added
- **Generator** **om_cost_per_hr_per_kw_rated**: Generator non-fuel variable operations and maintenance costs in \$/hr/kw_rated (default of 0.0)

Expand Down
35 changes: 18 additions & 17 deletions src/constraints/storage_constraints.jl
Original file line number Diff line number Diff line change
Expand Up @@ -105,38 +105,39 @@ function add_elec_storage_dispatch_constraints(m, p, b; _n="")
)
)

# Constraint (4i)-1: Dispatch to electrical storage is no greater than power capacity
@constraint(m, [ts in p.time_steps],
m[Symbol("dvStoragePower"*_n)][b] >=
sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec) + m[Symbol("dvGridToStorage"*_n)][b, ts]
)

#Constraint (4k)-alt: Dispatch to and from electrical storage is no greater than power capacity
@constraint(m, [ts in p.time_steps_with_grid],
m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][b, ts] +
sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec) + m[Symbol("dvGridToStorage"*_n)][b, ts]
)

#Constraint (4l)-alt: Dispatch from electrical storage is no greater than power capacity (no grid connection)
@constraint(m, [ts in p.time_steps_without_grid],
m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][b,ts] +
sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec)
# Constraint (4i): Dispatch to and from electrical storage is no greater than power capacity
@constraint(m, [ts in p.time_steps],
m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][b, ts]
+ sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec)
+ m[Symbol("dvGridToStorage"*_n)][b, ts]
)

# Remove grid-to-storage as an option if option to grid charge is turned off
# Constraint (4j): Remove grid-to-storage as an option if option to grid charge is turned off
if !(p.s.storage.attr[b].can_grid_charge)
for ts in p.time_steps_with_grid
fix(m[Symbol("dvGridToStorage"*_n)][b, ts], 0.0, force=true)
end
end

# Constraint (4k): 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)
@constraint(m, avg_soc >= p.s.storage.attr[b].minimum_avg_soc_fraction *
sum(m[Symbol("dvStorageEnergy"*_n)][b])
)
end

# Constraint (4l): 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)
# Allow for a percentage point (fractional) buffer on user-provided fixed_soc_series_fraction
@constraint(m, [ts in p.time_steps],
m[Symbol("dvStoredEnergy"*_n)][b, ts] <= (p.s.storage.attr[b].fixed_soc_series_fraction_tolerance + 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] >= (-p.s.storage.attr[b].fixed_soc_series_fraction_tolerance + p.s.storage.attr[b].fixed_soc_series_fraction[ts]) * m[Symbol("dvStorageEnergy"*_n)][b]
Comment thread
adfarth marked this conversation as resolved.
Comment thread
adfarth marked this conversation as resolved.
)
Comment on lines +131 to +139
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixed-SOC constraints only apply for ts in p.time_steps and do not constrain dvStoredEnergy[b, 0]. In MPC (and any case where soc_init_fraction isn’t auto-aligned to the fixed series), this can allow an inconsistent initial SOC or force a large first-step correction that may make the problem infeasible. Consider also constraining dvStoredEnergy[b, 0] to match the first fixed SOC value (within tolerance), or aligning soc_init_fraction automatically whenever a fixed series is provided.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

soc_init_fraction is set equal to fixed_soc_series_fraction[1] in electric_storage.jl. @lixiangk1 does this approach work for MPC?

end
end

function add_elec_storage_cost_constant_constraints(m, p, b; _n="")
Expand Down
2 changes: 1 addition & 1 deletion src/core/electric_tariff.jl
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ end
urdb_utility_name::String="",
urdb_rate_name::String="",
urdb_metadata::Dict=Dict(), # Meta data about the URDB rate, from the URDB API response
wholesale_rate::T1=nothing, # Price of electricity sold back to the grid in absence of net metering. Can be a scalar value, which applies for all-time, or an array with time-sensitive values. If an array is input then it must have a length of 8760, 17520, or 35040. The inputed array values are up/down-sampled using mean values to match the Settings.time_steps_per_hour.
wholesale_rate::T1=nothing, # Price of electricity [\$ per kWh] sold back to the grid in absence of net metering. Can be a scalar value, which applies for all-time, or an array with time-sensitive values. If an array is input then it must have a length of 8760, 17520, or 35040. The inputed array values are up/down-sampled using mean values to match the Settings.time_steps_per_hour.
export_rate_beyond_net_metering_limit::T2=nothing, # Price of electricity sold back to the grid beyond total annual grid purchases, regardless of net metering. Can be a scalar value, which applies for all-time, or an array with time-sensitive values. If an array is input then it must have a length of 8760, 17520, or 35040. The inputed array values are up/down-sampled using mean values to match the Settings.time_steps_per_hour
Comment thread
adfarth marked this conversation as resolved.
monthly_energy_rates::Array=[], # Array (length of 12) of blended energy rates in dollars per kWh
monthly_demand_rates::Array=[], # Array (length of 12) of blended demand charges in dollars per kW
Expand Down
47 changes: 37 additions & 10 deletions src/core/energy_storage/electric_storage.jl
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ end


"""
`ElectricStorage` is an optional optional REopt input with the following keys and default values:
`ElectricStorage` is an optional REopt input with the following keys and default values:

```julia
min_kw::Real = 0.0
Expand Down Expand Up @@ -201,6 +201,9 @@ end
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.
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)
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 +- the absolute fixed_soc_series_fraction_tolerance. Must be an array of values 0-1 with length equal to 8760*time_steps_per_hour.
fixed_soc_series_fraction_tolerance::Union{Nothing, Real} = !isnothing(fixed_soc_series_fraction) ? 0.02 : nothing # Absolute tolerance on fixed_soc_series_fraction to avoid infeasible solutions when fixed_soc_series_fraction is provided.

```
"""
Base.@kwdef struct ElectricStorageDefaults
Expand Down Expand Up @@ -241,6 +244,8 @@ 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
fixed_soc_series_fraction_tolerance::Union{Nothing, Real} = !isnothing(fixed_soc_series_fraction) ? 0.02 : nothing
end


Expand Down Expand Up @@ -290,8 +295,11 @@ struct ElectricStorage <: AbstractElectricStorage
optimize_soc_init_fraction::Bool
min_duration_hours::Real
max_duration_hours::Real

function ElectricStorage(d::Dict, f::Financial, s::Site)
fixed_soc_series_fraction::Union{Nothing, Array{<:Real,1}}
fixed_soc_series_fraction_tolerance::Union{Nothing, Real}


function ElectricStorage(d::Dict, f::Financial, s::Site, time_steps_per_hour::Int)
set_sector_defaults!(d; struct_name="Storage", sector=s.sector, federal_procurement_type=s.federal_procurement_type)
s = ElectricStorageDefaults(;d...)

Expand All @@ -307,6 +315,25 @@ 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_init_fraction = s.soc_init_fraction
soc_min_fraction = s.soc_min_fraction
optimize_soc_init_fraction = s.optimize_soc_init_fraction
minimum_avg_soc_fraction = s.minimum_avg_soc_fraction
fixed_soc_series_fraction = s.fixed_soc_series_fraction
if !isnothing(fixed_soc_series_fraction)
fixed_soc_series_fraction = check_and_adjust_load_length(fixed_soc_series_fraction, time_steps_per_hour, "ElectricStorage.fixed_soc_series_fraction") # using load function to clean this series.
@warn "Fixing ElectricStorage soc_series_fraction to the provided fixed_soc_series_fraction. Other SOC inputs will be ignored."
error_if_series_vals_not_0_to_1(fixed_soc_series_fraction, "ElectricStorage", "fixed_soc_series_fraction")
if s.fixed_soc_series_fraction_tolerance < 0
throw(@error("fixed_soc_series_fraction_tolerance must be non-negative."))
end
soc_init_fraction = fixed_soc_series_fraction[1]
soc_min_fraction = 0.0
optimize_soc_init_fraction = false
minimum_avg_soc_fraction = 0.0
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
Expand Down Expand Up @@ -341,7 +368,6 @@ struct ElectricStorage <: AbstractElectricStorage
net_present_cost_per_kwh -= s.total_rebate_per_kwh

if (s.installed_cost_constant != 0) || (s.replace_cost_constant != 0)

net_present_cost_cost_constant = effective_cost(;
itc_basis = s.installed_cost_constant,
replacement_cost = s.cost_constant_replacement_year >= f.analysis_years ? 0.0 : s.replace_cost_constant,
Expand All @@ -352,7 +378,6 @@ struct ElectricStorage <: AbstractElectricStorage
macrs_schedule = macrs_schedule,
macrs_bonus_fraction = s.macrs_bonus_fraction,
macrs_itc_reduction = s.macrs_itc_reduction

)
else
net_present_cost_cost_constant = 0
Expand Down Expand Up @@ -393,9 +418,9 @@ 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,
soc_init_fraction,
s.can_grid_charge,
s.installed_cost_per_kw,
s.installed_cost_per_kwh,
Expand All @@ -421,10 +446,12 @@ struct ElectricStorage <: AbstractElectricStorage
net_present_cost_cost_constant,
s.model_degradation,
degr,
s.minimum_avg_soc_fraction,
s.optimize_soc_init_fraction,
minimum_avg_soc_fraction,
optimize_soc_init_fraction,
s.min_duration_hours,
s.max_duration_hours
s.max_duration_hours,
fixed_soc_series_fraction,
s.fixed_soc_series_fraction_tolerance
)
end
end
6 changes: 3 additions & 3 deletions src/core/reopt.jl
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,8 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs)
fix(m[:dvGridPurchase][ts, tier] , 0.0, force=true)
end

for t in p.s.storage.types.elec
fix(m[:dvGridToStorage][t, ts], 0.0, force=true)
for b in p.s.storage.types.elec
fix(m[:dvGridToStorage][b, ts], 0.0, force=true)
end

if !isempty(p.s.electric_tariff.export_bins)
Expand Down Expand Up @@ -422,7 +422,7 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs)

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."
@info "Battery energy capacity degradation costs for $b are being modeled using REopt's Degradation model. ElectricStorageOMCost will include costs incurred for power electronics [per kW] and the cost constant, but not for the per kWh components."
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]
)
Expand Down
3 changes: 1 addition & 2 deletions src/core/scenario.jl
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ A Scenario struct can contain the following keys:
- [HotThermalStorage](@ref) (optional)
- [HighTempThermalStorage](@ref) (optional)
- [ColdThermalStorage](@ref) (optional)
- [ElectricStorage](@ref) (optional)
- [ElectricUtility](@ref) (optional)
- [Generator](@ref) (optional)
- [HeatingLoad](@ref) (optional)
Expand Down Expand Up @@ -208,7 +207,7 @@ function Scenario(d::Dict; flex_hvac_from_json=false)
else
storage_dict = Dict(:max_kw => 0.0)
end
storage_structs["ElectricStorage"] = ElectricStorage(storage_dict, financial, site)
storage_structs["ElectricStorage"] = ElectricStorage(storage_dict, financial, site, settings.time_steps_per_hour)
# TODO stop building ElectricStorage when it is not modeled by user
# (requires significant changes to constraints, variables)
if haskey(d, "HotThermalStorage")
Expand Down
5 changes: 4 additions & 1 deletion src/core/utils.jl
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,13 @@ function dictkeys_tosymbols(d::Dict)
"cooling_cop_reference",
"cooling_cf_reference",
"cooling_reference_temps_degF",
"cycle_fade_coefficient",
"cycle_fade_fraction",
#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)
Expand Down
4 changes: 2 additions & 2 deletions src/mpc/model.jl
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ function build_mpc!(m::JuMP.AbstractModel, p::MPCInputs)

fix(m[:dvGridPurchase][ts], 0.0, force=true)

for t in p.s.storage.types.elec
fix(m[:dvGridToStorage][t, ts], 0.0, force=true)
for b in p.s.storage.types.elec
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changing for consistency in how we index storage

fix(m[:dvGridToStorage][b, ts], 0.0, force=true)
end

for t in p.techs.elec, u in p.export_bins_by_tech[t]
Expand Down
4 changes: 4 additions & 0 deletions src/mpc/structs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ 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
fixed_soc_series_fraction::Union{Nothing, Array{<:Real,1}} = nothing
fixed_soc_series_fraction_tolerance::Union{Nothing, Real} = !isnothing(fixed_soc_series_fraction) ? 0.02 : nothing
end
```
"""
Expand All @@ -242,6 +244,8 @@ 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
fixed_soc_series_fraction_tolerance::Union{Nothing, Real} = !isnothing(fixed_soc_series_fraction) ? 0.02 : nothing
end


Expand Down
12 changes: 6 additions & 6 deletions src/results/electric_storage.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
`ElectricStorage` results keys:
- `size_kw` Optimal inverter capacity
- `size_kwh` Optimal storage capacity
- `soc_series_fraction` Vector of normalized (0-1) state of charge values over the first year
- `storage_to_load_series_kw` Vector of power used to meet load over the first year
- `soc_series_fraction` Vector of normalized (0-1) state of charge values over an average year
- `storage_to_load_series_kw` Vector of power used to meet load over an average year
- `initial_capital_cost` Upfront capital cost for storage and inverter
# The following results are reported if storage degradation is modeled:
- `state_of_health`
- `state_of_health_series_fraction`
- `maintenance_cost`
- `replacement_month` # only applies is maintenance_strategy = "replacement"
- `residual_value`
Expand Down Expand Up @@ -38,7 +38,7 @@ 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"] = round.(value.(m[:SOH]).data / value.(m[:dvStorageEnergy])["ElectricStorage"], digits=3)
r["state_of_health_series_fraction"] = 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(
Expand All @@ -48,9 +48,9 @@ function add_electric_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d::
# 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 no 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"]
r["total_residual_kwh"] = r["state_of_health_series_fraction"][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"]
Expand Down
2 changes: 1 addition & 1 deletion src/results/electric_utility.jl
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function add_electric_utility_results(m::JuMP.AbstractModel, p::AbstractInputs,
for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)
r["annual_energy_supplied_kwh"] = round(value(Year1UtilityEnergy), digits=2)

if !isempty(p.s.storage.types.elec)
if !isempty(p.s.storage.types.elec)
GridToLoad = (sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers)
- sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec)
for ts in p.time_steps)
Expand Down
Loading
Loading