Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"size_classes": [
{
"size_class": 1,
"name": "Residential",
"size_class_bounds_kw": [0, 40],
"installed_cost_per_kw": 705,
"installed_cost_per_kwh": 616,
"installed_cost_constant": 0,
"notes": "Based on ATB 2025 Residential costs modeled for a 12.5 kWh 4-hour battery system in 2025 dollars"
},
{
"size_class": 2,
"name": "Commercial",
"size_class_bounds_kw": [40, 400],
"installed_cost_per_kw": 676,
"installed_cost_per_kwh": 560,
"installed_cost_constant": 0,
"notes": "Interpolated costs from ATB 2025 Residential and Commercial battery system costs in 2025 dollars"
},
{
"size_class": 3,
"name": "Large-Commercial",
"size_class_bounds_kw": [400,1.0e6],
"installed_cost_per_kw": 527,
"installed_cost_per_kwh": 278,
"installed_cost_constant": 0,
"notes": "Based on ATB 2025 Commercial costs modeled for a 7200 kWh 1800 kW battery system in 2025 dollars"
}
]
}
235 changes: 214 additions & 21 deletions src/core/energy_storage/electric_storage.jl
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,17 @@ end
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
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.
# installed_cost_per_kw::Real = 968.0 # Cost of power components (e.g., inverter and BOS)
installed_cost_per_kw::Union{Real, Nothing} = nothing # defaults to average cost for determined size class.
installed_cost_per_kwh::Union{Real, Nothing} = nothing # Cost of energy components (e.g., battery pack)
installed_cost_constant::Union{Real, Nothing} = nothing # "+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.
replace_cost_per_kw::Real = 0.0
replace_cost_per_kwh::Real = 0.0
replace_cost_constant::Real = 0.0
inverter_replacement_year::Int = 10
battery_replacement_year::Int = 10
cost_constant_replacement_year::Int = 10
om_cost_fraction_of_installed_cost::Float64 = 0.025 # Annual O&M cost as a fraction of installed cost
om_cost_fraction_of_installed_cost::Float64 = 0.04 # Annual O&M cost as a fraction of installed cost
macrs_option_years::Int = 5 #Note: default may change if Site.sector is not "commercial/industrial"
macrs_bonus_fraction::Float64 = 1.0 #Note: default may change if Site.sector is not "commercial/industrial"
macrs_itc_reduction::Float64 = 0.5
Expand All @@ -203,7 +204,9 @@ end
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.

size_class::Union{Int, Nothing} = nothing, # Size class for cost curve selection
electric_load_annual_peak::Real = 0.0, # Annual electric load peak (kW) for size class determination
electric_load_average::Real = 0.0, # Annual electric load average (kW) for size class determination
```
"""
Base.@kwdef struct ElectricStorageDefaults
Expand All @@ -219,16 +222,17 @@ Base.@kwdef struct ElectricStorageDefaults
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
installed_cost_per_kw::Real = 968.0
installed_cost_per_kwh::Real = 253.0
installed_cost_constant::Real = 222115.0
# installed_cost_per_kw::Real = 968.0 # Cost of power components (e.g., inverter and BOS)
installed_cost_per_kw::Union{Real, Nothing} = nothing # defaults to average cost for determined size class.
installed_cost_per_kwh::Union{Real, Nothing} = nothing # Cost of energy components (e.g., battery pack)
installed_cost_constant::Union{Real, Nothing} = nothing # "+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.
replace_cost_per_kw::Real = 0.0
replace_cost_per_kwh::Real = 0.0
replace_cost_constant::Real = 0.0
inverter_replacement_year::Int = 10
battery_replacement_year::Int = 10
cost_constant_replacement_year::Int = 10
om_cost_fraction_of_installed_cost::Float64 = 0.025
om_cost_fraction_of_installed_cost::Float64 = 0.04
macrs_option_years::Int = 5
macrs_bonus_fraction::Float64 = 1.0
macrs_itc_reduction::Float64 = 0.5
Expand All @@ -246,6 +250,9 @@ Base.@kwdef struct ElectricStorageDefaults
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
size_class::Union{Int, Nothing} = nothing # Size class for cost curve selection
electric_load_annual_peak::Real = 0.0 # Annual electric load peak (kW) for size class determination
electric_load_average::Real = 0.0 # Annual electric load average (kW) for size class determination
end


Expand All @@ -267,9 +274,9 @@ struct ElectricStorage <: AbstractElectricStorage
soc_min_applies_during_outages::Bool
soc_init_fraction::Float64
can_grid_charge::Bool
installed_cost_per_kw::Real
installed_cost_per_kwh::Real
installed_cost_constant::Real
installed_cost_per_kw::Union{Real, Nothing}
installed_cost_per_kwh::Union{Real, Nothing}
installed_cost_constant::Union{Real, Nothing}
replace_cost_per_kw::Real
replace_cost_per_kwh::Real
replace_cost_constant::Real
Expand Down Expand Up @@ -297,8 +304,11 @@ struct ElectricStorage <: AbstractElectricStorage
max_duration_hours::Real
fixed_soc_series_fraction::Union{Nothing, Array{<:Real,1}}
fixed_soc_series_fraction_tolerance::Union{Nothing, Real}


size_class::Union{Int, Nothing}
size_class_bounds_kw::AbstractVector
electric_load_annual_peak::Real
electric_load_average::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)
stor = ElectricStorageDefaults(;d...)
Expand Down Expand Up @@ -341,8 +351,20 @@ struct ElectricStorage <: AbstractElectricStorage
throw(@error("ElectricStorage macrs_option_years must be 0, 5, or 7."))
end

installed_cost_per_kw, installed_cost_per_kwh, installed_cost_constant, size_class,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The O&M cost % is included in the defaults .json, implying that it changes by size_class, but we're not assigning it by size_class here. I'm fine to take it out of that file and just used a fixed default of 2.5% (will become 4% from the new ATB). ATB uses the same % for all three of their size_classes

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.

Makes sense, since the degradation wear and tear will vary by cycling and not size class.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The (new) 2025 ATB has 4% as the O&M as a fraction of total installed cost, so we should update that as well (does not change with size class) from the current 2.5%. I will comment on the actual variable to change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@rathod-b Here's the link to the input (default value and in docstring) to be changed to 0.04, from 0.025:

om_cost_fraction_of_installed_cost::Float64 = 0.025 # Annual O&M cost as a fraction of installed cost

size_kw_for_size_class, size_class_bounds_kw = get_electric_storage_cost_params(;
installed_cost_per_kw = stor.installed_cost_per_kw,
installed_cost_per_kwh = stor.installed_cost_per_kwh,
installed_cost_constant = stor.installed_cost_constant,
size_class = stor.size_class,
electric_load_annual_peak = stor.electric_load_annual_peak,
electric_load_average = stor.electric_load_average,
min_kw = stor.min_kw,
max_kw = stor.max_kw
)

net_present_cost_per_kw = effective_cost(;
itc_basis = stor.installed_cost_per_kw,
itc_basis = installed_cost_per_kw,
replacement_cost = stor.inverter_replacement_year >= f.analysis_years ? 0.0 : stor.replace_cost_per_kw,
replacement_year = stor.inverter_replacement_year,
discount_rate = f.owner_discount_rate_fraction,
Expand All @@ -354,7 +376,7 @@ struct ElectricStorage <: AbstractElectricStorage
rebate_per_kw = stor.total_rebate_per_kw
)
net_present_cost_per_kwh = effective_cost(;
itc_basis = stor.installed_cost_per_kwh,
itc_basis = installed_cost_per_kwh,
replacement_cost = stor.battery_replacement_year >= f.analysis_years ? 0.0 : stor.replace_cost_per_kwh,
replacement_year = stor.battery_replacement_year,
discount_rate = f.owner_discount_rate_fraction,
Expand All @@ -369,7 +391,7 @@ struct ElectricStorage <: AbstractElectricStorage

if (stor.installed_cost_constant != 0) || (stor.replace_cost_constant != 0)
net_present_cost_cost_constant = effective_cost(;
itc_basis = stor.installed_cost_constant,
itc_basis = installed_cost_constant,
replacement_cost = stor.cost_constant_replacement_year >= f.analysis_years ? 0.0 : stor.replace_cost_constant,
replacement_year = stor.cost_constant_replacement_year,
discount_rate = f.owner_discount_rate_fraction,
Expand Down Expand Up @@ -422,9 +444,9 @@ struct ElectricStorage <: AbstractElectricStorage
stor.soc_min_applies_during_outages,
soc_init_fraction,
stor.can_grid_charge,
stor.installed_cost_per_kw,
stor.installed_cost_per_kwh,
stor.installed_cost_constant,
installed_cost_per_kw,
installed_cost_per_kwh,
installed_cost_constant,
replace_cost_per_kw,
replace_cost_per_kwh,
replace_cost_constant,
Expand All @@ -451,7 +473,178 @@ struct ElectricStorage <: AbstractElectricStorage
stor.min_duration_hours,
stor.max_duration_hours,
fixed_soc_series_fraction,
stor.fixed_soc_series_fraction_tolerance
stor.fixed_soc_series_fraction_tolerance,
size_class,
size_class_bounds_kw,
stor.electric_load_annual_peak,
stor.electric_load_average
)
end
end

"""
get_electric_storage_cost_params(; installed_cost_per_kw, installed_cost_per_kwh, installed_cost_constant,
size_class, min_kw, max_kw, electric_load_annual_peak, electric_load_average)

Processes and determines the cost scaling parameters for a Battery system, including installed cost per kW,
installed cost per kWh, installed cost constant and size class.

# Arguments
- `installed_cost_per_kw`::Union{Real, Nothing} = Nothing,
- `installed_cost_per_kwh`::Union{Real, Nothing} = Nothing,
- `installed_cost_constant`::Union{Real, Nothing} = Nothing,
- `size_class`::Union{Int, Nothing} = Nothing,
- `min_kw`::Real = 0.0,
- `max_kw`::Real = 1.0e9,
- `electric_load_annual_peak`::Real = 0.0,
- `electric_load_average`::Real = 0.0

# Returns
Values:
1. `installed_cost_per_kw`: Final installed cost per kW.
2. `installed_cost_per_kwh`: Final installed cost per kWh.
3. `installed_cost_constant`: Final installed cost constant.
4. `size_class`: Determined size class.
5. `size_kw_for_size_class`: Calculated size_kw used to determine size class.

# Notes
- If `size_class` is not provided, it is determined based on (peak demand - average demand) or user-provided cost data.
- Handles both single-value and multi-point cost curves for installed and O&M costs.

"""
function get_electric_storage_cost_params(;
installed_cost_per_kw::Union{Real, Nothing} = Nothing,
installed_cost_per_kwh::Union{Real, Nothing} = Nothing,
installed_cost_constant::Union{Real, Nothing} = Nothing,
size_class::Union{Int, Nothing} = Nothing,
min_kw::Real = 0.0,
max_kw::Real = 1.0e9,
electric_load_annual_peak::Real = 0.0,
electric_load_average::Real = 0.0
)

# Get defaults and determine mount type
defaults = get_electric_storage_defaults_size_class()

# Initialize variables needed for processing
local determined_size_class
local size_kw_for_size_class = max_kw

# STEP 1: Determine size class
determined_size_class = if !isnothing(size_class)
# User explicitly set size class - validate boundaries
if size_class < 1
@warn "Size class $size_class is less than 1, using size class 1 instead"
1
elseif size_class > length(defaults)
@warn "Size class $size_class exceeds maximum ($(length(defaults))), using largest size class instead"
length(defaults)
else
size_class
end
elseif typeof(installed_cost_per_kw) <: Real
# Single cost value provided - size class not needed
size_class
else
# Default case: no costs or size_class information provided.
kw_tech_sizes = [c["size_class_bounds_kw"] for c in defaults]
size_class, size_kw_for_size_class = get_electric_storage_size_class(
electric_load_annual_peak,
electric_load_average,
kw_tech_sizes;
min_kw=min_kw,
max_kw=max_kw
)
size_class
end

# Get default data for determined size class
class_defaults = if !isnothing(determined_size_class)
matching_default = findfirst(d -> d["size_class"] == determined_size_class, defaults)
if isnothing(matching_default)
throw(ErrorException("Could not find matching defaults for size class $(determined_size_class)"))
end
defaults[matching_default]
end

installed_cost_constant = isnothing(installed_cost_constant) ? 0 : installed_cost_constant

# STEP 2: Handle installed costs
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should be able to remove/avoid all this validation and separate handling for scalar versus array cost parameters, since we're not allowing an array for storage.

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.

Yup, I corrected those instances already.

installed_cost_per_kw, installed_cost_per_kwh, installed_cost_constant = if (
typeof(installed_cost_per_kw) <: Real && typeof(installed_cost_per_kwh) <: Real && typeof(installed_cost_constant) <: Real
)
# Single cost value provided by user
convert(Float64, installed_cost_per_kw), convert(Float64, installed_cost_per_kwh), convert(Float64, installed_cost_constant)
elseif !isnothing(class_defaults)
class_defaults["installed_cost_per_kw"], class_defaults["installed_cost_per_kwh"], class_defaults["installed_cost_constant"]
else
throw(ErrorException("No installed costs provided and no size class determined"))
end

size_class_bounds_kw = if !isnothing(determined_size_class) && !isnothing(class_defaults)
convert(Vector{Float64}, class_defaults["size_class_bounds_kw"])
else
Float64[]
end

return installed_cost_per_kw, installed_cost_per_kwh, installed_cost_constant, determined_size_class, round(size_kw_for_size_class, digits=0), size_class_bounds_kw
end

# TODO combine functions to load size class defaults for eligible techs.
# Load ElectricStorage default size class data from JSON file
function get_electric_storage_defaults_size_class()
electric_storage_defaults_path = joinpath(@__DIR__, "..", "..", "..", "data", "energy_storage", "electric_storage", "electric_storage_defaults.json")
if !isfile(electric_storage_defaults_path)
throw(ErrorException("electric_storage_defaults.json not found at path: $electric_storage_defaults_path"))
end

electric_storage_defaults_all = JSON.parsefile(electric_storage_defaults_path)
return electric_storage_defaults_all["size_classes"]
end


# Determine appropriate size class based on system parameters
function get_electric_storage_size_class(
electric_load_annual_peak::Real,
electric_load_average::Real,
size_class_bounds_kw::AbstractVector;
min_kw::Real=0.0,
max_kw::Real=1.0e9
)

size_class_kw = nothing
size_kw = nothing

# Estimate size based on electric load and estimated (max_kw - avg_kw) value
kw_for_sizing = electric_load_annual_peak - electric_load_average
# if default min/max kw have been updated, factor those in.
# Do we need 2 size_kw here to factor in a wide size range that spreads over multiple size classes?
if max_kw != 1.0e9
size_kw = min(kw_for_sizing, max_kw)
end
if min_kw != 0.0
size_kw = max(kw_for_sizing, min_kw)
end
if isnothing(size_kw)
size_kw = kw_for_sizing
end
# Find the appropriate kw size class for the effective size
for (i, size_range) in enumerate(size_class_bounds_kw)
min_size = convert(Float64, size_range[1])
max_size = convert(Float64, size_range[2])

if size_kw >= min_size && size_kw <= max_size
size_class_kw = i
end
end
if isnothing(size_class_kw)
# Handle edge cases -> highest size class returned.
if size_kw > convert(Float64, size_class_bounds_kw[end][2])
size_class_kw = length(size_class_bounds_kw)
else
size_class_kw = 1 # Default to smallest size class
end
end

return size_class_kw, kw_for_sizing
end
7 changes: 7 additions & 0 deletions src/core/scenario.jl
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ function Scenario(d::Dict; flex_hvac_from_json=false)

storage_structs = Dict{String, AbstractStorage}()
if haskey(d, "ElectricStorage")
storage_dict = d["ElectricStorage"]
storage_dict["off_grid_flag"] = settings.off_grid_flag

electric_load_annual_peak = maximum(electric_load.loads_kw)
electric_load_average = sum(electric_load.loads_kw) / (8760*settings.time_steps_per_hour)
storage_dict["electric_load_annual_peak"] = electric_load_annual_peak
storage_dict["electric_load_average"] = electric_load_average
storage_dict = dictkeys_tosymbols(d["ElectricStorage"])
storage_dict[:off_grid_flag] = settings.off_grid_flag
else
Expand Down
9 changes: 9 additions & 0 deletions src/results/electric_storage.jl
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ function add_electric_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d::
r["size_kwh"] = round(value(m[Symbol("dvStorageEnergy"*_n)][b]), digits=2)
r["size_kw"] = round(value(m[Symbol("dvStoragePower"*_n)][b]), digits=2)

storage_tech = p.s.storage.attr[b]
if !isnothing(storage_tech.size_class) && !isempty(storage_tech.size_class_bounds_kw)
min_size = storage_tech.size_class_bounds_kw[1]
max_size = storage_tech.size_class_bounds_kw[2]
if r["size_kw"] < min_size || r["size_kw"] > max_size
@warn "ElectricStorage $(b): Optimal size ($(round(r["size_kw"], digits=1)) kW) doesn't match size class $(storage_tech.size_class) range ($(round(min_size, digits=1))-$(round(max_size, digits=1)) kW). For more accurate results, rerun with an appropriate size class or define the installed cost. Ignore if using custom costs instead of default size class costs."
end
end

if r["size_kwh"] != 0
soc = (m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps)
r["soc_series_fraction"] = round.(value.(soc) ./ r["size_kwh"], digits=3)
Expand Down
Loading
Loading