Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
a57d024
Merge branch 'develop' into dispatch-options
adfarth Apr 21, 2026
5b38812
initial structure
adfarth Apr 21, 2026
4b00424
Update electric_storage.jl
adfarth Apr 21, 2026
be310d6
add check on length
adfarth Apr 22, 2026
38daa44
update test and add check
adfarth Apr 22, 2026
d82365b
Merge branch 'fixed-bess-soc' into dispatch-options
adfarth Apr 22, 2026
406f655
Update electric_storage.jl
adfarth Apr 22, 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]
)
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
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
133 changes: 103 additions & 30 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 All @@ -172,10 +172,7 @@ end
internal_efficiency_fraction::Float64 = 0.975
inverter_efficiency_fraction::Float64 = 0.96
rectifier_efficiency_fraction::Float64 = 0.96
soc_min_fraction::Float64 = 0.2
soc_min_applies_during_outages::Bool = false
soc_init_fraction::Float64 = off_grid_flag ? 1.0 : 0.5
can_grid_charge::Bool = off_grid_flag ? false : true
can_grid_charge::Bool = off_grid_flag ? false : true # TODO: is this relevant for all dispatch strategies?
installed_cost_per_kw::Real = 968.0 # Cost of power components (e.g., inverter and BOS)
installed_cost_per_kwh::Real = 253.0 # Cost of energy components (e.g., battery pack)
installed_cost_constant::Real = 222115.0 # "+c" constant cost that is added to total ElectricStorage installed costs if a battery is included. Accounts for costs not expected to scale with power or energy capacity.
Expand All @@ -196,12 +193,31 @@ end
discharge_efficiency::Float64 = inverter_efficiency_fraction * internal_efficiency_fraction^0.5
grid_charge_efficiency::Float64 = can_grid_charge ? charge_efficiency : 0.0
model_degradation::Bool = false
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.
degradation::Dict = Dict()
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)
```

# Dispatch-related inputs
dispatch_strategy::String = "optimized" # can be one of ["optimized", "peak_shaving", "self_consumption", "backup", "custom_soc"] # Note: "daily_foresight_optimized" is available only via the REopt API
# SOC inputs relevant if dispatch_strategy = "optimized", "peak_shaving", "self_consumption", or "backup" #TODO: confirm this.
soc_min_fraction::Float64 = dispatch_strategy == "backup" ? 0.8 : 0.2
soc_min_applies_during_outages::Bool = false
soc_init_fraction::Float64 = off_grid_flag ? 1.0 : 0.5
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.
# SOC inputs relevant if dispatch_strategy = "custom_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 +- the absolute fixed_soc_series_fraction_tolerance (this buffer is to avoid infeasible solutions)
fixed_soc_series_fraction_tolerance::Union{Nothing, Real} = !isnothing(fixed_soc_series_fraction) ? 0.05 : nothing # Absolute tolerance on fixed_soc_series_fraction to avoid infeasible solutions when fixed_soc_series_fraction is provided.

!!! note "Dispatch Strategy Options"
The following dispatch strategies are available via the `dispatch_strategy` input:
- `optimized`: Storage dispatch is optimized to minimize the total lifecycle cost of energy for the site. The model has perfect foresight into loads and modeled variable generation potential over the entire year.
- `peak_shaving`: Uses SAM's Peak Shaving dispatch heuristic. To use this option, users MUST specify BESS (and PV if included) sizing (by setting min and max values) # TODO: Xiang to update
- `self_consumption`: To use this option, users MUST specify BESS (and PV if included) sizing (by setting min and max values) # TODO: Xiang to update
- `backup`: Storage is reserved to meet load during grid outages by changing the default soc_min_fraction to 0.8.
- `daily_foresight_optimized`: This option is only available via the REopt API (not available in REopt.jl)
- `custom_soc`: User must provide a fixed_soc_series_fraction and can optionally tailor the fixed_soc_series_fraction_tolerance.

"""
Base.@kwdef struct ElectricStorageDefaults
off_grid_flag::Bool = false
Expand All @@ -212,10 +228,7 @@ Base.@kwdef struct ElectricStorageDefaults
internal_efficiency_fraction::Float64 = 0.975
inverter_efficiency_fraction::Float64 = 0.96
rectifier_efficiency_fraction::Float64 = 0.96
soc_min_fraction::Float64 = 0.2
soc_min_applies_during_outages::Bool = false
soc_init_fraction::Float64 = off_grid_flag ? 1.0 : 0.5
can_grid_charge::Bool = off_grid_flag ? false : true
can_grid_charge::Bool = off_grid_flag ? false : true # TODO: is this relevant for all dispatch strategies?
installed_cost_per_kw::Real = 968.0
installed_cost_per_kwh::Real = 253.0
installed_cost_constant::Real = 222115.0
Expand All @@ -237,10 +250,16 @@ Base.@kwdef struct ElectricStorageDefaults
grid_charge_efficiency::Float64 = can_grid_charge ? charge_efficiency : 0.0
model_degradation::Bool = false
degradation::Dict = Dict()
minimum_avg_soc_fraction::Float64 = 0.0
optimize_soc_init_fraction::Bool = false
min_duration_hours::Real = 0.0
max_duration_hours::Real = 100000.0
dispatch_strategy::String = "optimized" # can be one of ["optimized", "peak_shaving", "self_consumption", "backup", "custom_soc"]
soc_min_fraction::Float64 = dispatch_strategy == "backup" ? 0.8 : 0.2
soc_min_applies_during_outages::Bool = false
soc_init_fraction::Float64 = off_grid_flag ? 1.0 : 0.5
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 +- the absolute fixed_soc_series_fraction_tolerance (this buffer is to avoid infeasible solutions)
fixed_soc_series_fraction_tolerance::Union{Nothing, Real} = !isnothing(fixed_soc_series_fraction) ? 0.05 : nothing # Absolute tolerance on fixed_soc_series_fraction to avoid infeasible solutions when fixed_soc_series_fraction is provided.
end


Expand All @@ -258,9 +277,6 @@ struct ElectricStorage <: AbstractElectricStorage
internal_efficiency_fraction::Float64
inverter_efficiency_fraction::Float64
rectifier_efficiency_fraction::Float64
soc_min_fraction::Float64
soc_min_applies_during_outages::Bool
soc_init_fraction::Float64
can_grid_charge::Bool
installed_cost_per_kw::Real
installed_cost_per_kwh::Real
Expand All @@ -286,12 +302,19 @@ struct ElectricStorage <: AbstractElectricStorage
net_present_cost_cost_constant::Real
model_degradation::Bool
degradation::Degradation
minimum_avg_soc_fraction::Float64
optimize_soc_init_fraction::Bool
min_duration_hours::Real
max_duration_hours::Real

function ElectricStorage(d::Dict, f::Financial, s::Site)
dispatch_strategy::String
soc_min_fraction::Float64
soc_min_applies_during_outages::Bool
soc_init_fraction::Float64
minimum_avg_soc_fraction::Float64
optimize_soc_init_fraction::Bool
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 +330,55 @@ struct ElectricStorage <: AbstractElectricStorage
throw(@error("ElectricStorage min_duration_hours must be less than max_duration_hours."))
end

# Dispatch validation
valid_dispatch_strategies = ["optimized", "peak_shaving", "self_consumption", "backup", "custom_soc"]
if !(s.dispatch_strategy in valid_dispatch_strategies)
throw(@error("ElectricStorage dispatch_strategy must be one of the following: $(valid_dispatch_strategies)"))
end
if s.dispatch_strategy == "custom_soc" && isnothing(s.fixed_soc_series_fraction)
throw(@error("ElectricStorage fixed_soc_series_fraction must be provided when dispatch_strategy is custom_soc."))
end
if s.dispatch_strategy != "custom_soc" && !isnothing(s.fixed_soc_series_fraction)
@warn "Updating ElectricStorage dispatch_strategy to custom_soc since fixed_soc_series_fraction is provided."
s.dispatch_strategy = "custom_soc"
end
requires_fixed_sizing = ["peak_shaving", "self_consumption"]
# TODO: Add checks on PV sizing
if s.dispatch_strategy in requires_fixed_sizing && (s.min_kw != s.max_kw || s.min_kwh != s.max_kwh || s.max_kw == 0 || s.max_kwh == 0)
throw(@error("ElectricStorage dispatch_strategy $(s.dispatch_strategy) requires fixed non-zero storage sizing. Please fix the sizing by setting min_kw=max_kw, and min_kwh=max_kwh."))
end


# Call SAM for peak_shaving and self_consumption dispatch strategies
if s.dispatch_strategy == "peak_shaving"
@info "Using SAM Peak Shaving dispatch strategy for ElectricStorage."
# TODO: Call SAM here?
# fixed_soc_series_fraction = SAM output
elseif s.dispatch_strategy == "self_consumption"
@info "Using SAM Self Consumption dispatch strategy for ElectricStorage."
# TODO: Call SAM here?
# fixed_soc_series_fraction = SAM output
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 +413,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 +423,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 +463,6 @@ struct ElectricStorage <: AbstractElectricStorage
s.internal_efficiency_fraction,
s.inverter_efficiency_fraction,
s.rectifier_efficiency_fraction,
s.soc_min_fraction,
s.soc_min_applies_during_outages,
s.soc_init_fraction,
s.can_grid_charge,
s.installed_cost_per_kw,
s.installed_cost_per_kwh,
Expand All @@ -421,10 +488,16 @@ struct ElectricStorage <: AbstractElectricStorage
net_present_cost_cost_constant,
s.model_degradation,
degr,
s.minimum_avg_soc_fraction,
s.optimize_soc_init_fraction,
s.min_duration_hours,
s.max_duration_hours
s.max_duration_hours,
s.dispatch_strategy,
soc_min_fraction,
s.soc_min_applies_during_outages,
soc_init_fraction,
minimum_avg_soc_fraction,
optimize_soc_init_fraction,
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
Loading
Loading