Skip to content

Introduction to history matching with gradients

Immiscible   HistoryMatching   Introduction   StartToFinish   Differentiability  

We demonstrate the history matching functionality on a very simple 2D model. This example is intended to show the basic functionality in a self-contained script. History matching is a parameter estimation problem where we want to find the model parameters that minimize the mismatch against some observed data.

Model setup

We setup a 2D reservoir with a single injector and a single producer. This part of the example is conceptually similar to the Your first JutulDarcy.jl simulation and we do not go into detail on this part of the setup.

julia
using JutulDarcy, Jutul
meter = si_unit(:meter)
kg = si_unit(:kg)
day = si_unit(:day)
bar = si_unit(:bar)
millidarcy = si_unit(:millidarcy)
9.86923266716013e-16

Mesh and system

julia
nx = ny = 10
Lx = Ly = 100.0*meter
Lz = 10.0*meter
g = reservoir_mesh(nx = nx, ny = ny, nz = 1, Lx = Lx, Ly = Ly, Lz = Lz)
phases = (AqueousPhase(), LiquidPhase())
rhoAS = 1000.0kg/meter^3
rhoLS = 700.0kg/meter^3
reference_densities = [rhoAS, rhoLS]
sys = ImmiscibleSystem(phases, reference_densities = reference_densities)
ImmiscibleSystem with AqueousPhase, LiquidPhase

Well constraints and report steps

julia
nstep = 25
dt = fill(365.0day, nstep)
inj_rate = 0.5*(Lx*Ly*Lz)/sum(dt)
I_ctrl = setup_injector_control(inj_rate, :rate, [1.0, 0.0], density = rhoAS)
P_ctrl = setup_producer_control(100bar, :bhp)
ProducerControl{BottomHolePressureTarget{Float64}, Float64}(BottomHolePressureTarget with value 100.0 [bar], 1.0)

Build a parametrized simulation case

If we want to do history matching or optimize a model, we need to be able to systematically change the model based on our optimzation variables. In JutulDarcy, this is done by defining a function that takes a dictionary of parameters and returns a JutulCase object. This is a very powerful concept, as it affords the user complete freedom in how to define the model and how to map the optimization parameters to the model.

Define the truth case

We want to have a model where we can generate synthetic data to use for history matching. We define a dictionary of parameters that we will use to build the "truth" case. In this case, we will use the permeability, porosity and initial water saturation as our optimization parameters. These entries can also be vectors or matrices of numbers, but we will just use one number per parameter here.

julia
prm_truth = Dict(
    "perm" => 100.0,
    "poro" => 0.2,
    "sw0" => 0.2
)
Dict{String, Float64} with 3 entries:
  "poro" => 0.2
  "perm" => 100.0
  "sw0"  => 0.2

Define the function that builds the case

Now that we have decided on a dict format, we can make a function that takes a parameter dictionary and returns a JutulCase object (i.e. a complete model setup). We can reuse existing objects to avoid expensive resetup on each call during the optimization if the objects are not dependent on the optimization parameters.

Note the input arguments: The function takes a parameter dictionary and an optional dict that gives additional information about the current timestep being looked at by the optimizer, which is rarely used for history matching.

julia
function build_case(prm::AbstractDict, step_info = missing)
    reservoir = reservoir_domain(g,
        permeability = prm["perm"]*millidarcy,
        porosity = prm["poro"]
    )
    I = setup_vertical_well(reservoir, 1, 1, name = :Injector)
    P = setup_well(reservoir, (nx, ny, 1), name = :Producer)
    model = setup_reservoir_model(reservoir, sys, wells = [I, P])

    sw0 = prm["sw0"]
    state0 = setup_reservoir_state(model,
        Pressure = 180*bar,
        Saturations = [sw0, 1-sw0]
    )
    controls = Dict(:Injector => I_ctrl, :Producer => P_ctrl)
    forces = setup_reservoir_forces(model, control = controls)
    return JutulCase(model, dt, forces, state0 = state0)
end
build_case (generic function with 2 methods)

Verify that the case can be built and simulated

We set up an initial guess and verify that the function works by building the truth case and simulating it. This will also provide us with the data that we will use for history matching.

julia
case_truth = build_case(prm_truth)
result_truth = simulate_reservoir(case_truth)
ReservoirSimResult with 25 entries:

  wells (2 present):
    :Producer
    :Injector
    Results per well:
       :wrat => Vector{Float64} of size (25,)
       :Aqueous_mass_rate => Vector{Float64} of size (25,)
       :orat => Vector{Float64} of size (25,)
       :bhp => Vector{Float64} of size (25,)
       :mrat => Vector{Float64} of size (25,)
       :lrat => Vector{Float64} of size (25,)
       :mass_rate => Vector{Float64} of size (25,)
       :rate => Vector{Float64} of size (25,)
       :control => Vector{Symbol} of size (25,)
       :Liquid_mass_rate => Vector{Float64} of size (25,)
       :wcut => Vector{Float64} of size (25,)

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

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

  result (extended states, reports)
     SimResult with 25 entries

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

  Completed at Aug. 22 2026 05:28 after 3 seconds, 918 milliseconds, 332.8 microseconds.

History match the model

To be able to define an optimization problem we need four things:

  • A parametrized function that builds the model.

  • A set of parameters that we want to optimize with sensible absolute or relative bounds.

  • An objective function that takes the model and the simulation result and returns a scalar value that we want to minimize.

  • A starting point for the optimizer that is within the bounds.

julia
### Define the initial guess

We define an initial guess for the optimization parameters. This is a dict with the same format as the truth case, but with different values.

julia
prm_guess = Dict(
    "perm" => 50.0,
    "poro" => 0.15,
    "sw0" => 0.25
)
Dict{String, Float64} with 3 entries:
  "poro" => 0.15
  "perm" => 50.0
  "sw0"  => 0.25

Set up the optimization problem

We can now set up the optimization problem. This will create an object that contains all the information about the optimization problem. We can then free parameters to be optimized and set bounds on them. We can also lump parameters and set scaling factors for each of the variables here.

julia
opt = setup_reservoir_dict_optimization(prm_guess, build_case)
free_optimization_parameter!(opt, "perm", abs_min = 1.0, abs_max = 1000.0)
free_optimization_parameter!(opt, "poro", abs_min = 0.05, abs_max = 0.5)
free_optimization_parameter!(opt, "sw0", abs_min = 0.0, abs_max = 1.0)
DictParameters with 3 parameters (3 active), and 0 multipliers:
Active optimization parameters
┌──────┬───────────────┬───────┬──────┬────────┐
 Name  Initial value  Count   Min     Max 
├──────┼───────────────┼───────┼──────┼────────┤
│ perm │ 50.0          │     1 │  1.0 │ 1000.0 │
│ poro │ 0.15          │     1 │ 0.05 │    0.5 │
│  sw0 │ 0.25          │     1 │  0.0 │    1.0 │
└──────┴───────────────┴───────┴──────┴────────┘
No inactive optimization parameters.
No multipliers set.

Load the history matching functionality

We can now load the history matching functionality and define an objective function that will be used to evaluate the mismatch between the model and the data. We could also have read (read_summary) or manually constructed a summary file to load data.

julia
import JutulDarcy.HistoryMatching: history_match_objective, match_injectors!, match_producers!
obj = history_match_objective(case_truth, result_truth)
SumHistoryMatchObjective
HistoryMatch objective covering case with 24 years, 51 weeks, 2.18 days total simulation time
┌──────┬──────┬──────────┬────────┬───────┐
 Well  Type  Quantity  Weight  Scale 
└──────┴──────┴──────────┴────────┴───────┘

Define what values to match

We can now define what values we want to match. In this case, we will match the bottom hole pressure of the injector and the water cut of the producer. We can also set weights for the different objectives, which will be used to scale the contributions to the objective function. The weights can be one value per reporting timestep (case.dt) or a single value that will be used for all timesteps. Weights balance the importance of different wells and measurables. The default scaling of each type of well response is intended to scale the values to be around unit range for typical values, but this obviously depends on the specific model.

julia
match_injectors!(obj, "WBHP", weight = 1.0)
match_producers!(obj, "WWCT", weight = 3.0)
HistoryMatch objective covering case with 24 years, 51 weeks, 2.18 days total simulation time
┌──────────┬──────────┬──────────┬────────┬─────────────┐
     Well      Type  Quantity  Weight        Scale 
├──────────┼──────────┼──────────┼────────┼─────────────┤
│ Injector │ Injector │     WBHP │    1.0 │ 1.15741e-11 │
│ Producer │ Producer │     WWCT │    3.0 │  1.15741e-5 │
└──────────┴──────────┴──────────┴────────┴─────────────┘

Run the optimizer

We know how to set up a model, we know the bounds and we have defined an objective. We call the optimizer. For such a simple case, it will recover the parameters exactly in a few iterations. In general, the history match problem is ill-posed and the optimizer may not converge to a unique solution.

julia
prm_opt = optimize_reservoir(opt, obj)
display(opt)
Optimization: Starting calibration of 3 parameters.
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Setting up adjoint storage.
Optimization: Finished setup in 36.609428143 seconds.
Optimization: Adjoint solve took 23.090544652 seconds.

Optimization: Objective #1: 1.68042e+07, gradient 2-norm: 4.13115e+08
It.  | Objective  | Proj. grad | Linesearch-its
-----------------------------------------------
   0 | 4.0677e-02 | 1.1411e+00 | -
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.055436048 seconds.

Optimization: Objective #2: 7.56168e+06 (f/f0=4.500e-01), gradient 2-norm: 3.31688e+08
LBFGS: Resetting 'm' to number of parameters: m = 3
   1 | 1.8304e-02 | 1.0425e+00 | 1
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.055540311 seconds.

Optimization: Objective #3: 4.33558e+06 (f/f0=2.580e-01), gradient 2-norm: 2.46619e+08
   2 | 1.0495e-02 | 3.5790e-01 | 1
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.05603571 seconds.

Optimization: Objective #4: 1.79981e+05 (f/f0=1.071e-02), gradient 2-norm: 1.84783e+07
   3 | 4.3567e-04 | 2.6564e-01 | 1
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.055100286 seconds.

Optimization: Objective #5: 1.36606e+05 (f/f0=8.129e-03), gradient 2-norm: 4.47656e+06
   4 | 3.3067e-04 | 2.9786e-02 | 1
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.056052681 seconds.

Optimization: Objective #6: 3.40788e+04 (f/f0=2.028e-03), gradient 2-norm: 6.00844e+06
   5 | 8.2492e-05 | 2.8777e-02 | 1
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.055155448 seconds.

Optimization: Objective #7: 1.91502e+04 (f/f0=1.140e-03), gradient 2-norm: 2.69631e+06
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.055230968 seconds.

Optimization: Objective #8: 3.28317e+03 (f/f0=1.954e-04), gradient 2-norm: 3.98865e+06
   6 | 7.9474e-06 | 1.6303e-02 | 2
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.054665099 seconds.

Optimization: Objective #9: 1.98998e+03 (f/f0=1.184e-04), gradient 2-norm: 1.12011e+06
   7 | 4.8170e-06 | 4.3447e-03 | 1
Jutul: Simulating 24 years, 51.31 weeks as 25 report steps
Optimization: Adjoint solve took 0.054375042 seconds.

Optimization: Objective #10: 1.61486e+03 (f/f0=9.610e-05), gradient 2-norm: 5.40652e+05
   8 | 3.9090e-06 | 1.1411e-03 | 1
Optimization: Finished in 70.529013995 seconds.

Plot the results

This section requires plotting to be available.

julia
using GLMakie

Plot the base case

We can now plot the results of the history matching. We first do a plot to verify that the base case has zero objective.

julia
import JutulDarcy: plot_mismatch
result_truth = simulate_reservoir(build_case(prm_truth))
plot_mismatch(obj, result_truth)

Plot the initial guess

julia
result_guess = simulate_reservoir(build_case(prm_guess))
plot_mismatch(obj, result_guess)

Plot the tuned case

We see that contributions to the objective function are now very small, indicating that the history matching has been successful.

julia
result_tuned = simulate_reservoir(build_case(prm_opt))
plot_mismatch(obj, result_tuned)

Plot the well responses

We see that we have successfully matched this model.

julia
plot_summary(
    [result_truth, result_guess, result_tuned],
    names = ["Truth", "Guess", "Tuned"],
    plots = ["Producer:WWCT", "FOPR"]
)

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

This example took 96.702333472 seconds to complete.

This page was generated using Literate.jl.