diff --git a/CHANGELOG.md b/CHANGELOG.md index 8913f74ca..c7a115706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ Classify the change according to the following categories: ### Deprecated ### Removed +## add-cost-constraint +### Added +- Added new fields `min_initial_capital_costs_before_incentives` and `max_initial_capital_costs_before_incentives` which, when floating-point numbers are provided, provide upper and lower bounds on initial capital costs for all technologies. When active, this also warns the user that the problem may become infeasible when performed in combination with resilience requirements. ## fix-pbi ### Fixed diff --git a/src/constraints/cost_curve_constraints.jl b/src/constraints/cost_curve_constraints.jl index 991e3942a..5ed44de0d 100644 --- a/src/constraints/cost_curve_constraints.jl +++ b/src/constraints/cost_curve_constraints.jl @@ -6,7 +6,7 @@ There are two situations under which we add binary constraints to the model in o for a technology: 1. When a technology has tax or investment incentives with upper capacity limits < tech.max_kw - first segment(s) have lower slope than last segment - 2. When a technology has multiple cost/size pairs (not implemented yet, used for CHP in v1) + 2. When a technology has multiple cost/size pairs - we interpolate the slope between the cost/size points, typically with economies of scale pricing We used to use cost curve segments for when a technology has a non-zero existing_kw by setting the first segment to a zero cost (and slope) from zero kw to the existing_kw. Instead, we now have dvPurchaseSize >= dvSize - existing_kw. @@ -65,4 +65,154 @@ function add_cost_curve_vars_and_constraints(m, p; _n="") @constraint(m, [t in p.techs.segmented], m[Symbol("dvPurchaseSize"*_n)][t] == m[Symbol("dvSize"*_n)][t] - p.existing_sizes[t] ) +end + +function add_capex_constraints(m, p; _n="") + @warn "Adding capital costs constraints. These may cause an infeasible problem in some cases, particularly for resilience runs." + if (!isnothing(p.s.financial.min_initial_capital_costs_before_incentives) && !isnothing(p.s.financial.max_initial_capital_costs_before_incentives) + && p.s.financial.min_initial_capital_costs_before_incentives > p.s.financial.max_initial_capital_costs_before_incentives) + throw(@error("Minimum required capital cost is larger than maximum required capital cost - this problem is infeasible.")) + end + if !isnothing(p.s.financial.min_initial_capital_costs_before_incentives) + @constraint(m, + m[:InitialCapexNoIncentives] >= p.s.financial.min_initial_capital_costs_before_incentives + ) + end + if !isnothing(p.s.financial.max_initial_capital_costs_before_incentives) + @constraint(m, + m[:InitialCapexNoIncentives] <= p.s.financial.max_initial_capital_costs_before_incentives + ) + end +end + +function initial_capex_no_incentives(m::JuMP.AbstractModel, p::REoptInputs; _n="") + m[:InitialCapexNoIncentives] = JuMP.GenericAffExpr{Float64, JuMP.VariableRef}(0.0) # Avoids MethodError + + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.financial.offgrid_other_capital_costs - m[Symbol("AvoidedCapexByASHP"*_n)] - m[Symbol("AvoidedCapexByGHP"*_n)] + ) + + if !isempty(p.techs.gen) && isempty(_n) # generators not included in multinode model + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.generator.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["Generator"] + ) + end + + if !isempty(p.techs.pv) + for pv in p.s.pvs + add_to_expression!(m[:InitialCapexNoIncentives], + pv.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)][pv.name] + ) + end + end + + for b in p.s.storage.types.elec + if p.s.storage.attr[b].max_kw > 0 + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.storage.attr[b].installed_cost_per_kw * m[Symbol("dvStoragePower"*_n)][b] + + p.s.storage.attr[b].installed_cost_per_kwh * m[Symbol("dvStorageEnergy"*_n)][b] + ) + end + end + + for b in p.s.storage.types.thermal + if p.s.storage.attr[b].max_kw > 0 + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.storage.attr[b].installed_cost_per_kwh * m[Symbol("dvStorageEnergy"*_n)][b] + ) + end + end + + if "Wind" in p.techs.all + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.wind.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["Wind"] + ) + end + + if "CHP" in p.techs.all + m[:CHPCapexNoIncentives] = JuMP.GenericAffExpr{Float64, JuMP.VariableRef}() + cost_list = p.s.chp.installed_cost_per_kw + size_list = p.s.chp.tech_sizes_for_cost_curve + + t="CHP" + if t in p.techs.segmented + # Use "no incentives" version of p.cap_cost_slope and p.seg_yint + cost_slope_no_inc = [cost_list[1]] + seg_yint_no_inc = [0.0] + for s in range(2, stop=length(size_list)) + tmp_slope = round((cost_list[s] * size_list[s] - cost_list[s-1] * size_list[s-1]) / + (size_list[s] - size_list[s-1]), digits=0) + tmp_y_int = round(cost_list[s-1] * size_list[s-1] - tmp_slope * size_list[s-1], digits=0) + append!(cost_slope_no_inc, tmp_slope) + append!(seg_yint_no_inc, tmp_y_int) + end + append!(cost_slope_no_inc, cost_list[end]) + append!(seg_yint_no_inc, 0.0) + + add_to_expression!(m[:CHPCapexNoIncentives], + sum(cost_slope_no_inc[s] * m[Symbol("dvSegmentSystemSize"*t)][s] + + seg_yint_no_inc[s] * m[Symbol("binSegment"*t)][s] for s in 1:p.n_segs_by_tech[t]) + ) + else + add_to_expression!(m[:CHPCapexNoIncentives], cost_list * m[Symbol("dvPurchaseSize"*_n)]["CHP"]) + end + if p.s.chp.supplementary_firing_capital_cost_per_kw > 0 + add_to_expression!(m[:CHPCapexNoIncentives], + p.s.chp.supplementary_firing_capital_cost_per_kw * m[Symbol("dvSupplementaryFiringSize"*_n)]["CHP"] + ) + end + add_to_expression!(m[:InitialCapexNoIncentives], m[:CHPCapexNoIncentives]) + end + + if "SteamTurbine" in p.techs.all + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.steam_turbine.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["SteamTurbine"] + ) + end + + if "Boiler" in p.techs.all + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.boiler.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["Boiler"] + ) + end + + if "AbsorptionChiller" in p.techs.all + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.absorption_chiller.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["AbsorptionChiller"] + ) + end + + if !isempty(p.s.ghp_option_list) + for option in enumerate(p.s.ghp_option_list) + if option[2].heat_pump_configuration == "WSHP" + add_to_expression!(m[:InitialCapexNoIncentives], + option[2].installed_cost_per_kw[2]*option[2].heatpump_capacity_ton*m[Symbol("binGHP"*_n)][option[1]] + ) + elseif option[2].heat_pump_configuration == "WWHP" + add_to_expression!(m[:InitialCapexNoIncentives], + (option[2].wwhp_heating_pump_installed_cost_curve[2]*option[2].wwhp_heating_pump_capacity_ton + option[2].wwhp_cooling_pump_installed_cost_curve[2]*option[2].wwhp_cooling_pump_capacity_ton)*m[Symbol("binGHP"*_n)][option[1]] + ) + else + @warn "Unknown heat pump configuration provided, excluding GHP costs from initial capital costs." + end + end + end + + if "ASHPSpaceHeater" in p.techs.all + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.ashp.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["ASHPSpaceHeater"] + ) + end + + if "ASHPWaterHeater" in p.techs.all + add_to_expression!(m[:InitialCapexNoIncentives], + p.s.ashp_wh.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["ASHPWaterHeater"] + ) + end + + if !isempty(p.s.electric_utility.outage_durations) + add_to_expression!(m[:InitialCapexNoIncentives], + m[:mgTotalTechUpgradeCost] + m[:dvMGStorageUpgradeCost] + ) + end end \ No newline at end of file diff --git a/src/core/financial.jl b/src/core/financial.jl index 105ebe7e2..624cf9739 100644 --- a/src/core/financial.jl +++ b/src/core/financial.jl @@ -19,7 +19,9 @@ macrs_five_year::Array{Float64,1} = [0.2, 0.32, 0.192, 0.1152, 0.1152, 0.0576], # IRS pub 946 macrs_seven_year::Array{Float64,1} = [0.1429, 0.2449, 0.1749, 0.1249, 0.0893, 0.0892, 0.0893, 0.0446], offgrid_other_capital_costs::Real = 0.0, # only applicable when `off_grid_flag` is true. Straight-line depreciation is applied to this capex cost, reducing taxable income. - offgrid_other_annual_costs::Real = 0.0 # only applicable when `off_grid_flag` is true. Considered tax deductible for owner. Costs are per year. + offgrid_other_annual_costs::Real = 0.0 # only applicable when `off_grid_flag` is true. Considered tax deductible for owner. Costs are per year. + min_initial_capital_costs_before_incentives::Union{Nothing,Real} = nothing # minimum up-front capital cost for all technologies, excluding replacement costs and incentives. + max_initial_capital_costs_before_incentives::Union{Nothing,Real} = nothing # maximum up-front capital cost for all technologies, excluding replacement costs and incentives. # Emissions cost inputs CO2_cost_per_tonne::Real = 51.0, CO2_cost_escalation_rate_fraction::Real = 0.042173, @@ -62,6 +64,8 @@ struct Financial macrs_seven_year::Array{Float64,1} offgrid_other_capital_costs::Float64 offgrid_other_annual_costs::Float64 + min_initial_capital_costs_before_incentives::Union{Nothing,Real} + max_initial_capital_costs_before_incentives::Union{Nothing,Real} CO2_cost_per_tonne::Float64 CO2_cost_escalation_rate_fraction::Float64 NOx_grid_cost_per_tonne::Float64 @@ -94,6 +98,8 @@ struct Financial macrs_seven_year::Array{<:Real,1} = [0.1429, 0.2449, 0.1749, 0.1249, 0.0893, 0.0892, 0.0893, 0.0446], offgrid_other_capital_costs::Real = 0.0, # only applicable when `off_grid_flag` is true. Straight-line depreciation is applied to this capex cost, reducing taxable income. offgrid_other_annual_costs::Real = 0.0, # only applicable when `off_grid_flag` is true. Considered tax deductible for owner. + min_initial_capital_costs_before_incentives::Union{Nothing,Real} = nothing, + max_initial_capital_costs_before_incentives::Union{Nothing,Real} = nothing, # Emissions cost inputs CO2_cost_per_tonne::Real = 51.0, CO2_cost_escalation_rate_fraction::Real = 0.042173, @@ -191,6 +197,8 @@ struct Financial macrs_seven_year, offgrid_other_capital_costs, offgrid_other_annual_costs, + min_initial_capital_costs_before_incentives, + max_initial_capital_costs_before_incentives, CO2_cost_per_tonne, CO2_cost_escalation_rate_fraction, NOx_grid_cost_per_tonne, diff --git a/src/core/reopt.jl b/src/core/reopt.jl index 662d249cd..e79e44757 100644 --- a/src/core/reopt.jl +++ b/src/core/reopt.jl @@ -398,7 +398,7 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) @warn "Adding binary variable(s) to model cost curves" add_cost_curve_vars_and_constraints(m, p) for t in p.techs.segmented # cannot have this for statement in sum( ... for t in ...) ??? - m[:TotalTechCapCosts] += p.third_party_factor * ( + m[:TotalTechCapCosts] += p.third_party_factor * ( sum(p.cap_cost_slope[t][s] * m[Symbol("dvSegmentSystemSize"*t)][s] + p.seg_yint[t][s] * m[Symbol("binSegment"*t)][s] for s in 1:p.n_segs_by_tech[t]) ) @@ -477,6 +477,12 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) end end + # Get CAPEX expressions and optionally constrain CAPEX + initial_capex_no_incentives(m, p) + if !isnothing(p.s.financial.min_initial_capital_costs_before_incentives) || !isnothing(p.s.financial.max_initial_capital_costs_before_incentives) + add_capex_constraints(m, p) + end + ################################# Objective Function ######################################## @expression(m, Costs, # Capital Costs diff --git a/src/results/chp.jl b/src/results/chp.jl index 27ee6903b..90ea526a9 100644 --- a/src/results/chp.jl +++ b/src/results/chp.jl @@ -21,6 +21,7 @@ - `year_one_standby_cost_after_tax` CHP standby charges in year one, after tax - `lifecycle_standby_cost_after_tax` Present value of all CHP standby charges, after tax. - `thermal_production_series_mmbtu_per_hour` +- `initial_capital_costs` Initial capital costs of the CHP system, before incentives [\$] !!! note "'Series' and 'Annual' energy outputs are average annual" REopt performs load balances using average annual production values for technologies that include degradation. @@ -137,7 +138,7 @@ function add_chp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") r["year_one_standby_cost_before_tax"] = round(value(m[Symbol("TotalCHPStandbyCharges")]) / p.pwf_e, digits=0) r["year_one_standby_cost_after_tax"] = r["year_one_standby_cost_before_tax"] * (1 - p.s.financial.offtaker_tax_rate_fraction) r["lifecycle_standby_cost_after_tax"] = round(value(m[Symbol("TotalCHPStandbyCharges")]) * (1 - p.s.financial.offtaker_tax_rate_fraction), digits=0) - + r["initial_capital_costs"] = round(value(m[Symbol("CHPCapexNoIncentives")]), digits=2) d["CHP"] = r nothing diff --git a/src/results/financial.jl b/src/results/financial.jl index da477f873..24bd459c6 100644 --- a/src/results/financial.jl +++ b/src/results/financial.jl @@ -24,8 +24,8 @@ - `year_one_chp_standby_cost_before_tax` Year one CHP standby costs, before tax. - `lifecycle_capital_costs_plus_om_after_tax` Capital cost for all technologies plus present value of operations and maintenance over anlaysis period. - `lifecycle_capital_costs` Net capital costs for all technologies, in present value, including replacement costs and incentives. -- `initial_capital_costs` Up-front capital costs for all technologies, in present value, excluding replacement costs and incentives. -- `initial_capital_costs_after_incentives` Up-front capital costs for all technologies, in present value, excluding replacement costs, and accounting for incentives. Note: the ITC and MACRS are discounted by 1 year, and 1-7 years, respectively, to obtain the present value. +- `initial_capital_costs` Up-front capital costs for all technologies, in present value, excluding replacement costs and incentives. If third party ownership, represents cost to third party. +- `initial_capital_costs_after_incentives` Up-front capital costs for all technologies, in present value, excluding replacement costs, and accounting for incentives. Note: the ITC and MACRS are discounted by 1 year, and 1-7 years, respectively, to obtain the present value. If third party ownership, represents cost to third party. - `replacements_future_cost_after_tax` Future cost of replacing storage and/or generator systems, after tax. - `replacements_present_cost_after_tax` Present value cost of replacing storage and/or generator systems, after tax. - `om_and_replacement_present_cost_after_tax` Present value of all O&M and replacement costs, after tax. @@ -73,13 +73,16 @@ function add_financial_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _ if !(Symbol("AvoidedCapexByASHP"*_n) in keys(m.obj_dict)) m[Symbol("AvoidedCapexByASHP"*_n)] = 0.0 end + if !(Symbol("InitialCapexNoIncentives"*_n) in keys(m.obj_dict)) + m[Symbol("InitialCapexNoIncentives"*_n)] = 0.0 + end r["lcc"] = value(m[Symbol("Costs"*_n)]) + 0.0001 * value(m[Symbol("MinChargeAdder"*_n)]) r["lifecycle_om_costs_before_tax"] = value(m[Symbol("TotalPerUnitSizeOMCosts"*_n)] + m[Symbol("TotalPerUnitProdOMCosts"*_n)] + m[Symbol("TotalPerUnitHourOMCosts"*_n)] + m[Symbol("GHPOMCosts"*_n)]) - ## LCC breakdown: ## + ## Start LCC breakdown: ## r["lifecycle_generation_tech_capital_costs"] = value(m[Symbol("TotalTechCapCosts"*_n)] + m[Symbol("GHPCapCosts"*_n)]) # Tech capital costs (including replacements) r["lifecycle_storage_capital_costs"] = value(m[Symbol("TotalStorageCapCosts"*_n)]) # Storage capital costs (including replacements) r["lifecycle_om_costs_after_tax"] = r["lifecycle_om_costs_before_tax"] * (1 - p.s.financial.owner_tax_rate_fraction) # Fixed & Variable O&M @@ -116,11 +119,14 @@ function add_financial_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _ r["year_one_om_costs_after_tax"] = r["lifecycle_om_costs_after_tax"] / (p.pwf_om * p.third_party_factor) r["lifecycle_capital_costs"] = value(m[Symbol("TotalTechCapCosts"*_n)] + m[Symbol("TotalStorageCapCosts"*_n)] + m[Symbol("GHPCapCosts"*_n)] + - m[Symbol("OffgridOtherCapexAfterDepr"*_n)] - m[Symbol("AvoidedCapexByGHP"*_n)] - m[Symbol("ResidualGHXCapCost"*_n)] - m[Symbol("AvoidedCapexByASHP"*_n)]) - + m[Symbol("OffgridOtherCapexAfterDepr"*_n)] - m[Symbol("AvoidedCapexByGHP"*_n)] - m[Symbol("ResidualGHXCapCost"*_n)] - m[Symbol("AvoidedCapexByASHP"*_n)] + ) + if !isempty(p.s.electric_utility.outage_durations) + r["lifecycle_capital_costs"] += value(m[:mgTotalTechUpgradeCost] + m[:dvMGStorageUpgradeCost]) + end r["lifecycle_capital_costs_plus_om_after_tax"] = r["lifecycle_capital_costs"] + r["lifecycle_om_costs_after_tax"] - r["initial_capital_costs"] = initial_capex(m, p; _n=_n) + r["initial_capital_costs"] = value(m[Symbol("InitialCapexNoIncentives"*_n)]) future_replacement_cost, present_replacement_cost = replacement_costs_future_and_present(m, p; _n=_n) r["initial_capital_costs_after_incentives"] = r["lifecycle_capital_costs"] / p.third_party_factor - present_replacement_cost @@ -150,85 +156,6 @@ function add_financial_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _ end -""" - initial_capex(m::JuMP.AbstractModel, p::REoptInputs; _n="") - -Calculate and return the up-front capital costs for all technologies, in present value, excluding replacement costs and -incentives. -""" -function initial_capex(m::JuMP.AbstractModel, p::REoptInputs; _n="") - initial_capex = p.s.financial.offgrid_other_capital_costs - value(m[Symbol("AvoidedCapexByASHP"*_n)]) - value(m[Symbol("AvoidedCapexByGHP"*_n)]) - - if !isempty(p.techs.gen) && isempty(_n) # generators not included in multinode model - initial_capex += p.s.generator.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["Generator"] - end - - if !isempty(p.techs.pv) - for pv in p.s.pvs - initial_capex += pv.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])[pv.name] - end - end - - for b in p.s.storage.types.elec - if p.s.storage.attr[b].max_kw > 0 - initial_capex += p.s.storage.attr[b].installed_cost_per_kw * value.(m[Symbol("dvStoragePower"*_n)])[b] + - p.s.storage.attr[b].installed_cost_per_kwh * value.(m[Symbol("dvStorageEnergy"*_n)])[b] - end - end - - for b in p.s.storage.types.thermal - if p.s.storage.attr[b].max_kw > 0 - initial_capex += p.s.storage.attr[b].installed_cost_per_kwh * value.(m[Symbol("dvStorageEnergy"*_n)])[b] - end - end - - if "Wind" in p.techs.all - initial_capex += p.s.wind.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["Wind"] - end - - if "CHP" in p.techs.all - chp_size_kw = value.(m[Symbol("dvPurchaseSize"*_n)])["CHP"] - initial_capex += get_chp_initial_capex(p, chp_size_kw) - end - - if "SteamTurbine" in p.techs.all - initial_capex += p.s.steam_turbine.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["SteamTurbine"] - end - - if "Boiler" in p.techs.all - initial_capex += p.s.boiler.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["Boiler"] - end - - if "AbsorptionChiller" in p.techs.all - initial_capex += p.s.absorption_chiller.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["AbsorptionChiller"] - end - - if !isempty(p.s.ghp_option_list) - - for option in enumerate(p.s.ghp_option_list) - - if option[2].heat_pump_configuration == "WSHP" - initial_capex += option[2].installed_cost_per_kw[2]*option[2].heatpump_capacity_ton*value(m[Symbol("binGHP"*_n)][option[1]]) - elseif option[2].heat_pump_configuration == "WWHP" - initial_capex += (option[2].wwhp_heating_pump_installed_cost_curve[2]*option[2].wwhp_heating_pump_capacity_ton + option[2].wwhp_cooling_pump_installed_cost_curve[2]*option[2].wwhp_cooling_pump_capacity_ton)*value(m[Symbol("binGHP"*_n)][option[1]]) - else - @warn "Unknown heat pump configuration provided, excluding GHP costs from initial capital costs." - end - end - end - - if "ASHPSpaceHeater" in p.techs.all - initial_capex += p.s.ashp.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["ASHPSpaceHeater"] - end - - if "ASHPWaterHeater" in p.techs.all - initial_capex += p.s.ashp_wh.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["ASHPWaterHeater"] - end - - return initial_capex -end - - """ replacement_costs_future_and_present(m::JuMP.AbstractModel, p::REoptInputs; _n="") @@ -404,41 +331,4 @@ function get_depreciation_schedule(p::REoptInputs, tech::Union{AbstractTech,Abst depreciation_schedule[1] += (tech.macrs_bonus_fraction * macrs_bonus_basis) return depreciation_schedule -end - - -""" - get_chp_initial_capex(p::REoptInputs, size_kw::Float64) - -CHP has a cost-curve input option, so calculating the initial CapEx requires more logic than typical tech CapEx calcs -""" -function get_chp_initial_capex(p::REoptInputs, size_kw::Float64) - # CHP.installed_cost_per_kw is now a list with potentially > 1 elements - cost_list = p.s.chp.installed_cost_per_kw - size_list = p.s.chp.tech_sizes_for_cost_curve - chp_size = size_kw - initial_capex = 0.0 - if typeof(cost_list) == Vector{Float64} - if chp_size <= size_list[1] - initial_capex = chp_size * cost_list[1] # Currently not handling non-zero cost ($) for 0 kW size input - elseif chp_size > size_list[end] - initial_capex = chp_size * cost_list[end] - else - for s in 2:length(size_list) - if (chp_size > size_list[s-1]) && (chp_size <= size_list[s]) - slope = (cost_list[s] * size_list[s] - cost_list[s-1] * size_list[s-1]) / - (size_list[s] - size_list[s-1]) - initial_capex = cost_list[s-1] * size_list[s-1] + (chp_size - size_list[s-1]) * slope - end - end - end - else - initial_capex = cost_list * chp_size - #Add supplementary firing capital cost - # chp_supp_firing_size = self.nested_outputs["Scenario"]["Site"][tech].get("size_supplementary_firing_kw") - # chp_supp_firing_cost = self.inputs[tech].get("supplementary_firing_capital_cost_per_kw") or 0 - # initial_capex += chp_supp_firing_size * chp_supp_firing_cost - end - - return initial_capex end \ No newline at end of file diff --git a/src/results/proforma.jl b/src/results/proforma.jl index ec121745f..8bea1ac78 100644 --- a/src/results/proforma.jl +++ b/src/results/proforma.jl @@ -361,7 +361,7 @@ function update_metrics(m::Metrics, p::REoptInputs, tech::AbstractTech, tech_nam existing_kw = :existing_kw in fieldnames(typeof(tech)) ? tech.existing_kw : 0 new_kw = total_kw - existing_kw if tech_name == "CHP" - capital_cost = get_chp_initial_capex(p, results["CHP"]["size_kw"]) + capital_cost = results["CHP"]["initial_capital_costs"] else capital_cost = new_kw * tech.installed_cost_per_kw end diff --git a/test/runtests.jl b/test/runtests.jl index 8ed802c98..abacb2914 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -160,6 +160,15 @@ else # run HiGHS tests @test r["Financial"]["lcc"] ≈ 1.2391786e7 rtol=1e-5 @test r["ElectricStorage"]["size_kw"] ≈ 49.0 atol=0.1 @test r["ElectricStorage"]["size_kwh"] ≈ 83.3 atol=0.1 + + # Test constrained CAPEX + initial_capex_no_incentives = r["Financial"]["initial_capital_costs"] + max_capex = initial_capex_no_incentives * 0.60 + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + data = JSON.parsefile("./scenarios/pv_storage.json") + data["Financial"]["max_initial_capital_costs_before_incentives"] = max_capex + r = run_reopt(model, data) + @test r["Financial"]["initial_capital_costs"] ≈ max_capex rtol=1e-5 end # TODO test MPC with outages @@ -759,6 +768,14 @@ else # run HiGHS tests @test round(results["CHP"]["size_kw"], digits=0) ≈ 263.0 atol=50.0 @test round(results["Financial"]["lcc"], digits=0) ≈ 1.11e7 rtol=0.05 + + # Test constrained CAPEX + initial_capex_no_incentives = results["Financial"]["initial_capital_costs"] + min_capex = initial_capex_no_incentives * 1.3 + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + data_sizing["Financial"]["min_initial_capital_costs_before_incentives"] = min_capex + results = run_reopt(model, data_sizing) + @test results["Financial"]["initial_capital_costs"] ≈ min_capex rtol=1e-5 end @testset "CHP Cost Curve and Min Allowable Size" begin @@ -812,13 +829,9 @@ else # run HiGHS tests init_capex_total_expected = init_capex_chp_expected + init_capex_pv_expected lifecycle_capex_total_expected = lifecycle_capex_chp_expected + lifecycle_capex_pv_expected - init_capex_total = results["Financial"]["initial_capital_costs"] - lifecycle_capex_total = results["Financial"]["initial_capital_costs_after_incentives"] - - # Check initial CapEx (pre-incentive/tax) and life cycle CapEx (post-incentive/tax) cost with expect - @test init_capex_total_expected ≈ init_capex_total atol=0.0001*init_capex_total_expected - @test lifecycle_capex_total_expected ≈ lifecycle_capex_total atol=0.0001*lifecycle_capex_total_expected + @test init_capex_total_expected ≈ results["Financial"]["initial_capital_costs"] atol=0.0001*init_capex_total_expected + @test lifecycle_capex_total_expected ≈ results["Financial"]["initial_capital_costs_after_incentives"] atol=0.0001*lifecycle_capex_total_expected # Test CHP.min_allowable_kw - the size would otherwise be ~100 kW less by setting min_allowable_kw to zero @test results["CHP"]["size_kw"] ≈ data_cost_curve["CHP"]["min_allowable_kw"] atol=0.1 @@ -2820,7 +2833,7 @@ else # run HiGHS tests s = Scenario(d) p = REoptInputs(s) - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.05)) results = run_reopt(m, p) #Case 3: ASHP present but does not run because dispatch is not forced and boiler fuel is cheap @@ -2953,8 +2966,8 @@ else # run HiGHS tests # Throw a handled error d = JSON.parsefile("./scenarios/logger.json") - m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - m2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) + m2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) r = run_reopt([m1,m2], d) @test r["status"] == "error" @test "Messages" ∈ keys(r) @@ -2964,7 +2977,7 @@ else # run HiGHS tests @test length(r["Messages"]["warnings"]) > 0 @test r["Messages"]["has_stacktrace"] == false - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) r = run_reopt(m, d) @test r["status"] == "error" @test "Messages" ∈ keys(r) @@ -2977,8 +2990,8 @@ else # run HiGHS tests @test isa(REoptInputs(d), Dict) # Using filepath - n1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - n2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + n1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) + n2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) r = run_reopt([n1,n2], "./scenarios/logger.json") @test r["status"] == "error" @test "Messages" ∈ keys(r) @@ -2987,7 +3000,7 @@ else # run HiGHS tests @test length(r["Messages"]["errors"]) > 0 @test length(r["Messages"]["warnings"]) > 0 - n = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + n = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) r = run_reopt(n, "./scenarios/logger.json") @test r["status"] == "error" @test "Messages" ∈ keys(r) @@ -3000,8 +3013,8 @@ else # run HiGHS tests d["ElectricLoad"]["doe_reference_name"] = "MidriseApartment" d["ElectricTariff"]["urdb_label"] = "62c70a6c40a0c425535d387x" - m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - m2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) + m2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) r = run_reopt([m1,m2], d) @test r["status"] == "error" @test "Messages" ∈ keys(r) @@ -3010,7 +3023,7 @@ else # run HiGHS tests @test length(r["Messages"]["errors"]) > 0 @test length(r["Messages"]["warnings"]) > 0 - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) r = run_reopt(m, d) @test r["status"] == "error" @test "Messages" ∈ keys(r) @@ -3023,8 +3036,8 @@ else # run HiGHS tests @test isa(REoptInputs(d), Dict) # Using filepath - n1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - n2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + n1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) + n2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) r = run_reopt([n1,n2], "./scenarios/logger.json") @test r["status"] == "error" @test "Messages" ∈ keys(r) @@ -3033,7 +3046,7 @@ else # run HiGHS tests @test length(r["Messages"]["errors"]) > 0 @test length(r["Messages"]["warnings"]) > 0 - n = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + n = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) r = run_reopt(n, "./scenarios/logger.json") @test r["status"] == "error" @test "Messages" ∈ keys(r) @@ -3190,7 +3203,7 @@ else # run HiGHS tests input_data["ElectricLoad"]["loads_kw"][31*24+29*24+3*24+16] = peak_load s = Scenario(input_data) inputs = REoptInputs(s) - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.01, "output_flag" => false, "log_to_console" => false)) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.05, "output_flag" => false, "log_to_console" => false)) results = run_reopt(m, inputs) # TOU Energy charges @@ -3249,7 +3262,7 @@ else # run HiGHS tests s = Scenario(input_data) inputs = REoptInputs(s) - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.01, "output_flag" => false, "log_to_console" => false)) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.1, "output_flag" => false, "log_to_console" => false)) results = run_reopt(m, inputs) electric_load = results["ElectricLoad"]["load_series_kw"] @@ -3334,6 +3347,6 @@ else # run HiGHS tests results = run_reopt([m1,m2], inputs) payback = results["Financial"]["capital_costs_after_non_discounted_incentives"] / results["Financial"]["year_one_total_operating_cost_savings_after_tax"] @test round(results["Financial"]["simple_payback_years"], digits=2) ≈ round(payback, digits=2) - end + end end end \ No newline at end of file