diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac7f2c8c..161718dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/constraints/storage_constraints.jl b/src/constraints/storage_constraints.jl index 2787d3b25..d7e659528 100644 --- a/src/constraints/storage_constraints.jl +++ b/src/constraints/storage_constraints.jl @@ -105,31 +105,21 @@ 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) @@ -137,6 +127,17 @@ function add_elec_storage_dispatch_constraints(m, p, b; _n="") 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="") diff --git a/src/core/electric_tariff.jl b/src/core/electric_tariff.jl index 08bca2501..e97591d39 100644 --- a/src/core/electric_tariff.jl +++ b/src/core/electric_tariff.jl @@ -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 diff --git a/src/core/energy_storage/electric_storage.jl b/src/core/energy_storage/electric_storage.jl index a2202b034..c42412ae6 100644 --- a/src/core/energy_storage/electric_storage.jl +++ b/src/core/energy_storage/electric_storage.jl @@ -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 @@ -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. @@ -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 @@ -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 @@ -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 @@ -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 @@ -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...) @@ -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 @@ -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, @@ -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 @@ -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, @@ -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 diff --git a/src/core/reopt.jl b/src/core/reopt.jl index 264e4fa63..83a1cce32 100644 --- a/src/core/reopt.jl +++ b/src/core/reopt.jl @@ -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) @@ -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] ) diff --git a/src/core/scenario.jl b/src/core/scenario.jl index 9031798f8..8380b342c 100644 --- a/src/core/scenario.jl +++ b/src/core/scenario.jl @@ -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) @@ -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") diff --git a/src/core/utils.jl b/src/core/utils.jl index 0ecd510d0..e3e705f8e 100644 --- a/src/core/utils.jl +++ b/src/core/utils.jl @@ -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) diff --git a/src/mpc/model.jl b/src/mpc/model.jl index 674a18689..e69312a3e 100644 --- a/src/mpc/model.jl +++ b/src/mpc/model.jl @@ -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 + fix(m[:dvGridToStorage][b, ts], 0.0, force=true) end for t in p.techs.elec, u in p.export_bins_by_tech[t] diff --git a/src/mpc/structs.jl b/src/mpc/structs.jl index 5b166c279..cb6676d18 100644 --- a/src/mpc/structs.jl +++ b/src/mpc/structs.jl @@ -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.05 : nothing end ``` """ @@ -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.05 : nothing end diff --git a/src/results/electric_storage.jl b/src/results/electric_storage.jl index 4752c5182..fc994e1c3 100644 --- a/src/results/electric_storage.jl +++ b/src/results/electric_storage.jl @@ -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` @@ -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( @@ -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"] diff --git a/src/results/electric_utility.jl b/src/results/electric_utility.jl index 0b56b7e1b..8e876e5af 100644 --- a/src/results/electric_utility.jl +++ b/src/results/electric_utility.jl @@ -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) diff --git a/src/results/thermal_storage.jl b/src/results/thermal_storage.jl index 74d60f99f..2ae82d1d3 100644 --- a/src/results/thermal_storage.jl +++ b/src/results/thermal_storage.jl @@ -3,13 +3,13 @@ `HotThermalStorage` results keys: - `size_kwh` Optimal TES capacity, by energy [kWh] - `size_gal` Optimal TES capacity, by volume [gal] -- `soc_series_fraction` Vector of normalized (0-1) state of charge values over the first year [-] -- `storage_to_steamturbine_series_mmbtu_per_hour` Vector of heat sent to steam turbine over the first year [MMBTU/hr] -- `storage_to_absorption_chiller_series_mmbtu_per_hour` Vector of heat sent to absorption chiller over the first year [MMBTU/hr] -- `storage_to_load_series_mmbtu_per_hour` Vector of thermal power used to meet load over the first year [MMBTU/hr] -- `storage_to_space_heating_load_series_mmbtu_per_hour` Vector of heat sent to space heating load over the first year [MMBTU/hr] -- `storage_to_dhw_load_series_mmbtu_per_hour` Vector of heat sent to domestic hot water load over the first year [MMBTU/hr] -- `storage_to_process_heat_load_series_mmbtu_per_hour` Vector of heat sent to process heat load over the first year [MMBTU/hr] +- `soc_series_fraction` Vector of normalized (0-1) state of charge values over an average year [-] +- `storage_to_steamturbine_series_mmbtu_per_hour` Vector of heat sent to steam turbine over an average year [MMBTU/hr] +- `storage_to_absorption_chiller_series_mmbtu_per_hour` Vector of heat sent to absorption chiller over an average year [MMBTU/hr] +- `storage_to_load_series_mmbtu_per_hour` Vector of thermal power used to meet load over an average year [MMBTU/hr] +- `storage_to_space_heating_load_series_mmbtu_per_hour` Vector of heat sent to space heating load over an average year [MMBTU/hr] +- `storage_to_dhw_load_series_mmbtu_per_hour` Vector of heat sent to domestic hot water load over an average year [MMBTU/hr] +- `storage_to_process_heat_load_series_mmbtu_per_hour` Vector of heat sent to process heat load over an average year [MMBTU/hr] !!! note "'Series' and 'Annual' energy outputs are average annual" REopt performs load balances using average annual production values for technologies that include degradation. @@ -119,8 +119,8 @@ end """ `ColdThermalStorage` results: - `size_gal` Optimal TES capacity, by volume [gal] -- `soc_series_fraction` Vector of normalized (0-1) state of charge values over the first year [-] -- `storage_to_load_series_ton` Vector of power used to meet load over the first year [ton] +- `soc_series_fraction` Vector of normalized (0-1) state of charge values over an average year [-] +- `storage_to_load_series_ton` Vector of power used to meet load over an average year [ton] """ function add_cold_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict, b::String; _n="") #= @@ -171,13 +171,13 @@ end """ `HighTempThermalStorage` results keys: - `size_kwh` Optimal TES capacity, by energy [kWh] -- `soc_series_fraction` Vector of normalized (0-1) state of charge values over the first year [-] -- `storage_to_steamturbine_series_mmbtu_per_hour` Vector of heat sent to steam turbine over the first year [MMBTU/hr] -- `storage_to_absorption_chiller_series_mmbtu_per_hour` Vector of heat sent to absorption chiller over the first year [MMBTU/hr] -- `storage_to_load_series_mmbtu_per_hour` Vector of thermal power used to meet load over the first year [MMBTU/hr] -- `storage_to_space_heating_load_series_mmbtu_per_hour` Vector of heat sent to space heating load over the first year [MMBTU/hr] -- `storage_to_dhw_load_series_mmbtu_per_hour` Vector of heat sent to domestic hot water load over the first year [MMBTU/hr] -- `storage_to_process_heat_load_series_mmbtu_per_hour` Vector of heat sent to process heat load over the first year [MMBTU/hr] +- `soc_series_fraction` Vector of normalized (0-1) state of charge values over an average year [-] +- `storage_to_steamturbine_series_mmbtu_per_hour` Vector of heat sent to steam turbine over an average year [MMBTU/hr] +- `storage_to_absorption_chiller_series_mmbtu_per_hour` Vector of heat sent to absorption chiller over an average year [MMBTU/hr] +- `storage_to_load_series_mmbtu_per_hour` Vector of thermal power used to meet load over an average year [MMBTU/hr] +- `storage_to_space_heating_load_series_mmbtu_per_hour` Vector of heat sent to space heating load over an average year [MMBTU/hr] +- `storage_to_dhw_load_series_mmbtu_per_hour` Vector of heat sent to domestic hot water load over an average year [MMBTU/hr] +- `storage_to_process_heat_load_series_mmbtu_per_hour` Vector of heat sent to process heat load over an average year [MMBTU/hr] !!! note "'Series' and 'Annual' energy outputs are average annual" REopt performs load balances using average annual production values for technologies that include degradation. diff --git a/test/runtests.jl b/test/runtests.jl index 375b768f6..f41dcdbc7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4205,6 +4205,27 @@ else # run HiGHS tests empty!(m2) GC.gc() end + + @testset "Fixed ElectricStorage state of charge" begin + post_name = "fixed_pv_bess" + post = JSON.parsefile("./scenarios/$post_name.json") + + # Get optimal SOC + m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(m1 , post) + lcc1 = results["Financial"]["lcc"] + soc_series = results["ElectricStorage"]["soc_series_fraction"] + + # Fix soc_series to optimal from previous run + m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + post["ElectricStorage"]["fixed_soc_series_fraction"] = soc_series + post["ElectricStorage"]["fixed_soc_series_fraction_tolerance"] = 0.05 + results = run_reopt(m1 , post) + lcc2 = results["Financial"]["lcc"] + + @test lcc1 ≈ lcc2 rtol=0.001 + @test maximum(abs.(soc_series - results["ElectricStorage"]["soc_series_fraction"])) <= post["ElectricStorage"]["fixed_soc_series_fraction_tolerance"]+ 1e-7 + end @testset "Existing HVAC (Boiler and Chiller) Costs for BAU" begin """ diff --git a/test/scenarios/fixed_pv_bess.json b/test/scenarios/fixed_pv_bess.json new file mode 100644 index 000000000..755e07fbb --- /dev/null +++ b/test/scenarios/fixed_pv_bess.json @@ -0,0 +1,31 @@ +{ + "Site": { + "longitude": -118.1164613, + "latitude": 34.5794343 + }, + "ElectricLoad": { + "doe_reference_name": "RetailStore", + "annual_kwh": 876000.0 + }, + "ElectricTariff": { + "blended_annual_energy_rate": 0.10, + "blended_annual_demand_rate": 0 + }, + "Financial": { + "elec_cost_escalation_rate_fraction": 0.026, + "offtaker_discount_rate_fraction": 0.081, + "analysis_years": 20, + "offtaker_tax_rate_fraction": 0.4, + "om_cost_escalation_rate_fraction": 0.025 + }, + "PV" : { + "min_kw": 100, + "max_kw": 100 + }, + "ElectricStorage" : { + "min_kw": 100, + "max_kw": 100, + "min_kwh": 200, + "max_kwh": 200 + } +} \ No newline at end of file