-
Notifications
You must be signed in to change notification settings - Fork 1
/
FuelPrice.jl
290 lines (242 loc) · 13.3 KB
/
FuelPrice.jl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""
FuelPrice(;file, fp_scalar=1e3) <: Modification
FuelPrice is a [`Modification`](@ref) allowing users to specify fuel prices for different fuels by region. If multiple steps and quantities are given, the fuel price for a given region will be computed endogenously.
* [`modify_raw_data!(mod::FuelPrice, config, data)`](@ref)
* [`modify_setup_data!(mod::FuelPrice, config, data)`](@ref)
* [`modify_model!(mod::FuelPrice, config, data, model)`](@ref)
* [`modify_results!(mod::FuelPrice, config, data)`](@ref)
To adjust price by hour or year, see [`AdjustHourly`](@ref) or [`AdjustYearly`](@ref).
## Fields
* `file` - file path to the table of fuel steps. See [`summarize_table(::Val{:fuel_price})`]@(ref)
* `fp_scalar = 1e3` - a `Float64` for how much to scale the fuel price variables by. This helps with numerical instability, given that some fuel steps can be very large and could bloat the RHS and bounds range of the model. The default value of 1e3, for example, means that the quantity variables will all be in terms of thousands of MMBtu rather than MMBtu.
"""
Base.@kwdef struct FuelPrice <: Modification
file::String
fp_scalar::Float64 = 1e3
end
export FuelPrice
mod_rank(::Type{<:FuelPrice}) = 0.0
@doc """
summarize_table(::Val{:fuel_price})
$(table2markdown(summarize_table(Val(:fuel_price))))
"""
function summarize_table(::Val{:fuel_price})
df = TableSummary()
push!(df,
(:genfuel, String, NA, true, "The type of fuel that the price applies for. i.e. `ng` or `coal`"),
(:area, String, NA, true, "The area that the price applies for i.e. `nation`. Leave blank if grid-wide"),
(:subarea, String, NA, true, "The subarea that the price applies for i.e. `narnia`. Leave blank if grid-wide"),
(:filter_, String, NA, false, "I.e. `filter1`, `filter2`, etc. Other filter conditions that the price applies for, see [`parse_comparison`](@ref) for ideas"),
(:price, Float64, DollarsPerMMBtu, true, "The price of 1 MMBtu of fuel"),
(:quantity, Float64, MMBtu, true, "The number of MMBtu of the fuel available at the price in each year. Set to `Inf` for unlimited."),
)
return df
end
"""
modify_raw_data!(mod::FuelPrice, config, data)
Read table from `mod.file` into `data[:fuel_price]`
"""
function modify_raw_data!(mod::FuelPrice, config, data)
config[:fuel_price_file] = mod.file
read_table!(config, data, :fuel_price_file=>:fuel_price)
return nothing
end
"""
modify_setup_data!(mod::FuelPrice, config, data)
Zero out the `fuel_price` column of the `gen` table, as it will get overwritten later by this Modification. This is to avoid double-counting the fuel cost.
"""
function modify_setup_data!(mod::FuelPrice, config, data)
# Set the fuel_price table to all zero for the areas affected by `mod`.
gen = get_table(data, :gen)
fp = get_table(data, :fuel_price)
if hasproperty(gen, :fuel_price)
to_container!(gen, :fuel_price)
v = fill(0.0, get_num_years(data))
gdf = groupby(fp, Not([:price, :quantity]))
for sdf in gdf
row = sdf[1,:]
gen_idxs = get_row_idxs(gen, parse_comparisons(row))
for gen_idx in gen_idxs
gen.fuel_price[gen_idx] = set_yearly(gen.fuel_price[gen_idx], v)
end
end
else
bn = ByNothing(0.0)
gen.fuel_price = Container[bn for _ in axes(gen,1)]
end
end
"""
modify_model!(mod::FuelPrice, config, data, model)
* Make `data[:fuel_markets]` to keep track of each of the fuel markets
* Add variable `fuel_sold[fuel_price_idx, yr_idx, hr_idx]`: total fuel sold at each price step for each time interval
* Add expression `fuel_used[fuel_market_idx, yr_idx, hr_idx]`: total fuel used by generators for each market region for each time interval
* Add expression `fuel_price_obj[fuel_market_idx, yr_idx, hr_idx]`: total cost of the fuel, added to the objective.
* Add constraint `cons_fuel_sold[fuel_price_idx, yr_idx]`: constrain the total `fuel_sold` in each year to be ≤ yearly quantity
* Add constraint `cons_fuel_bal[fuel_market_idx, yr_idx, hr_idx]`: constrain the amount of fuel sold in each market region to equal the amount of fuel used in each market region.
`fuel_sold` and `fuel_used` will be scaled down using the fp_scalar to reduce the difference in size between the variables used together in these constraints.
This prevents issues with shadow prices which can sometimes be rounded to 0 when the objective scalar is high.
"""
function modify_model!(mod::FuelPrice, config, data, model)
@info "Adding endogenous fuel prices to the model via FuelPrice Modification"
table = get_table(data, :fuel_price)
nhr = get_num_hours(data)
nyr = get_num_years(data)
heat_rate = get_table_col(data, :gen, :heat_rate)
gen = get_table(data, :gen)
# Make a DataFrame with each row being a fuel market.
filter_cols = filter!(contains("filter"), names(table))
gdf = groupby(table, [:genfuel, :area, :subarea, filter_cols...])
fuel_markets = combine(gdf, _get_row_idxs)
rename!(fuel_markets, :x1=>:fuel_price_idxs)
fuel_markets.filters = map(parse_comparisons, eachrow(fuel_markets))
fuel_markets.gen_idxs = map(fuel_markets.filters) do filt
idxs = get_row_idxs(gen, filt)
isempty(idxs) && @warn "No generators found for fuel market with filters $filt, removing from table"
return idxs
end
filter!(:gen_idxs=>!isempty, fuel_markets)
# Pull out other necessary data
data[:fuel_markets] = fuel_markets
pgen = model[:pgen_gen]::Array{VariableRef, 3}
hour_weights = get_hour_weights(data)
fp_scalar = mod.fp_scalar
# Create variable fuel_sold for fuel sold in each row of the fuel table
@info "Create variable fuel_sold for each price step"
@variable(model,
fuel_sold[
fuel_idx in axes(table, 1),
yr_idx in 1:nyr,
],
lower_bound=0
)
# Create objective expression for fuel cost
@info "Create expression fuel_cost_obj for each price step"
@expression(model,
fuel_cost_obj[
fuel_idx in axes(table, 1),
yr_idx in 1:nyr,
],
fuel_sold[fuel_idx, yr_idx] * table.price[fuel_idx][yr_idx, :] * fp_scalar
)
# Add fuel_cost_obj to the objective function
add_obj_exp!(data, model, FuelCostTerm(), :fuel_cost_obj; oper=+)
# Create an expression fuel_used for total fuel used by generators for each genfuel-area-subarea combo over a year.
@info "Create expression fuel_used for each price step"
@expression(model,
fuel_used[
fm_idx in axes(fuel_markets,1),
yr_idx in 1:nyr
],
sum(
(pgen[gen_idx, yr_idx, hr_idx] * hour_weights[hr_idx] * heat_rate[gen_idx][yr_idx, hr_idx])
for gen_idx in fuel_markets.gen_idxs[fm_idx], hr_idx in 1:nhr
) / fp_scalar
)
# Set upper bound of fuel_sold to help the problem be more bounded, even though these will likely not be binding.
@info "Set upper bound of fuel_sold"
for (idx, quantity) in enumerate(table.quantity)
for yr_idx in 1:nyr
isfinite(quantity[yr_idx, :]) || continue
set_upper_bound.(fuel_sold[idx, yr_idx], quantity[yr_idx, :]/fp_scalar*1.1) # 10% margin to prevent this from binding instead of `cons_fuel_sold`
end
end
# Constrain the fuel sold for each row for each year to be less than or equal to the quantity
@info "Add cons_fuel_sold constraint for each price step"
@constraint(model,
cons_fuel_sold[
fuel_idx in axes(table,1),
yr_idx in 1:nyr;
isfinite(table.quantity[fuel_idx][yr_idx, :])
],
fuel_sold[fuel_idx, yr_idx] <= table.quantity[fuel_idx][yr_idx, :] / fp_scalar
)
# Constrain sum of fuel_sold in each genfuel-area-subarea combo to equal the fuel_used expression.
@info "Add cons_fuel_bal for each fuel region"
@constraint(model,
cons_fuel_bal[
fm_idx in axes(fuel_markets, 1),
yr_idx in 1:nyr
],
sum0(
fuel_idx -> fuel_sold[fuel_idx, yr_idx],
fuel_markets.fuel_price_idxs[fm_idx]
) == fuel_used[fm_idx, yr_idx] #both fuel_sold and fuel_used should be divide by the scalar so this is all set
)
do_not_parse!(data, :cons_fuel_bal)
end
"""
modify_results!(mod::FuelPrice, config, data)
* Calculate the clearing price for each market region for each fuel type.
* Equal to the shadow price of `cons_fuel_sold` for the cheapest fuel price step in the region plus the cheapest fuel price
* Add it to `fuel_markets.clearing_price` column
* Update `gen.fuel_price` column to use the clearing price (multiplied by the `heat_rate` column)
"""
function modify_results!(mod::FuelPrice, config, data)
## scale back to actual fuel quantities
fp_scalar = mod.fp_scalar
raw_results = get_raw_results(data)
raw_results[:fuel_sold] .*= fp_scalar
raw_results[:fuel_used] .*= fp_scalar
# no broadcasting method for SparseAxisArray so need to use loops, open to suggestions to make more efficient
cons_fuel_sold = get_raw_result(data, :cons_fuel_sold)
for i in eachindex(cons_fuel_sold)
cons_fuel_sold[i] /= fp_scalar
end
# Do not need cons_fuel_bal
# cons_fuel_bal = get_raw_result(data, :cons_fuel_bal)
# for i in eachindex(cons_fuel_bal)
# cons_fuel_bal[i] /= fp_scalar
# end
# get easy access data
fuel_sold = get_raw_result(data, :fuel_sold)
fuel_used = get_raw_result(data, :fuel_used)
fuel_price = get_table(data, :fuel_price)
fuel_markets = get_table(data, :fuel_markets)
nyr = get_num_years(data)
nhr = get_num_hours(data)
gen = get_table(data, :gen)
cp = Container[ByNothing(0.0) for _ in axes(fuel_markets, 1)]
add_table_col!(data, :fuel_markets, :clearing_price, cp, DollarsPerMMBtuSold, "Annual clearing price, in Dollars per MMBtu of energy.")
add_table_col!(data, :fuel_markets, :fuel_sold, fuel_used, MMBtu, "Quantity of fuel sold into this market, in MMBtu")
add_results_formula!(data, :fuel_markets, :fuel_cost, "SumYearly(fuel_sold,clearing_price)", Dollars, "Total cost of fuel sold in the market(s) (using the clearing price)")
add_results_formula!(data, :fuel_markets, :fuel_sold_total, "SumYearly(fuel_sold)", MMBtu, "Total amount of fuel sold in the market(s)")
add_results_formula!(data, :fuel_markets, :fuel_clearing_price_per_mmbtu, "fuel_cost/fuel_sold_total", DollarsPerMMBtuSold, "Average price of fuel paid to these market(s). Computed using the clearing price")
# Compute the clearing price for each genfuel-area-subarea combo
# The clearing price would be the shadow price + the price of the cheapest fuel option in each region.
for row in eachrow(fuel_markets)
# # Find the index of the cheapest fuel price for each year in the market region
fp_idxs = [argmin(pf_idx->mean(fuel_price.price[pf_idx][yr_idx, :]), row.fuel_price_idxs) for yr_idx in 1:nyr]
# The following commented section of code can be used in place of the line above and should give the same clearing price in the end
# The exception is if there is a step that isn't actually binding but still filtered as filled because it is very nearly filled
# To avoid complications from this edge case, and for simplicity, we are opting to use the min step + shadow price (above)
#find idxs of the max completely filled step in each year
# fp_idxs = []
# for yr_idx in 1:nyr
# # find all filled steps
# tol = 0.99999
# fill_idxs = filter(step_idx -> fuel_sold[step_idx, yr_idx] >= fuel_price.quantity[step_idx][yr_idx, :]*tol, row.fuel_price_idxs)
# if fill_idxs == Int64[] # no filled steps
# #set to the min price for that region
# fp_idx = argmin(pf_idx->mean(fuel_price.price[pf_idx][yr_idx, :]), row.fuel_price_idxs)
# else
# #find max price of the filled steps
# fp_idx = argmax(fill_idx->mean(fuel_price.price[fill_idx][yr_idx, :]), fill_idxs) #assumes that indexing matches between fuel_price and fuel_sold, which it should
# end
# push!(fp_idxs, fp_idx)
# end
all(gen_idx->all(==(0), gen.fuel_price[gen_idx]), row.gen_idxs) || @warn "Found non-zero fuel_price in gen table with FuelPrice Modification. That could mean double-counting of fuel cost in the objective function!"
# Find the shadow price of the fuel sold constraint. I.e. the change in objective value, in dollars per MMBtu, by relaxing the `cons_fuel_sold` constraint.
# I.e. if we allowed one more unit of the fuel, this value is the difference between the clearing price and the price of the fuel step.
shad_price = ByYear([
(haskey(cons_fuel_sold, (fp_idxs[yr_idx], yr_idx)) ? cons_fuel_sold[fp_idxs[yr_idx], yr_idx] : 0.0)
for yr_idx in 1:nyr
])
row.clearing_price = fuel_price.price[fp_idxs] .- shad_price #shadow price should be negative so clearing price should be higher than step
for gen_idx in row.gen_idxs
fp = gen.fuel_price[gen_idx]
gen.fuel_price[gen_idx] = (fp .* 0) .+ row.clearing_price
end
end
end
struct FuelCostTerm <: Term end
_get_row_idxs(sdf) = Ref(getfield(sdf, :rows))