Skip to content

Commit 19de1d3

Browse files
authored
Merge pull request #491 from NREL/add-cost-constraint
Option to constrain initial CAPEX
2 parents 51faf30 + 3431afd commit 19de1d3

8 files changed

Lines changed: 220 additions & 149 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Classify the change according to the following categories:
2525
### Deprecated
2626
### Removed
2727

28+
## add-cost-constraint
29+
### Added
30+
- 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.
2831

2932
## fix-pbi
3033
### Fixed

src/constraints/cost_curve_constraints.jl

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ There are two situations under which we add binary constraints to the model in o
66
for a technology:
77
1. When a technology has tax or investment incentives with upper capacity limits < tech.max_kw
88
- first segment(s) have lower slope than last segment
9-
2. When a technology has multiple cost/size pairs (not implemented yet, used for CHP in v1)
9+
2. When a technology has multiple cost/size pairs
1010
- we interpolate the slope between the cost/size points, typically with economies of scale pricing
1111
We used to use cost curve segments for when a technology has a non-zero existing_kw by setting the first segment to a
1212
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="")
6565
@constraint(m, [t in p.techs.segmented],
6666
m[Symbol("dvPurchaseSize"*_n)][t] == m[Symbol("dvSize"*_n)][t] - p.existing_sizes[t]
6767
)
68+
end
69+
70+
function add_capex_constraints(m, p; _n="")
71+
@warn "Adding capital costs constraints. These may cause an infeasible problem in some cases, particularly for resilience runs."
72+
if (!isnothing(p.s.financial.min_initial_capital_costs_before_incentives) && !isnothing(p.s.financial.max_initial_capital_costs_before_incentives)
73+
&& p.s.financial.min_initial_capital_costs_before_incentives > p.s.financial.max_initial_capital_costs_before_incentives)
74+
throw(@error("Minimum required capital cost is larger than maximum required capital cost - this problem is infeasible."))
75+
end
76+
if !isnothing(p.s.financial.min_initial_capital_costs_before_incentives)
77+
@constraint(m,
78+
m[:InitialCapexNoIncentives] >= p.s.financial.min_initial_capital_costs_before_incentives
79+
)
80+
end
81+
if !isnothing(p.s.financial.max_initial_capital_costs_before_incentives)
82+
@constraint(m,
83+
m[:InitialCapexNoIncentives] <= p.s.financial.max_initial_capital_costs_before_incentives
84+
)
85+
end
86+
end
87+
88+
function initial_capex_no_incentives(m::JuMP.AbstractModel, p::REoptInputs; _n="")
89+
m[:InitialCapexNoIncentives] = JuMP.GenericAffExpr{Float64, JuMP.VariableRef}(0.0) # Avoids MethodError
90+
91+
add_to_expression!(m[:InitialCapexNoIncentives],
92+
p.s.financial.offgrid_other_capital_costs - m[Symbol("AvoidedCapexByASHP"*_n)] - m[Symbol("AvoidedCapexByGHP"*_n)]
93+
)
94+
95+
if !isempty(p.techs.gen) && isempty(_n) # generators not included in multinode model
96+
add_to_expression!(m[:InitialCapexNoIncentives],
97+
p.s.generator.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["Generator"]
98+
)
99+
end
100+
101+
if !isempty(p.techs.pv)
102+
for pv in p.s.pvs
103+
add_to_expression!(m[:InitialCapexNoIncentives],
104+
pv.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)][pv.name]
105+
)
106+
end
107+
end
108+
109+
for b in p.s.storage.types.elec
110+
if p.s.storage.attr[b].max_kw > 0
111+
add_to_expression!(m[:InitialCapexNoIncentives],
112+
p.s.storage.attr[b].installed_cost_per_kw * m[Symbol("dvStoragePower"*_n)][b]
113+
+ p.s.storage.attr[b].installed_cost_per_kwh * m[Symbol("dvStorageEnergy"*_n)][b]
114+
)
115+
end
116+
end
117+
118+
for b in p.s.storage.types.thermal
119+
if p.s.storage.attr[b].max_kw > 0
120+
add_to_expression!(m[:InitialCapexNoIncentives],
121+
p.s.storage.attr[b].installed_cost_per_kwh * m[Symbol("dvStorageEnergy"*_n)][b]
122+
)
123+
end
124+
end
125+
126+
if "Wind" in p.techs.all
127+
add_to_expression!(m[:InitialCapexNoIncentives],
128+
p.s.wind.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["Wind"]
129+
)
130+
end
131+
132+
if "CHP" in p.techs.all
133+
m[:CHPCapexNoIncentives] = JuMP.GenericAffExpr{Float64, JuMP.VariableRef}()
134+
cost_list = p.s.chp.installed_cost_per_kw
135+
size_list = p.s.chp.tech_sizes_for_cost_curve
136+
137+
t="CHP"
138+
if t in p.techs.segmented
139+
# Use "no incentives" version of p.cap_cost_slope and p.seg_yint
140+
cost_slope_no_inc = [cost_list[1]]
141+
seg_yint_no_inc = [0.0]
142+
for s in range(2, stop=length(size_list))
143+
tmp_slope = round((cost_list[s] * size_list[s] - cost_list[s-1] * size_list[s-1]) /
144+
(size_list[s] - size_list[s-1]), digits=0)
145+
tmp_y_int = round(cost_list[s-1] * size_list[s-1] - tmp_slope * size_list[s-1], digits=0)
146+
append!(cost_slope_no_inc, tmp_slope)
147+
append!(seg_yint_no_inc, tmp_y_int)
148+
end
149+
append!(cost_slope_no_inc, cost_list[end])
150+
append!(seg_yint_no_inc, 0.0)
151+
152+
add_to_expression!(m[:CHPCapexNoIncentives],
153+
sum(cost_slope_no_inc[s] * m[Symbol("dvSegmentSystemSize"*t)][s] +
154+
seg_yint_no_inc[s] * m[Symbol("binSegment"*t)][s] for s in 1:p.n_segs_by_tech[t])
155+
)
156+
else
157+
add_to_expression!(m[:CHPCapexNoIncentives], cost_list * m[Symbol("dvPurchaseSize"*_n)]["CHP"])
158+
end
159+
if p.s.chp.supplementary_firing_capital_cost_per_kw > 0
160+
add_to_expression!(m[:CHPCapexNoIncentives],
161+
p.s.chp.supplementary_firing_capital_cost_per_kw * m[Symbol("dvSupplementaryFiringSize"*_n)]["CHP"]
162+
)
163+
end
164+
add_to_expression!(m[:InitialCapexNoIncentives], m[:CHPCapexNoIncentives])
165+
end
166+
167+
if "SteamTurbine" in p.techs.all
168+
add_to_expression!(m[:InitialCapexNoIncentives],
169+
p.s.steam_turbine.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["SteamTurbine"]
170+
)
171+
end
172+
173+
if "Boiler" in p.techs.all
174+
add_to_expression!(m[:InitialCapexNoIncentives],
175+
p.s.boiler.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["Boiler"]
176+
)
177+
end
178+
179+
if "AbsorptionChiller" in p.techs.all
180+
add_to_expression!(m[:InitialCapexNoIncentives],
181+
p.s.absorption_chiller.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["AbsorptionChiller"]
182+
)
183+
end
184+
185+
if !isempty(p.s.ghp_option_list)
186+
for option in enumerate(p.s.ghp_option_list)
187+
if option[2].heat_pump_configuration == "WSHP"
188+
add_to_expression!(m[:InitialCapexNoIncentives],
189+
option[2].installed_cost_per_kw[2]*option[2].heatpump_capacity_ton*m[Symbol("binGHP"*_n)][option[1]]
190+
)
191+
elseif option[2].heat_pump_configuration == "WWHP"
192+
add_to_expression!(m[:InitialCapexNoIncentives],
193+
(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]]
194+
)
195+
else
196+
@warn "Unknown heat pump configuration provided, excluding GHP costs from initial capital costs."
197+
end
198+
end
199+
end
200+
201+
if "ASHPSpaceHeater" in p.techs.all
202+
add_to_expression!(m[:InitialCapexNoIncentives],
203+
p.s.ashp.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["ASHPSpaceHeater"]
204+
)
205+
end
206+
207+
if "ASHPWaterHeater" in p.techs.all
208+
add_to_expression!(m[:InitialCapexNoIncentives],
209+
p.s.ashp_wh.installed_cost_per_kw * m[Symbol("dvPurchaseSize"*_n)]["ASHPWaterHeater"]
210+
)
211+
end
212+
213+
if !isempty(p.s.electric_utility.outage_durations)
214+
add_to_expression!(m[:InitialCapexNoIncentives],
215+
m[:mgTotalTechUpgradeCost] + m[:dvMGStorageUpgradeCost]
216+
)
217+
end
68218
end

src/core/financial.jl

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
macrs_five_year::Array{Float64,1} = [0.2, 0.32, 0.192, 0.1152, 0.1152, 0.0576], # IRS pub 946
2020
macrs_seven_year::Array{Float64,1} = [0.1429, 0.2449, 0.1749, 0.1249, 0.0893, 0.0892, 0.0893, 0.0446],
2121
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.
22-
offgrid_other_annual_costs::Real = 0.0 # only applicable when `off_grid_flag` is true. Considered tax deductible for owner. Costs are per year.
22+
offgrid_other_annual_costs::Real = 0.0 # only applicable when `off_grid_flag` is true. Considered tax deductible for owner. Costs are per year.
23+
min_initial_capital_costs_before_incentives::Union{Nothing,Real} = nothing # minimum up-front capital cost for all technologies, excluding replacement costs and incentives.
24+
max_initial_capital_costs_before_incentives::Union{Nothing,Real} = nothing # maximum up-front capital cost for all technologies, excluding replacement costs and incentives.
2325
# Emissions cost inputs
2426
CO2_cost_per_tonne::Real = 51.0,
2527
CO2_cost_escalation_rate_fraction::Real = 0.042173,
@@ -62,6 +64,8 @@ struct Financial
6264
macrs_seven_year::Array{Float64,1}
6365
offgrid_other_capital_costs::Float64
6466
offgrid_other_annual_costs::Float64
67+
min_initial_capital_costs_before_incentives::Union{Nothing,Real}
68+
max_initial_capital_costs_before_incentives::Union{Nothing,Real}
6569
CO2_cost_per_tonne::Float64
6670
CO2_cost_escalation_rate_fraction::Float64
6771
NOx_grid_cost_per_tonne::Float64
@@ -94,6 +98,8 @@ struct Financial
9498
macrs_seven_year::Array{<:Real,1} = [0.1429, 0.2449, 0.1749, 0.1249, 0.0893, 0.0892, 0.0893, 0.0446],
9599
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.
96100
offgrid_other_annual_costs::Real = 0.0, # only applicable when `off_grid_flag` is true. Considered tax deductible for owner.
101+
min_initial_capital_costs_before_incentives::Union{Nothing,Real} = nothing,
102+
max_initial_capital_costs_before_incentives::Union{Nothing,Real} = nothing,
97103
# Emissions cost inputs
98104
CO2_cost_per_tonne::Real = 51.0,
99105
CO2_cost_escalation_rate_fraction::Real = 0.042173,
@@ -191,6 +197,8 @@ struct Financial
191197
macrs_seven_year,
192198
offgrid_other_capital_costs,
193199
offgrid_other_annual_costs,
200+
min_initial_capital_costs_before_incentives,
201+
max_initial_capital_costs_before_incentives,
194202
CO2_cost_per_tonne,
195203
CO2_cost_escalation_rate_fraction,
196204
NOx_grid_cost_per_tonne,

src/core/reopt.jl

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs)
398398
@warn "Adding binary variable(s) to model cost curves"
399399
add_cost_curve_vars_and_constraints(m, p)
400400
for t in p.techs.segmented # cannot have this for statement in sum( ... for t in ...) ???
401-
m[:TotalTechCapCosts] += p.third_party_factor * (
401+
m[:TotalTechCapCosts] += p.third_party_factor * (
402402
sum(p.cap_cost_slope[t][s] * m[Symbol("dvSegmentSystemSize"*t)][s] +
403403
p.seg_yint[t][s] * m[Symbol("binSegment"*t)][s] for s in 1:p.n_segs_by_tech[t])
404404
)
@@ -477,6 +477,12 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs)
477477
end
478478
end
479479

480+
# Get CAPEX expressions and optionally constrain CAPEX
481+
initial_capex_no_incentives(m, p)
482+
if !isnothing(p.s.financial.min_initial_capital_costs_before_incentives) || !isnothing(p.s.financial.max_initial_capital_costs_before_incentives)
483+
add_capex_constraints(m, p)
484+
end
485+
480486
################################# Objective Function ########################################
481487
@expression(m, Costs,
482488
# Capital Costs

src/results/chp.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- `year_one_standby_cost_after_tax` CHP standby charges in year one, after tax
2222
- `lifecycle_standby_cost_after_tax` Present value of all CHP standby charges, after tax.
2323
- `thermal_production_series_mmbtu_per_hour`
24+
- `initial_capital_costs` Initial capital costs of the CHP system, before incentives [\$]
2425
2526
!!! note "'Series' and 'Annual' energy outputs are average annual"
2627
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="")
137138
r["year_one_standby_cost_before_tax"] = round(value(m[Symbol("TotalCHPStandbyCharges")]) / p.pwf_e, digits=0)
138139
r["year_one_standby_cost_after_tax"] = r["year_one_standby_cost_before_tax"] * (1 - p.s.financial.offtaker_tax_rate_fraction)
139140
r["lifecycle_standby_cost_after_tax"] = round(value(m[Symbol("TotalCHPStandbyCharges")]) * (1 - p.s.financial.offtaker_tax_rate_fraction), digits=0)
140-
141+
r["initial_capital_costs"] = round(value(m[Symbol("CHPCapexNoIncentives")]), digits=2)
141142

142143
d["CHP"] = r
143144
nothing

0 commit comments

Comments
 (0)