Profit Maximization Example

using Nonconvex.jl

This a example on how to use Nonconvex.jl to maximize the profit of a company operating in a defined market (through the OptimalBids API).

  • For this example, we will use a market of type PowerModelsMarkets which represents an energy spot market.
  • Case instance is IEEE 118 Bus Case
  • To ilustrate a situation where the company defines the distribution of volume across it's assets in a predefined way (and reduce the decision dimentionality), the only controlable variable to maximize the company profit is a multiplicative factor of all offers.

Include Needed Packages

using OptimalBids
using OptimalBids.PowerModelsMarkets
using HiGHS # Market Clearing Solver
using JuMP: optimizer_with_attributes

using Nonconvex
using NonconvexIpopt # Nonconvex.@load Ipopt
using NonconvexBayesian # Nonconvex.@load BayesOpt
using Zygote

using AbstractGPs
using KernelFunctions

using Plots # For some evaluation plots at the end
using Plots.PlotMeasures
using Downloads # To download Test Cases

Case Definition

# Read market data from IEEE 118 bus case
case_name = "pglib_opf_case118_ieee.m"
DATA_DIR = mktempdir()
case_file_path = joinpath(DATA_DIR, case_name)
Downloads.download("https://raw.githubusercontent.com/power-grid-lib/pglib-opf/01681386d084d8bd03b429abcd1ee6966f68b9a3/" * case_name, case_file_path)
network_data = PowerModels.parse_file(case_file_path)

# Measure maximum load
max_load = sum(load["pd"] for load in values(network_data["load"])) * network_data["baseMVA"]

# Pretend we are a company constructing a new set of generators in the grid.
# Choose a percentage of the total number of buses to install the new generators:
percentage_buses = 0.09

# We need the keys PowerModels uses to reference the appropriate buses in it's network data dictionary.
# First, find out all available keys:
bus_indexes = collect(keys(network_data["bus"]))
# Then, calculate number of buses that consitute the chose percent (`percentage_buses`):
num_buses = length(bus_indexes)
num_strategic_buses = ceil(Int, percentage_buses * num_buses)
# To avoid any biases let's grab some generators in the middle:
bus_indexes = bus_indexes[21:(21 + num_strategic_buses - 1)]
# Finally, add new generators to the network grid data and collect their reference keys.
generator_indexes = [
    add_generator(network_data, parse(Int, bus_idx)) for bus_idx in bus_indexes
]
11-element Vector{String}:
 "55"
 "56"
 "57"
 "58"
 "59"
 "60"
 "61"
 "62"
 "63"
 "64"
 "65"

OptimalBids API


# Define market
market = build_market(
    PowerModelsMarket,
    network_data,
    generator_indexes,
    optimizer_with_attributes(HiGHS.Optimizer, "LogLevel" => 0),
)

# Relative distribution of offers are sometimes predefined and cannot be changed at bidding time.
using Random
rng = MersenneTwister(666)
offer_weights = rand(rng, num_strategic_buses)
offer_weights = offer_weights / sum(offer_weights)

# However, the decision maker is allowed to increase all bids evenly:
min_total_volume = 0.0
max_total_volume = 155.0
range_mul_factor = min_total_volume:1.0:max_total_volume
bid_range = [offer_weights .* [i] for i in range_mul_factor]
p_curve = profit_curve!(market, bid_range)
maximum_pq_curve, argmax_pq_curve = findmax(p_curve)

# Mesure time to change bids and solve opf
num_opfs = 10
opf_time = @elapsed [profit_for_bid!(market, offer_weights) for _ = 1:num_opfs]
opf_time /= num_opfs

# Let's plot and see how the range profit evaluatiuon went:
plt_range = plot(collect(range_mul_factor) * 100 / max_load, p_curve,
    label="Range Evaluation",
    ylabel="Profit (USD)",
    xlabel="Bid Volume (% Market Share)",
    legend=:outertopright,
    left_margin=10mm,
    bottom_margin=10mm,
    size=(900, 600),
    color=:black,
    width=2.0
);
plt_comp = deepcopy(plt_range);

Nonconvex API

# Nonconvex needs a minimization objective function that only receives the decision vector.
function profit_function(total_volume)
    return - profit_for_bid!(market, offer_weights .* first(total_volume))
end

mutable struct StorageCallbackProfit <: Function
    start_time::Float64
    fcalls::Int
    visited_objective::Array{Float64}
    visited_volumes::Array{Float64}
    visited_times::Array{Float64}
    projection::Array{Float64}
end
StorageCallbackProfit(maxiter, start_time; projection=offer_weights) = StorageCallbackProfit(start_time, 0,
    Array{Float64}(undef, 5 * maxiter),
    Array{Float64}(undef, 5 * maxiter),
    Array{Float64}(undef, 5 * maxiter),
    projection
)

function (callback::StorageCallbackProfit)(total_volume)
    Zygote.@ignore callback.fcalls += 1
    Zygote.@ignore callback.visited_volumes[callback.fcalls] = sum(callback.projection .* total_volume)
    Zygote.@ignore callback.visited_times[callback.fcalls] = time() - callback.start_time
    obj = profit_for_bid!(market, callback.projection .* total_volume)
    Zygote.@ignore callback.visited_objective[callback.fcalls] = obj
    return - obj
end

Motivation for fixed weights

Random bids range evaluation


number_of_curves = 30
p_curves = Array{Any}(undef, number_of_curves)

min_total_volume = 0.0
max_total_volume = 655.0
range_mul_factor = min_total_volume:5.0:max_total_volume

num_points = length(range_mul_factor)

for i = 1:2
    offer_weights = rand(rng, num_strategic_buses)
    offer_weights = offer_weights / sum(offer_weights)

    # However, the decision maker is allowed to increase all bids evenly:
    bid_range = [offer_weights .* [j] for j in range_mul_factor]
    p_curves[i] = profit_curve!(market, bid_range)
end

for i = 3:number_of_curves
    mul_factor = rand(rng, max_total_volume/4:max_total_volume)
    offer_weights_up = rand(rng, num_strategic_buses)
    offer_weights_up = offer_weights_up / sum(offer_weights_up) * mul_factor
    offer_weights_down = rand(rng, num_strategic_buses)
    offer_weights_down = offer_weights_down / sum(offer_weights_down) * mul_factor
    norm(offer_weights_up - offer_weights_down, 1)
    direction = offer_weights_up - offer_weights_down

    # However, the decision maker is allowed to increase all bids evenly:
    bid_range = [offer_weights_down + direction * i for i in range(0,1, num_points)]
    p_curves[i] = profit_curve!(market, bid_range)
end

plot(range(-100,100, num_points), p_curves,
    label="Range Evaluation",
    ylabel="Profit (USD)",
    xlabel="Relative Distance (%)",
    legend=false,
    left_margin=10mm,
    bottom_margin=10mm,
    size=(900, 600)
)

Individual bids


min_total_volume = 0.0
max_total_volume = 155.0
range_mul_factor = min_total_volume:1.0:max_total_volume

plt_comp_individual = plot(collect(range_mul_factor) * 100 / max_load, p_curve,
    label="Regularized Benchmark",
    ylabel="Profit (USD)",
    xlabel="Bid Volume (% Market Share)",
    legend=:outertopright,
    left_margin=10mm,
    bottom_margin=10mm,
    size=(900, 600),
    color=:black,
    width=2.0
);

for i = 1:num_strategic_buses
    ind_offer = zeros(num_strategic_buses)
    ind_offer[i] = 1.0
    bid_range_individual = [ind_offer .* [i] for i in range_mul_factor]
    p_curve_individual = profit_curve!(market, bid_range_individual)
    plot!(plt_comp_individual, collect(range_mul_factor) * 100 / max_load, p_curve_individual, label="Node $i")
end

plot(plt_comp_individual)

Unconstrained bids


# Max Number of Iterations for the solution method (proxy to a time limit at bidding time).
# ps.: Currently, no option for limiting fcalls.
maxiter = 60

storage_profit_function = StorageCallbackProfit(maxiter * 10, time(); projection=ones(num_strategic_buses))

# Build Nonconvex optimization model:
model = Nonconvex.Model()
set_objective!(model, storage_profit_function, flags = [:expensive])
addvar!(model, fill(min_total_volume, num_strategic_buses), fill(max_total_volume * 2, num_strategic_buses))
add_ineq_constraint!(model, x -> sum(x) - max_total_volume * 8)

# Solution Method: Bayesian Optimization
alg = BayesOptAlg(IpoptAlg())
options = BayesOptOptions(
    sub_options = IpoptOptions(max_iter = 20, print_level = 0),
    ninit=3,
    maxiter = maxiter, ftol = 1e-4, ctol = 1e-5, initialize=true, postoptimize=false,
    kernel= RationalKernel(α=2.27e8) ∘ ScaleTransform(0.01),
    noise=0.001,
    std_multiple=8.67e4,
    fit_prior=false # not working with custom priors
)

# Optimize model:
r_bayes = optimize(model, alg, offer_weights * 5; options = options)

best_solution = r_bayes.minimizer
best_profit = -r_bayes.minimum

plt_comp_individual = plot(collect(range_mul_factor) * 100 / max_load, p_curve,
    label="Regularized Benchmark",
    ylabel="Profit (USD)",
    xlabel="Bid Volume (% Market Share)",
    legend=:outertopright,
    left_margin=10mm,
    bottom_margin=10mm,
    size=(900, 600),
    color=:black,
    width=2.0
);

scatter!(plt_comp_individual, [sum(best_solution)] * 100 / max_load, [best_profit],
    label="Multinode BayesOpt - OPF Calls:$(storage_profit_function.fcalls)",
)

plt_surrogate = deepcopy(plt_comp_individual);

range_mul_factor_multi_node = min_total_volume:1.0:max_load
bid_range = [best_solution .* [i / sum(best_solution)] for i in range_mul_factor_multi_node]
p_curve_multi_node = profit_curve!(market, bid_range)

plot!(plt_surrogate, collect(range_mul_factor_multi_node) * 100 / max_load, p_curve_multi_node; 
    label="Multi Node Range Evaluation",
    color="orange"
);

scatter!(plt_surrogate, storage_profit_function.visited_volumes[1:storage_profit_function.fcalls] * 100 / max_load, 
    storage_profit_function.visited_objective[1:storage_profit_function.fcalls]; label="Visited",
    color="orange"
)
storage_profit_function.visited_times = storage_profit_function.visited_times ./ opf_time

plot(storage_profit_function.visited_times[1:storage_profit_function.fcalls], 
(maximum_pq_curve .- accumulate(max, storage_profit_function.visited_objective[1:storage_profit_function.fcalls])) ./ maximum_pq_curve,
    xlabel="Time (x OPF)",
    ylabel="Optimality Gap (%)",
    ylim=(0.0,1.0),
    legend=false
)

BayesOpt (0-Order)


# Max Number of Iterations for the solution method (proxy to a time limit at bidding time).
# ps.: Currently, no option for limiting fcalls.
maxiter = 10

storage_profit_function = StorageCallbackProfit(maxiter * 10, time())

# Build Nonconvex optimization model:
model = Nonconvex.Model()
set_objective!(model, storage_profit_function, flags = [:expensive])
addvar!(model, [min_total_volume], [max_total_volume])
add_ineq_constraint!(model, x -> sum(x) - max_total_volume)

# Solution Method: Bayesian Optimization
alg = BayesOptAlg(IpoptAlg())
options = BayesOptOptions(
    sub_options = IpoptOptions(max_iter = 20, print_level = 0),
    # ninit=Int(floor(maxiter / 5)),
    maxiter = maxiter, ftol = 1e-4, ctol = 1e-5, initialize=true, postoptimize=false,
    kernel= RationalKernel(α=2.27e8) ∘ ScaleTransform(0.01),
    noise=0.001,
    std_multiple=8.67e4,
    fit_prior=false # not working with custom priors
)

# Optimize model:
r_bayes = optimize(model, alg, [max_total_volume / 2]; options = options)

best_solution = r_bayes.minimizer
best_profit = -r_bayes.minimum

scatter!(plt_comp, [best_solution] * 100 / max_load, [best_profit],
    label="BayesOpt - OPF Calls:$(storage_profit_function.fcalls)",
)

plt_surrogate = deepcopy(plt_range);

up_surrugate = -getproperty.(r_bayes.surrogates[1].(range_mul_factor), :lo)
lb_surrugate = -getproperty.(r_bayes.surrogates[1].(range_mul_factor), :hi)
std_surrugate = (up_surrugate .- lb_surrugate) / 2
med_surrugate = lb_surrugate + std_surrugate

storage_profit_function.visited_times = storage_profit_function.visited_times ./ opf_time

plot!(plt_surrogate, range_mul_factor * 100 / max_load, med_surrugate,
    ribbon=std_surrugate,
    title="BayesOpt Analysis",
    label="Surrogate Function",
);
scatter!(plt_surrogate, storage_profit_function.visited_volumes[1:storage_profit_function.fcalls] * 100 / max_load, 
    storage_profit_function.visited_objective[1:storage_profit_function.fcalls]; label="Visited"
)

plot(storage_profit_function.visited_times[1:storage_profit_function.fcalls], 
(maximum_pq_curve .- accumulate(max, storage_profit_function.visited_objective[1:storage_profit_function.fcalls])) ./ maximum_pq_curve,
    xlabel="Time (x OPF)",
    ylabel="Optimality Gap (%)",
    ylim=(0.0,1.0),
    legend=false
)

NLopt - BOBYQA (0-Order)


using NonconvexNLopt

maxeval = 50
storage_profit_function = StorageCallbackProfit(maxeval * 10, time())

# Build Nonconvex optimization model:
model = Nonconvex.Model()
set_objective!(model, storage_profit_function)
addvar!(model, [min_total_volume], [max_total_volume])

# Solution Method: Sequential Least-Squares Quadratic Programming
method = :LN_BOBYQA
alg = NLoptAlg(:LN_BOBYQA)
options = NLoptOptions(maxeval=maxeval)

# Optimize model
r = optimize(model, alg, [min_total_volume]; options = options)

best_solution = r.minimizer
best_profit = -r.minimum

scatter!(plt_comp, [best_solution] * 100 / max_load, [best_profit],
    label="NLOpt-$(method) - OPF Calls:$(storage_profit_function.fcalls)",
);


plt_surrogate = deepcopy(plt_range);

storage_profit_function.visited_times = storage_profit_function.visited_times ./ opf_time

scatter!(plt_surrogate, storage_profit_function.visited_volumes[1:storage_profit_function.fcalls] * 100 / max_load, 
    storage_profit_function.visited_objective[1:storage_profit_function.fcalls]; label="Visited",
    title="BOBYQA"
)

plot(storage_profit_function.visited_times[1:storage_profit_function.fcalls], 
(maximum_pq_curve .- accumulate(max, storage_profit_function.visited_objective[1:storage_profit_function.fcalls])) ./ maximum_pq_curve,
    xlabel="Time (x OPF)",
    ylabel="Optimality Gap (%)",
    ylim=(0.0,1.0),
    legend=false,
    title="BOBYQA"
)

NonconvexMultistart - GPSampler (0-Order)


using NonconvexMultistart

maxiter = 10
storage_profit_function = StorageCallbackProfit(maxiter * 10, time())

# Build Nonconvex optimization model:
model = Nonconvex.Model()
set_objective!(model, storage_profit_function)
addvar!(model, [min_total_volume], [max_total_volume])

# Solution Method: Hyperopt
method = :Hyperopt
alg = HyperoptAlg(IpoptAlg())
options = HyperoptOptions(
    sub_options = IpoptOptions(max_iter = maxiter), sampler = GPSampler(),
    iters = 2,
    keep_all=true
)

# Optimize model
r_hyp = optimize(model, alg, [max_total_volume / 2], options = options)

best_solution = r_hyp.minimizer
best_profit = -r_hyp.minimum

scatter!(plt_comp, best_solution * 100 / max_load, [best_profit],
    label="$(method) Offer - OPF Calls:$(storage_profit_function.fcalls)",
);

plt_visited = deepcopy(plt_range)

storage_profit_function.visited_times = storage_profit_function.visited_times ./ opf_time

scatter!(plt_visited, storage_profit_function.visited_volumes[1:storage_profit_function.fcalls] * 100 / max_load, 
    storage_profit_function.visited_objective[1:storage_profit_function.fcalls]; label="Visited"
)

plot(storage_profit_function.visited_times[1:storage_profit_function.fcalls], 
(maximum_pq_curve .- accumulate(max, storage_profit_function.visited_objective[1:storage_profit_function.fcalls])) ./ maximum_pq_curve,
    xlabel="Time (x OPF)",
    ylabel="Optimality Gap (%)",
    ylim=(0.0,1.0),
    legend=false
)

Profit Comparison NLP 0-Order Strategies


plot(plt_comp, margin=5Plots.mm,
    title="Profit Comparison NLP Strategies",
)

NLopt - CCSAQ (1-Order)


using NonconvexNLopt

maxeval = 50
storage_profit_function = StorageCallbackProfit(maxeval * 10, time())

# Build Nonconvex optimization model:
model = Nonconvex.Model()
set_objective!(model, storage_profit_function)
addvar!(model, [min_total_volume], [max_total_volume])

# Solution Method: Sequential Least-Squares Quadratic Programming
method = :LD_CCSAQ
alg = NLoptAlg(method)
options = NLoptOptions(maxeval=maxeval)

# Optimize model
r = optimize(model, alg, [min_total_volume + 5], options = options)

best_solution = r.minimizer
best_profit = -r.minimum

scatter!(plt_comp, [best_solution] * 100 / max_load, [best_profit],
    label="NLOpt-$(method) - OPF Calls:$(storage_profit_function.fcalls)",
)

plt_visited = deepcopy(plt_range)

storage_profit_function.visited_times = storage_profit_function.visited_times ./ opf_time

scatter!(plt_visited, storage_profit_function.visited_volumes[1:storage_profit_function.fcalls] * 100 / max_load, 
    storage_profit_function.visited_objective[1:storage_profit_function.fcalls]; label="Visited",
    title="LD_CCSAQ"
)

plot(storage_profit_function.visited_times[1:storage_profit_function.fcalls], 
(maximum_pq_curve .- accumulate(max, storage_profit_function.visited_objective[1:storage_profit_function.fcalls])) ./ maximum_pq_curve,
    xlabel="Time (x OPF)",
    ylabel="Optimality Gap (%)",
    ylim=(0.0,1.0),
    legend=false,
    title="LD_CCSAQ"
)