From 7f627ece8d4024b089377df2b11218a3756062ff Mon Sep 17 00:00:00 2001 From: "Rathod, Bhavesh" Date: Thu, 19 Sep 2024 10:40:55 -0400 Subject: [PATCH] degradation model cleanup Moving old degradation branch changes to this "cleaned up" branch --- src/constraints/battery_degradation.jl | 129 +++++++++++--------- src/core/electric_utility.jl | 6 +- src/core/energy_storage/electric_storage.jl | 80 +++++++----- src/core/generator.jl | 8 +- src/core/reopt.jl | 26 ++-- src/results/electric_storage.jl | 5 +- 6 files changed, 149 insertions(+), 105 deletions(-) diff --git a/src/constraints/battery_degradation.jl b/src/constraints/battery_degradation.jl index be7c78cc2..d5ca399c9 100644 --- a/src/constraints/battery_degradation.jl +++ b/src/constraints/battery_degradation.jl @@ -7,6 +7,7 @@ function add_degradation_variables(m, p) @variable(m, Eplus_sum[days] >= 0) @variable(m, Eminus_sum[days] >= 0) @variable(m, EFC[days] >= 0) + @variable(m, SOH[days]) end @@ -47,33 +48,35 @@ NOTE the average SOC and EFC variables are in absolute units. For example, the S at the battery capacity in kWh. """ function add_degradation(m, p; b="ElectricStorage") + + # Indices days = 1:365*p.s.financial.analysis_years + months = 1:p.s.financial.analysis_years*12 + strategy = p.s.storage.attr[b].degradation.maintenance_strategy if isempty(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh) - function pwf(day::Int) + # Correctly account for discount rate and install cost declination rate for days over analysis period + function pwf_bess_replacements(day::Int) (1-p.s.storage.attr[b].degradation.installed_cost_per_kwh_declination_rate)^(day/365) / (1+p.s.financial.owner_discount_rate_fraction)^(day/365) end - # for the augmentation strategy the maintenance cost curve (function of time) starts at - # 80% of the installed cost since we are not replacing the entire battery - f = strategy == "augmentation" ? 0.8 : 1.0 - p.s.storage.attr[b].degradation.maintenance_cost_per_kwh = [ f * - p.s.storage.attr[b].installed_cost_per_kwh * pwf(d) for d in days[1:end-1] + p.s.storage.attr[b].degradation.maintenance_cost_per_kwh = [ + p.s.storage.attr[b].installed_cost_per_kwh * pwf_bess_replacements(d) for d in days[1:end-1] ] end - @assert(length(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh) == length(days) - 1, - "The degradation maintenance_cost_per_kwh must have a length of $(length(days)-1)." - ) - - @variable(m, SOH[days]) + # Under augmentation scenario, each day's battery augmentation cost is calculated using day-1 value from maintenance_cost_per_kwh vector + # Therefore, on last day, day-1's maintenance cost is utilized. + if length(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh) != length(days) - 1 + throw(@error("The degradation maintenance_cost_per_kwh must have a length of $(length(days)-1).")) + end add_degradation_variables(m, p) constrain_degradation_variables(m, p, b=b) @constraint(m, [d in 2:days[end]], - SOH[d] == SOH[d-1] - p.hours_per_time_step * ( + m[:SOH][d] == m[:SOH][d-1] - p.hours_per_time_step * ( p.s.storage.attr[b].degradation.calendar_fade_coefficient * p.s.storage.attr[b].degradation.time_exponent * m[:Eavg][d-1] * d^(p.s.storage.attr[b].degradation.time_exponent-1) + @@ -82,7 +85,7 @@ function add_degradation(m, p; b="ElectricStorage") ) # NOTE SOH can be negative - @constraint(m, SOH[1] == m[:dvStorageEnergy][b]) + @constraint(m, m[:SOH][1] == m[:dvStorageEnergy][b]) # NOTE SOH is _not_ normalized, and has units of kWh if strategy == "replacement" @@ -100,29 +103,9 @@ function add_degradation(m, p; b="ElectricStorage") The first month that the battery is replaced is determined by d_0p8, which is the integer number of days that the SOH is at least 80% of the purchased capacity. We define a binary for each month and only allow one month to be chosen. - =# - - # define d_0p8 - @warn "Adding binary and indicator constraints for - ElectricStorage.degradation.maintenance_strategy = \"replacement\". - Not all solvers support indicators and some are slow with integers." - # TODO import the latest battery degradation model in the degradation branch - @variable(m, soh_indicator[days], Bin) - @constraint(m, [d in days], - soh_indicator[d] => {SOH[d] >= 0.8*m[:dvStorageEnergy][b]} - ) - @expression(m, d_0p8, sum(soh_indicator[d] for d in days)) - # define binaries for the finding the month that battery must be replaced - months = 1:p.s.financial.analysis_years*12 - @variable(m, bmth[months], Bin) - # can only pick one month (or no month if SOH is >= 80% in last day) - @constraint(m, sum(bmth[mth] for mth in months) == 1-soh_indicator[length(days)]) - # the month picked is at most the month in which the SOH hits 80% - @constraint(m, sum(mth*bmth[mth] for mth in months) <= d_0p8 / 30.42) - # 30.42 is the average number of days in a month - - #= + # maintenance_cost_per_kwh must have length == length(days) - 1, i.e. starts on day 2 + number of replacments as function of d_0p8 ^ | @@ -139,41 +122,77 @@ function add_degradation(m, p; b="ElectricStorage") The above curve is multiplied by the maintenance_cost_per_kwh to create the cost coefficients =# - c = zeros(length(months)) # initialize cost coefficients - N = 365*p.s.financial.analysis_years + + @warn "Adding binary decision variables for + ElectricStorage.degradation.maintenance_strategy = \"replacement\". + Some solvers are slow with integers." + + @variable(m, binSOHIndicator[months], Bin) # track SOH levels, should be 1 if SOH >= 80%, 0 otherwise + @variable(m, binSOHIndicatorChange[months], Bin) # track which month SOH indicator drops to < 80% + @variable(m, 0 <= dvSOHChangeTimesEnergy[months]) # track the kwh to be replaced in a replacement month + + # the big M + if p.s.storage.attr[b].max_kwh == 1.0e6 || p.s.storage.attr[b].max_kwh == 0 + # Under default max_kwh (i.e. not modeling large batteries) or max_kwh = 0 + bigM_StorageEnergy = 24*maximum(p.s.electric_load.loads_kw) + else + # Select the larger value of maximum electric load or provided max_kwh size. + bigM_StorageEnergy = max(24*maximum(p.s.electric_load.loads_kw), p.s.storage.attr[b].max_kwh) + end + + # HEALTHY: if binSOHIndicator is 1, then SOH >= 80%. If binSOHIndicator is 0 and SOH >= very negative number + @constraint(m, [mth in months], m[:SOH][Int(round(30.4167*mth))] >= 0.8*m[:dvStorageEnergy][b] - bigM_StorageEnergy * (1-binSOHIndicator[mth])) + + # UNHEALTHY: if binSOHIndicator is 1, then SOH <= large number. If binSOHIndicator is 0 and SOH <= 80% + @constraint(m, [mth in months], m[:SOH][Int(round(30.4167*mth))] <= 0.8*m[:dvStorageEnergy][b] + bigM_StorageEnergy * (binSOHIndicator[mth])) + + # binSOHIndicatorChange[mth] = binSOHIndicator[mth-1] - binSOHIndicator[mth]. + # If replacement month is x, then binSOHIndicatorChange[x] = 1. All other binSOHIndicatorChange values will be 0s (either 1-1 or 0-0) + @constraint(m, m[:binSOHIndicatorChange][1] == 1 - m[:binSOHIndicator][1]) + @constraint(m, [mth in 2:months[end]], m[:binSOHIndicatorChange][mth] == m[:binSOHIndicator][mth-1] - m[:binSOHIndicator][mth]) + + @expression(m, months_to_first_replacement, sum(m[:binSOHIndicator][mth] for mth in months)) + + # -> linearize the product of binSOHIndicatorChange & m[:dvStorageEnergy][b] + @constraint(m, [mth in months], m[:dvSOHChangeTimesEnergy][mth] >= m[:dvStorageEnergy][b] - bigM_StorageEnergy * (1 - m[:binSOHIndicatorChange][mth])) + @constraint(m, [mth in months], m[:dvSOHChangeTimesEnergy][mth] <= m[:dvStorageEnergy][b] + bigM_StorageEnergy * (1 - m[:binSOHIndicatorChange][mth])) + @constraint(m, [mth in months], m[:dvSOHChangeTimesEnergy][mth] <= bigM_StorageEnergy * m[:binSOHIndicatorChange][mth]) + + replacement_costs = zeros(length(months)) # initialize cost coefficients + residual_values = zeros(length(months)) # initialize cost coefficients for residual_value + N = 365*p.s.financial.analysis_years # number of days + for mth in months - day = Int(round((mth-1)*30.42 + 15, digits=0)) - c[mth] = p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[day] * - ceil(N/day - 1) + day = Int(round((mth-1)*30.4167 + 15, digits=0)) + batt_replace_count = Int(ceil(N/day - 1)) # number of battery replacements in analysis period if they periodically happened on "day" + maint_cost = sum(p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[day*i] for i in 1:batt_replace_count) + replacement_costs[mth] = maint_cost + + residual_factor = 1 - (p.s.financial.analysis_years*12/mth - floor(p.s.financial.analysis_years*12/mth)) + residual_value = p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[end]*residual_factor + residual_values[mth] = residual_value end - # linearize the product of bmth & m[:dvStorageEnergy][b] - M = p.s.storage.attr[b].max_kwh # the big M - @variable(m, 0 <= bmth_BkWh[months]) - @constraint(m, [mth in months], bmth_BkWh[mth] <= m[:dvStorageEnergy][b]) - @constraint(m, [mth in months], bmth_BkWh[mth] <= M * bmth[mth]) - @constraint(m, [mth in months], bmth_BkWh[mth] >= m[:dvStorageEnergy][b] - M*(1-bmth[mth])) + # create replacement cost expression for objective + @expression(m, degr_cost, sum(replacement_costs[mth] * m[:dvSOHChangeTimesEnergy][mth] for mth in months)) - # add replacment cost to objective - @expression(m, degr_cost, - sum(c[mth] * bmth_BkWh[mth] for mth in months) - ) + # create residual value expression for objective + @expression(m, residual_value, sum(residual_values[mth] * m[:dvSOHChangeTimesEnergy][mth] for mth in months)) elseif strategy == "augmentation" @expression(m, degr_cost, sum( - p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[d-1] * (SOH[d-1] - SOH[d]) + p.s.storage.attr[b].degradation.maintenance_cost_per_kwh[d-1] * (m[:SOH][d-1] - m[:SOH][d]) for d in days[2:end] ) ) - # add augmentation cost to objective - # maintenance_cost_per_kwh must have length == length(days) - 1, i.e. starts on day 2 + + # No lifetime based residual value assigned to battery under the augmentation strategy + @expression(m, residual_value, 0.0) else throw(@error("Battery maintenance strategy $strategy is not supported. Choose from augmentation and replacement.")) end - - @objective(m, Min, m[:Costs] + m[:degr_cost]) # NOTE adding to Costs expression does not modify the objective function end diff --git a/src/core/electric_utility.jl b/src/core/electric_utility.jl index aaa45e77f..1fa2c8404 100644 --- a/src/core/electric_utility.jl +++ b/src/core/electric_utility.jl @@ -8,7 +8,7 @@ # Single Outage Modeling Inputs (Outage Modeling Option 1) outage_start_time_step::Int=0, # for modeling a single outage, with critical load spliced into the baseline load ... - outage_end_time_step::Int=0, # ... utiltity production_factor = 0 during the outage + outage_end_time_step::Int=0, # ... utility production_factor = 0 during the outage # Multiple Outage Modeling Inputs (Outage Modeling Option 2): minimax the expected outage cost, # with max taken over outage start time, expectation taken over outage duration @@ -115,7 +115,7 @@ struct ElectricUtility emissions_factor_SO2_decrease_fraction::Real emissions_factor_PM25_decrease_fraction::Real outage_start_time_step::Int # for modeling a single outage, with critical load spliced into the baseline load ... - outage_end_time_step::Int # ... utiltity production_factor = 0 during the outage + outage_end_time_step::Int # ... utility production_factor = 0 during the outage allow_simultaneous_export_import::Bool # if true the site has two meters (in effect) # next 5 variables below used for minimax the expected outage cost, # with max taken over outage start time, expectation taken over outage duration @@ -147,7 +147,7 @@ struct ElectricUtility net_metering_limit_kw::Real = 0, # Upper limit on the total capacity of technologies that can participate in net metering agreement. interconnection_limit_kw::Real = 1.0e9, outage_start_time_step::Int=0, # for modeling a single outage, with critical load spliced into the baseline load ... - outage_end_time_step::Int=0, # ... utiltity production_factor = 0 during the outage + outage_end_time_step::Int=0, # ... utility production_factor = 0 during the outage allow_simultaneous_export_import::Bool=true, # if true the site has two meters (in effect) # next 5 variables below used for minimax the expected outage cost, # with max taken over outage start time, expectation taken over outage duration diff --git a/src/core/energy_storage/electric_storage.jl b/src/core/energy_storage/electric_storage.jl index 626e2993a..19ebbf7b1 100644 --- a/src/core/energy_storage/electric_storage.jl +++ b/src/core/energy_storage/electric_storage.jl @@ -15,10 +15,7 @@ end ``` None of the above values are required. If `ElectricStorage.model_degradation` is `true` then the -defaults above are used. -If the `maintenance_cost_per_kwh` is not provided then it is determined using the `ElectricStorage.installed_cost_per_kwh` -and the `installed_cost_per_kwh_declination_rate` along with a present worth factor ``f`` to account for the present cost -of buying a battery in the future. The present worth factor for each day is: +defaults above are used. If the `maintenance_cost_per_kwh` is not provided then it is determined using the `ElectricStorage.installed_cost_per_kwh` and the `installed_cost_per_kwh_declination_rate` along with a present worth factor ``f`` to account for the present cost of buying a battery in the future. The present worth factor for each day is: `` f(day) = \\frac{ (1-r_g)^\\frac{day}{365} } { (1+r_d)^\\frac{day}{365} } @@ -28,18 +25,17 @@ where ``r_g`` = `installed_cost_per_kwh_declination_rate` and ``r_d`` = `p.s.fin Note this day-specific calculation of the present-worth factor accumulates differently from the annually updated discount rate for other net-present value calculations in REopt, and has a higher effective discount rate as a result. The present -worth factor is used in two different ways, depending on the `maintenance_strategy`, which is described below. +worth factor is used in the same manner irrespective of the `maintenance_strategy`. !!! warn When modeling degradation the following ElectricStorage inputs are not used: - - `replace_cost_per_kw` - `replace_cost_per_kwh` - - `inverter_replacement_year` - `battery_replacement_year` The are replaced by the `maintenance_cost_per_kwh` vector. + Inverter replacement costs and inverter replacement year should still be used to model scheduled replacement of inverter. !!! note - When providing the `maintenance_cost_per_kwh` it must have a length equal to `Financial.analysis_years*365`. + When providing the `maintenance_cost_per_kwh` it must have a length equal to `Financial.analysis_years*365`-1. # Battery State Of Health @@ -61,16 +57,22 @@ where: The `SOH` is used to determine the maintence cost of the storage system, which depends on the `maintenance_strategy`. +!!! note + Battery degradation parameters are from based on laboratory aging data, and are expected to be reasonable only within + the range of conditions tested. Battery lifetime can vary widely from these estimates based on battery use and system design. + Battery cost estimates are based on domain expertise and published guidelines and are not to be taken as an indicator of real + system costs. + # Augmentation Maintenance Strategy The augmentation maintenance strategy assumes that the battery energy capacity is maintained by replacing degraded cells daily in terms of cost. Using the definition of the `SOH` above the maintenance cost is: `` -C_{\\text{aug}} = \\sum_{d \\in \\{2\\dots D\\}} 0.8 C_{\\text{install}} f(day) \\left( SOH[d-1] - SOH[d] \\right) +C_{\\text{aug}} = \\sum_{d \\in \\{2\\dots D\\}} C_{\\text{install}} f(day) \\left( SOH[d-1] - SOH[d] \\right) `` where -- the ``0.8`` factor accounts for sunk costs that do not need to be paid; +- ``f(day)`` is the present worth factor of battery degradation costs as described above; - ``C_{\\text{install}}`` is the `ElectricStorage.installed_cost_per_kwh`; and - ``SOH[d-1] - SOH[d]`` is the incremental amount of battery capacity lost in a day. @@ -79,13 +81,13 @@ The ``C_{\\text{aug}}`` is added to the objective function to be minimized with # Replacement Maintenance Strategy Modeling the replacement maintenance strategy is more complex than the augmentation strategy. -Effectively the replacement strategy says that the battery has to be replaced once the `SOH` hits 80% -of the optimal, purchased capacity. It is possible that multiple replacements could be required under +Effectively the replacement strategy says that the battery has to be replaced once the `SOH` drops below 80% +of the optimal, purchased capacity. It is possible that multiple replacements (at same replacement frequency) could be required under this strategy. !!! warn - The "replacement" maintenance strategy requires integer variables and indicator constraints. - Not all solvers support indicator constraints and some solvers are slow with integer variables. + The "replacement" maintenance strategy requires integer decision variables. + Some solvers are slow with integer decision variables. The replacement strategy cost is: @@ -96,7 +98,22 @@ C_{\\text{repl}} = B_{\\text{kWh}} N_{\\text{repl}} f(d_{80}) C_{\\text{install} where: - ``B_{\\text{kWh}}`` is the optimal battery capacity (`ElectricStorage.size_kwh` in the results dictionary); - ``N_{\\text{repl}}`` is the number of battery replacments required (a function of the month in which the `SOH` reaches 80% of original capacity); -- ``f(d_{80})`` is the present worth factor at approximately the 15th day of the month that the `SOH` reaches 80% of original capacity. +- ``f(d_{80})`` is the present worth factor at approximately the 15th day of the month that the `SOH` crosses 80% of original capacity; +- ``C_{\\text{install}}`` is the `ElectricStorage.installed_cost_per_kwh`. +The ``C_{\\text{repl}}`` is added to the objective function to be minimized with all other costs. +## Battery residual value +Since the battery can be replaced one-to-many times under this strategy, battery residual value captures the \$ value of remaining battery life at end of analysis period. +For example if replacement happens in month 145, then assuming 25 year analysis period there will be 2 replacements (months 145 and 290). +The last battery which was placed in service during month 290 only serves for 10 months (i.e. 6.89% of its expected life assuming 145 month replacement frequecy). +In this case, the battery has 93.1% of residual life remaining as useful life left after analysis period ends. +A residual value cost vector is created to hold this value for all months. Residual value is calculated as: +`` +C_{\\text{residual}} = R f(d_{\\text{last}}) C_{\\text{install}} +`` +where: +- ``R`` is the `residual_factor` which determines portion of battery life remaining at end of analysis period; +- ``f(d_{\\text{last}})`` is the present worth factor at approximately the 15th day of last month in analysis period; +- ``C_{\\text{install}}`` is the `ElectricStorage.installed_cost_per_kwh`. The ``C_{\\text{repl}}`` is added to the objective function to be minimized with all other costs. @@ -123,9 +140,9 @@ The following shows how one would use the degradation model in REopt via the [Sc Note that not all of the above inputs are necessary. When not providing `calendar_fade_coefficient` for example the default value will be used. """ Base.@kwdef mutable struct Degradation - calendar_fade_coefficient::Real = 2.46E-03 - cycle_fade_coefficient::Real = 7.82E-05 - time_exponent::Real = 0.5 + calendar_fade_coefficient::Real = 2.55E-03 + cycle_fade_coefficient::Real = 9.83E-05 + time_exponent::Real = 0.42 installed_cost_per_kwh_declination_rate::Real = 0.05 maintenance_strategy::String = "augmentation" # one of ["augmentation", "replacement"] maintenance_cost_per_kwh::Vector{<:Real} = Real[] @@ -251,9 +268,20 @@ struct ElectricStorage <: AbstractElectricStorage @warn "Battery replacement costs (per_kwh) will not be considered because battery_replacement_year is greater than or equal to analysis_years." end + # copy the replace_costs in case we need to change them + replace_cost_per_kw = s.replace_cost_per_kw + replace_cost_per_kwh = s.replace_cost_per_kwh + if s.model_degradation + if haskey(d, :replace_cost_per_kwh) && d[:replace_cost_per_kwh] != 0.0 + @warn "Setting ElectricStorage replacement costs to zero. \nUsing degradation.maintenance_cost_per_kwh instead." + end + replace_cost_per_kwh = 0.0 # Always modeled using maintenance_cost_vector in degradation model. + # replace_cost_per_kw is unchanged here. + end + net_present_cost_per_kw = effective_cost(; itc_basis = s.installed_cost_per_kw, - replacement_cost = s.inverter_replacement_year >= f.analysis_years ? 0.0 : s.replace_cost_per_kw, + replacement_cost = s.inverter_replacement_year >= f.analysis_years ? 0.0 : replace_cost_per_kw, replacement_year = s.inverter_replacement_year, discount_rate = f.owner_discount_rate_fraction, tax_rate = f.owner_tax_rate_fraction, @@ -265,7 +293,7 @@ struct ElectricStorage <: AbstractElectricStorage ) net_present_cost_per_kwh = effective_cost(; itc_basis = s.installed_cost_per_kwh, - replacement_cost = s.battery_replacement_year >= f.analysis_years ? 0.0 : s.replace_cost_per_kwh, + replacement_cost = s.battery_replacement_year >= f.analysis_years ? 0.0 : replace_cost_per_kwh, replacement_year = s.battery_replacement_year, discount_rate = f.owner_discount_rate_fraction, tax_rate = f.owner_tax_rate_fraction, @@ -282,18 +310,6 @@ struct ElectricStorage <: AbstractElectricStorage else degr = Degradation() end - - # copy the replace_costs in case we need to change them - replace_cost_per_kw = s.replace_cost_per_kw - replace_cost_per_kwh = s.replace_cost_per_kwh - if s.model_degradation - if haskey(d, :replace_cost_per_kw) && d[:replace_cost_per_kw] != 0.0 || - haskey(d, :replace_cost_per_kwh) && d[:replace_cost_per_kwh] != 0.0 - @warn "Setting ElectricStorage replacement costs to zero. Using degradation.maintenance_cost_per_kwh instead." - end - replace_cost_per_kw = 0.0 - replace_cost_per_kwh = 0.0 - end return new( s.min_kw, diff --git a/src/core/generator.jl b/src/core/generator.jl index 691e39bcb..e65c08b58 100644 --- a/src/core/generator.jl +++ b/src/core/generator.jl @@ -42,8 +42,8 @@ emissions_factor_lb_NOx_per_gal::Real = 0.0775544, emissions_factor_lb_SO2_per_gal::Real = 0.040020476, emissions_factor_lb_PM25_per_gal::Real = 0.0, - replacement_year::Int = off_grid_flag ? 10 : analysis_years, - replace_cost_per_kw::Real = off_grid_flag ? installed_cost_per_kw : 0.0 + replacement_year::Int = off_grid_flag ? 10 : analysis_years, # Project year in which generator capacity will be replaced at a cost of replace_cost_per_kw. + replace_cost_per_kw::Real = off_grid_flag ? installed_cost_per_kw : 0.0 # Per kW replacement cost for generator capacity. Replacement costs are considered tax deductible. ``` !!! note "Replacement costs" @@ -137,8 +137,8 @@ struct Generator <: AbstractGenerator emissions_factor_lb_NOx_per_gal::Real = 0.0775544, emissions_factor_lb_SO2_per_gal::Real = 0.040020476, emissions_factor_lb_PM25_per_gal::Real = 0.0, - replacement_year::Int = off_grid_flag ? 10 : analysis_years, - replace_cost_per_kw::Real = off_grid_flag ? installed_cost_per_kw : 0.0 + replacement_year::Int = off_grid_flag ? 10 : analysis_years, # Project year in which generator capacity will be replaced at a cost of replace_cost_per_kw. + replace_cost_per_kw::Real = off_grid_flag ? installed_cost_per_kw : 0.0 # Per kW replacement cost for generator capacity. Replacement costs are considered tax deductible. ) if (replacement_year >= analysis_years) && !(replace_cost_per_kw == 0.0) diff --git a/src/core/reopt.jl b/src/core/reopt.jl index e9344b703..d887837ff 100644 --- a/src/core/reopt.jl +++ b/src/core/reopt.jl @@ -450,6 +450,15 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) m[:OffgridOtherCapexAfterDepr] = p.s.financial.offgrid_other_capital_costs - offgrid_other_capex_depr_savings end + for b in p.s.storage.types.elec + if p.s.storage.attr[b].model_degradation + add_degradation(m, p; b=b) + if p.s.settings.add_soc_incentive # this warning should be tied to IF condition where SOC incentive is added + @warn "Settings.add_soc_incentive is set to true and it will incentivize BESS energy levels to be kept high. It could conflict with the battery degradation model and should be disabled." + end + end + end + ################################# Objective Function ######################################## @expression(m, Costs, # Capital Costs @@ -494,6 +503,14 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) if p.s.settings.include_health_in_objective add_to_expression!(Costs, m[:Lifecycle_Emissions_Cost_Health]) end + + has_degr = false + for b in p.s.storage.types.elec + if p.s.storage.attr[b].model_degradation + has_degr = true + add_to_expression!(Costs, m[:degr_cost] - m[:residual_value]) # maximize residual value + end + end ## Modify objective with incentives that are not part of the LCC # 1. Comfort limit violation costs @@ -512,15 +529,6 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) # Set model objective @objective(m, Min, m[:Costs] + m[:ObjectivePenalties] ) - - for b in p.s.storage.types.elec - if p.s.storage.attr[b].model_degradation - add_degradation(m, p; b=b) - if p.s.settings.add_soc_incentive - @warn "Settings.add_soc_incentive is set to true but no incentive will be added because it conflicts with the battery degradation model." - end - end - end nothing end diff --git a/src/results/electric_storage.jl b/src/results/electric_storage.jl index 4c2b1483c..ef07eb48c 100644 --- a/src/results/electric_storage.jl +++ b/src/results/electric_storage.jl @@ -38,10 +38,11 @@ function add_electric_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d:: r["state_of_health"] = value.(m[:SOH]).data / value.(m[:dvStorageEnergy])["ElectricStorage"]; r["maintenance_cost"] = value(m[:degr_cost]) if p.s.storage.attr[b].degradation.maintenance_strategy == "replacement" - r["replacement_month"] = Int(value( - sum(mth * m[:bmth][mth] for mth in 1:p.s.financial.analysis_years*12) + r["replacement_month"] = round(Int, value( + sum(mth * m[:binSOHIndicatorChange][mth] for mth in 1:p.s.financial.analysis_years*12) )) end + r["residual_value"] = value(m[:residual_value]) end else r["soc_series_fraction"] = []