Skip to content

Gradient-based optimization of net present value (NPV)

One usage of a differentiable simulator is control optimization. A counterpart to history matching, control optimization is the process of finding the optimal controls for a given objective and simulation model. In this example, we will optimize the injection rates for a simplified version of the EGG model to maximize the net present value (NPV) of the reservoir.

Setting up a coarse model

We start off by loading the Egg model and coarsening it to a 20x20x3 grid. We limit the optimization to the first 50 timesteps to speed up the optimization process. If you want to run the optimization for all timesteps on the fine model directly, you can remove the slicing of the case and replace the coarse case with the fine case.

julia
using Jutul, JutulDarcy, GLMakie, GeoEnergyIO, HYPRE
data_dir = GeoEnergyIO.test_input_file_path("EGG")
data_pth = joinpath(data_dir, "EGG.DATA")
fine_case = setup_case_from_data_file(data_pth)
fine_case = fine_case[1:50];
coarse_case = coarsen_reservoir_case(fine_case, (20, 20, 3), method = :ijk);

Set up the rate optimization

We use an utility to set up the rate optimization problem. The utility sets up the objective function, constraints, and initial guess for the optimization. The optimization is set up to maximize the NPV of the reservoir. The contribution to the NPV for a given time ti given in years is defined as: NPVi=ΔtiCi(qo,qw)(1+r)ti

Here, r is the discount rate and Ci(qo,qw) is the cash flow for the current rates. The cash flow is defined as the price of producing each phase (oil and water, with oil having a positive price and water a negative price) minus the cost of injecting water.

We set the prices and costs (per barrel) as well as a discount rate of 5% per year. The base rate will be used for all injectors initially, which matches the base case for the Egg model. The utility function takes in a steps argument that can be used to set how often the rates are allowed to change.

The values used here are arbitrary for the purposes of the example. You are encouraged to play around with the values to see how the outcome changes in the optimization. Generally higher discount rates prioritze immediate income, while lower or zero discount rates prioritize maximizing the oil production over the entire simulation period.

julia
ctrl = coarse_case.forces[1][:Facility].control
base_rate = ctrl[:INJECT1].target.value
function optimize_rates(steps)
    setup = JutulDarcy.setup_rate_optimization_objective(coarse_case, base_rate,
        max_rate_factor = 10,
        oil_price = 100.0,
        water_price = -10.0,
        water_cost = 5.0,
        discount_rate = 0.05,
        steps = steps
    )
    obj_best, x_best, hist = Jutul.unit_box_bfgs(setup.x0,
    setup.obj,
        maximize = true,
        lin_eq = setup.lin_eq
    )
    return (setup.case, hist.val, x_best)
end
optimize_rates (generic function with 1 method)

Optimize the rates

We optimize the rates for two different strategies. The first strategy is to optimize constant rates for the entire period. The second strategy is to optimize the rates for each report step.

Optimize with a single set of rates

julia
case1, hist1, x1 = optimize_rates(:first);
Rate optimization: steps=:first selected, optimizing one set of controls for all steps.
Rate optimization: 8 injectors and 4 producers selected.
It:   0 | val: 2.131e+08 | ls-its: NaN | pgrad: 8.044e+12
It:   1 | val: 2.136e+08 | ls-its: 1 | pgrad: 2.438e+11
It:   2 | val: 2.144e+08 | ls-its: 2 | pgrad: 2.221e+11
LBFGS: Line search unable to succeed in 5 iterations ...
It:   3 | val: 2.155e+08 | ls-its: 5 | pgrad: 2.268e+11
It:   4 | val: 2.162e+08 | ls-its: 1 | pgrad: 1.855e+11
It:   5 | val: 2.164e+08 | ls-its: 1 | pgrad: 5.402e+10
LBFGS: Line search unable to succeed in 5 iterations ...
LBFGS: Hessian not updated during iteration 6
It:   6 | val: 2.171e+08 | ls-its: 5 | pgrad: 5.614e+10
LBFGS: Line search unable to succeed in 5 iterations ...
It:   7 | val: 2.171e+08 | ls-its: 5 | pgrad: 7.844e+10
LBFGS: Line search unable to succeed in 5 iterations ...
LBFGS: Hessian not updated during iteration 8
It:   8 | val: 2.171e+08 | ls-its: 5 | pgrad: 7.436e+10

Plot rate allocation for the constant case

julia
fig = Figure()
ax = Axis(fig[1, 1], xlabel = "Injector number", ylabel = "Rate fraction (of max injection rate)")
barplot!(ax, x1)
ax.xticks = eachindex(x1)
fig

Optimize with varying rates per time-step

julia
case2, hist2, x2 = optimize_rates(:each);
Rate optimization: steps=:each selected, optimizing controls for all 50 steps separately.
Rate optimization: 8 injectors and 4 producers selected.
It:   0 | val: 2.131e+08 | ls-its: NaN | pgrad: 1.329e+12
LBFGS: Line search unable to succeed in 5 iterations ...
It:   1 | val: 2.133e+08 | ls-its: 5 | pgrad: 1.962e+10
LBFGS: Line search unable to succeed in 5 iterations ...
It:   2 | val: 2.152e+08 | ls-its: 5 | pgrad: 1.968e+10
LBFGS: Line search at max step size, Wolfe conditions not satisfied for this step
It:   3 | val: 2.165e+08 | ls-its: 1 | pgrad: 3.037e+10
LBFGS: Line search at max step size, Wolfe conditions not satisfied for this step
It:   4 | val: 2.179e+08 | ls-its: 1 | pgrad: 6.590e+10
LBFGS: Line search at max step size, Wolfe conditions not satisfied for this step
It:   5 | val: 2.185e+08 | ls-its: 1 | pgrad: 6.455e+10
LBFGS: Line search at max step size, Wolfe conditions not satisfied for this step
LBFGS: Hessian not updated during iteration 6
It:   6 | val: 2.189e+08 | ls-its: 1 | pgrad: 3.060e+10
LBFGS: Line search at max step size, Wolfe conditions not satisfied for this step
LBFGS: Hessian not updated during iteration 7
It:   7 | val: 2.193e+08 | ls-its: 2 | pgrad: 3.168e+10
LBFGS: Line search at max step size, Wolfe conditions not satisfied for this step
It:   8 | val: 2.195e+08 | ls-its: 1 | pgrad: 3.325e+10
LBFGS: Line search at max step size, Wolfe conditions not satisfied for this step
It:   9 | val: 2.198e+08 | ls-its: 1 | pgrad: 1.751e+10
LBFGS: Line search at max step size, Wolfe conditions not satisfied for this step
It:  10 | val: 2.200e+08 | ls-its: 1 | pgrad: 6.951e+10
LBFGS: Line search unable to succeed in 5 iterations ...
It:  11 | val: 2.200e+08 | ls-its: 5 | pgrad: 6.611e+10
LBFGS: Line search unable to succeed in 5 iterations ...
LBFGS: Hessian not updated during iteration 12
It:  12 | val: 2.200e+08 | ls-its: 5 | pgrad: 6.609e+10

Plot rate allocation per well

julia
allocs = reshape(x2, length(x1), :)
fig = Figure()
ax = Axis(fig[1, 1], xlabel = "Report step", ylabel = "Rate fraction (of max injection rate)")
for i in axes(allocs, 1)
    lines!(ax, allocs[i, :], label = "Injector #$i")
end
axislegend()
fig

Plot the evolution of the NPV

We plot the evolution of the NPV for the two strategies to compare the results. Note that the optimization produces a higher NPV for the varying rates. This is expected, as the optimization can adjust the rates to the changes in the mobility field during the progress of the simulation.

julia
fig = Figure()
ax = Axis(fig[1, 1], xlabel = "LBFGS iteration", ylabel = "Net present value (million USD)")
scatter!(ax, 1:length(hist1), hist1./1e6, label = "Constant rates")
scatter!(ax, 1:length(hist2), hist2./1e6, marker = :x, label = "Varying rates")
axislegend(position = :rb)
fig

Simulate the results

We finally simulate all the cases to compare the results.

julia
ws0, states0 = simulate_reservoir(coarse_case, info_level = -1)
ws1, states1 = simulate_reservoir(case1, info_level = -1)
ws2, states2 = simulate_reservoir(case2, info_level = -1)
ReservoirSimResult with 50 entries:

  wells (12 present):
    :PROD4
    :INJECT3
    :PROD2
    :PROD3
    :INJECT5
    :INJECT4
    :INJECT8
    :PROD1
    :INJECT6
    :INJECT1
    :INJECT2
    :INJECT7
    Results per well:
       :lrat => Vector{Float64} of size (50,)
       :wrat => Vector{Float64} of size (50,)
       :Aqueous_mass_rate => Vector{Float64} of size (50,)
       :orat => Vector{Float64} of size (50,)
       :control => Vector{Symbol} of size (50,)
       :bhp => Vector{Float64} of size (50,)
       :Liquid_mass_rate => Vector{Float64} of size (50,)
       :wcut => Vector{Float64} of size (50,)
       :mass_rate => Vector{Float64} of size (50,)
       :rate => Vector{Float64} of size (50,)

  states (Vector with 50 entries, reservoir variables for each state)
    :Saturations => Matrix{Float64} of size (2, 975)
    :Pressure => Vector{Float64} of size (975,)
    :TotalMasses => Matrix{Float64} of size (2, 975)

  time (report time for each state)
     Vector{Float64} of length 50

  result (extended states, reports)
     SimResult with 50 entries

  extra
     Dict{Any, Any} with keys :simulator, :config

  Completed at Mar. 30 2025 16:03 after 805 milliseconds, 184 microseconds, 748 nanoseconds.

Compute measurables and compare

NPV favors early production, as later income/costs have less relative value. We compute the field measurables to be able to compare the oil and water production.

julia
f0 = reservoir_measurables(coarse_case.model, ws0)
f1 = reservoir_measurables(case1.model, ws1)
f2 = reservoir_measurables(case2.model, ws2)
Dict{Symbol, Any} with 16 entries:
  :fwit => (values = [636.0, 3180.0, 6341.01, 12701.0, 19061.0, 25421.0, 31781.…
  :fwpt => (values = [0.150372, 0.150372, 0.150372, 0.150372, 0.150372, 0.15037…
  :flpr => (values = [0.00721402, 0.00735963, 0.00731461, 0.00736045, 0.0073611…
  :time => [86400.0, 432000.0, 864000.0, 1.728e6, 2.592e6, 3.456e6, 4.32e6, 5.1…
  :fwct => (values = [0.000241254, 2.7453e-12, 4.75662e-18, 2.33518e-21, 1.9949…
  :fopr => (values = [0.00721228, 0.00735963, 0.00731461, 0.00736045, 0.0073611…
  :fwir => (values = [0.00736111, 0.00736111, 0.00731716, 0.00736111, 0.0073611…
  :foir => (values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0,…
  :fgpr => (values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0,…
  :fgir => (values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0,…
  :fwpr => (values = [1.74041e-6, 2.02044e-14, 3.47928e-20, 1.7188e-23, 1.46854…
  :fgit => (values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0,…
  :fgor => (values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0,…
  :fopt => (values = [623.141, 3166.63, 6326.54, 12686.0, 19046.0, 25405.3, 317…
  :foit => (values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0,…
  :fgpt => (values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0,…

Plot the cumulative field oil production

The optimized cases produce more oil than the base case.

julia
bbl = si_unit(:stb)
fig = Figure()
ax = Axis(fig[1, 1], xlabel = "Time / days", ylabel = "Field oil production (accumulated, barrels)", title = "Base case")
t = ws0.time./si_unit(:day)
lines!(ax, t, f0[:fopt].values./bbl, label = "Base case")
lines!(ax, t, f1[:fopt].values./bbl, label = "Constant rates")
lines!(ax, t, f2[:fopt].values./bbl, label = "Varying rates")
axislegend(position = :rb)
fig

Plot the cumulative field water production

The optimized cases produce less water than the base case.

julia
fig = Figure()
ax = Axis(fig[1, 1], xlabel = "Time / days", ylabel = "Field water production (accumulated, barrels)", title = "Base case")
t = ws0.time./si_unit(:day)
lines!(ax, t, f0[:fwpt].values./bbl, label = "Base case")
lines!(ax, t, f1[:fwpt].values./bbl, label = "Constant rates")
lines!(ax, t, f2[:fwpt].values./bbl, label = "Varying rates")
axislegend(position = :rb)
fig

Plot the differences in water saturation

The optimized cases have a different water saturation distribution compared to the base case.

julia
reservoir = reservoir_domain(coarse_case.model)
g = physical_representation(reservoir)
function plot_diff!(ax, s)
    plt = plot_cell_data!(ax, g, s0 - s1, colorrange = (-0.5, 0.5), colormap = :balance)
    for (w, wd) in get_model_wells(coarse_case.model)
        plot_well!(ax, g, wd, top_factor = 0.8, fontsize = 10)
    end
    ax.elevation[] = 1.03
    ax.azimuth[] = 3.75
    return plt
end

s0 = states0[end][:Saturations][1, :]
s1 = states1[end][:Saturations][1, :]
s2 = states2[end][:Saturations][1, :]
fig = Figure(size = (1600, 800))
ax = Axis3(fig[1, 1], zreversed = true, title = "Water saturation difference, constant rates")
plt = plot_diff!(ax, s1)
ax = Axis3(fig[1, 2], zreversed = true, title = "Water saturation difference, varying rates")
plot_diff!(ax, s2)
Colorbar(fig[2, :], plt, vertical = false)
fig

Example on GitHub

If you would like to run this example yourself, it can be downloaded from the JutulDarcy.jl GitHub repository as a script, or as a Jupyter Notebook

This example took 110.794130326 seconds to complete.

This page was generated using Literate.jl.