Skip to content

Geothermal energy production from a well doublet

This example demonstrates how to simulate and visualize the production of geothermal energy from a doublet well system. The setup consists of a producer well that extracts hot water from the reservoir, and an injector well that reinjects produced water at a lower temperature.

Add required modules to namespace

julia
using Jutul, JutulDarcy, Fimbul
using HYPRE
using GLMakie

Set up case

The injector and producer wells are placed 100 m apart at the top, and run parallel down to 800 m depth before they diverge to a distance of 1000 m at 2500 m depth.

julia
case, plot_args = geothermal_doublet();
Info    : Clearing all models and views...
Info    : Done clearing all models and views
Info    : Meshing 1D...
Info    : [  0%] Meshing curve 1 (Line)
Info    : [ 10%] Meshing curve 2 (Line)
Info    : [ 10%] Meshing curve 3 (Line)
Info    : [ 10%] Meshing curve 4 (Line)
Info    : [ 10%] Meshing curve 5 (Line)
Info    : [ 10%] Meshing curve 6 (Line)
Info    : [ 20%] Meshing curve 7 (Line)
Info    : [ 20%] Meshing curve 8 (Line)
Info    : [ 20%] Meshing curve 9 (Line)
Info    : [ 20%] Meshing curve 10 (Line)
Info    : [ 20%] Meshing curve 11 (Line)
Info    : [ 30%] Meshing curve 12 (Line)
Info    : [ 30%] Meshing curve 13 (Line)
Info    : [ 30%] Meshing curve 14 (Line)
Info    : [ 30%] Meshing curve 15 (Line)
Info    : [ 30%] Meshing curve 16 (Line)
Info    : [ 40%] Meshing curve 17 (Line)
Info    : [ 40%] Meshing curve 18 (Line)
Info    : [ 40%] Meshing curve 19 (Line)
Info    : [ 40%] Meshing curve 21 (Extruded)
Info    : [ 40%] Meshing curve 22 (Extruded)
Info    : [ 40%] Meshing curve 23 (Extruded)
Info    : [ 50%] Meshing curve 24 (Extruded)
Info    : [ 50%] Meshing curve 25 (Extruded)
Info    : [ 50%] Meshing curve 26 (Extruded)
Info    : [ 50%] Meshing curve 27 (Extruded)
Info    : [ 50%] Meshing curve 28 (Extruded)
Info    : [ 60%] Meshing curve 29 (Extruded)
Info    : [ 60%] Meshing curve 30 (Extruded)
Info    : [ 60%] Meshing curve 31 (Extruded)
Info    : [ 60%] Meshing curve 32 (Extruded)
Info    : [ 60%] Meshing curve 33 (Extruded)
Info    : [ 70%] Meshing curve 34 (Extruded)
Info    : [ 70%] Meshing curve 35 (Extruded)
Info    : [ 70%] Meshing curve 36 (Extruded)
Info    : [ 70%] Meshing curve 37 (Extruded)
Info    : [ 70%] Meshing curve 39 (Extruded)
Info    : [ 70%] Meshing curve 40 (Extruded)
Info    : [ 80%] Meshing curve 44 (Extruded)
Info    : [ 80%] Meshing curve 48 (Extruded)
Info    : [ 80%] Meshing curve 52 (Extruded)
Info    : [ 80%] Meshing curve 56 (Extruded)
Info    : [ 80%] Meshing curve 60 (Extruded)
Info    : [ 90%] Meshing curve 64 (Extruded)
Info    : [ 90%] Meshing curve 68 (Extruded)
Info    : [ 90%] Meshing curve 72 (Extruded)
Info    : [ 90%] Meshing curve 76 (Extruded)
Info    : [ 90%] Meshing curve 80 (Extruded)
Info    : [100%] Meshing curve 84 (Extruded)
Info    : [100%] Meshing curve 88 (Extruded)
Info    : [100%] Meshing curve 92 (Extruded)
Info    : [100%] Meshing curve 96 (Extruded)
Info    : [100%] Meshing curve 100 (Extruded)
Info    : Done meshing 1D (Wall 0.00529429s, CPU 0.005296s)
Info    : Meshing 2D...
Info    : [  0%] Meshing surface 1 (Plane, Frontal-Delaunay)
Warning : Cannot apply Blossom: odd number of triangles (2575) in surface 1
Info    : [  0%] Blossom recombination completed (Wall 0.0249499s, CPU 0.02495s): 1122 quads, 321 triangles, 0 invalid quads, 0 quads with Q < 0.1, avg Q = 0.764788, min Q = 0.308496
Info    : [ 10%] Meshing surface 41 (Extruded)
Info    : [ 20%] Meshing surface 45 (Extruded)
Info    : [ 20%] Meshing surface 49 (Extruded)
Info    : [ 30%] Meshing surface 53 (Extruded)
Info    : [ 30%] Meshing surface 57 (Extruded)
Info    : [ 40%] Meshing surface 61 (Extruded)
Info    : [ 40%] Meshing surface 65 (Extruded)
Info    : [ 50%] Meshing surface 69 (Extruded)
Info    : [ 50%] Meshing surface 73 (Extruded)
Info    : [ 60%] Meshing surface 77 (Extruded)
Info    : [ 60%] Meshing surface 81 (Extruded)
Info    : [ 70%] Meshing surface 85 (Extruded)
Info    : [ 70%] Meshing surface 89 (Extruded)
Info    : [ 80%] Meshing surface 93 (Extruded)
Info    : [ 80%] Meshing surface 97 (Extruded)
Info    : [ 90%] Meshing surface 101 (Extruded)
Info    : [ 90%] Meshing surface 105 (Extruded)
Info    : [100%] Meshing surface 106 (Extruded)
Info    : Done meshing 2D (Wall 0.0600044s, CPU 0.060003s)
Info    : Meshing 3D...
Info    : Meshing volume 1 (Extruded)
Info    : Done meshing 3D (Wall 0.37294s, CPU 0.372902s)
Info    : Optimizing mesh...
Info    : Done optimizing mesh (Wall 0.00232945s, CPU 0.002328s)
Info    : 74100 nodes 86626 elements
Warning : ------------------------------
Warning : Mesh generation error summary
Warning :     1 warning
Warning :     0 errors
Warning : Check the full log for details
Warning : ------------------------------
Info    : Removing duplicate mesh nodes...
Info    : Found 0 duplicate nodes
Info    : No duplicate nodes found

Inspect model

We first plot the computational mesh and wells. The mesh is refined around the wells in the horizontal plane and vertically in and near the target aquifer.

julia
msh = physical_representation(reservoir_model(case.model).data_domain)
fig = Figure(size = (1200, 800))
ax = Axis3(fig[1, 1], zreversed = true, aspect = plot_args.aspect)
Jutul.plot_mesh_edges!(ax, msh, alpha = 1.0)
wells = get_model_wells(case.model)
for (k, w) in wells
    plot_well!(ax, msh, w)
end
fig

Plot reservoir properties

Next, we visualize the reservoir interactively.

julia
plot_reservoir(case.model; plot_args...)

Simulate geothermal energy production

We simulate the geothermal doublet for 200 years. The producer is set to produce at a rate of 300 m^3/hour with a lower BHP limit of 1 bar, while the injector is set to reinject the produced water, lowered to a temperature of 20 °C.

Note: this simulation can take a few minutes to run. Setting info_level = 0 will show a progress bar while the simulation runs.

julia
results = simulate_reservoir(case; info_level = -1)
ReservoirSimResult with 200 entries:

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

  states (Vector with 200 entries, reservoir variables for each state)
    :Pressure => Vector{Float64} of size (80808,)
    :TotalMasses => Matrix{Float64} of size (1, 80808)
    :TotalThermalEnergy => Vector{Float64} of size (80808,)
    :FluidEnthalpy => Matrix{Float64} of size (1, 80808)
    :Temperature => Vector{Float64} of size (80808,)
    :PhaseMassDensities => Matrix{Float64} of size (1, 80808)
    :RockInternalEnergy => Vector{Float64} of size (80808,)
    :FluidInternalEnergy => Matrix{Float64} of size (1, 80808)
    :PhaseViscosities => Matrix{Float64} of size (1, 80808)

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

  result (extended states, reports)
     SimResult with 200 entries

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

  Completed at Jul. 07 2025 22:07 after 3 minutes, 15 seconds, 811.1 milliseconds.

Visualize results

We first plot the reservoir state interactively. You can notice how the cold front propagates from the injector well by filtering out high values.

julia
plot_reservoir(case.model, results.states;
colormap = :seaborn_icefire_gradient, key = :Temperature, plot_args...)

Plot well output

Next, we plot the well output to examine the production rates and temperatures.

julia
plot_well_results(results.wells)

Well temperature evolution

For a more detailed analysis of the production temperature evolution, we plot the temperature inside the production well as a function of well depth for all 200 years of production. We also highlight the temperature at selected timesteps (7, 21, 65, and 200 years).

julia
states = results.result.states
times = convert_from_si.(cumsum(case.dt), :year)
geo = tpfv_geometry(msh)

fig = Figure(size = (1200, 800))
ax = Axis(fig[1, 1], xlabel = "Temperature (°C)", ylabel = "Depth (m)", yreversed = true)
colors = cgrad(:seaborn_icefire_gradient, length(states), categorical = true)

Plot temperature profiles for all timesteps with transparency

julia
for (n, state) in enumerate(states)
    T = convert_from_si.(state[:Producer][:Temperature], :Celsius)
    plot_mswell_values!(ax, case.model, :Producer, T;
    geo = geo, linewidth = 4, color = colors[n], alpha = 0.25)
end

Highlight selected timesteps with solid lines and labels

julia
timesteps = [7, 21, 65, 200]
for n in timesteps
    T = convert_from_si.(states[n][:Producer][:Temperature], :Celsius)
    plot_mswell_values!(ax, case.model, :Producer, T;
    geo = geo, linewidth = 4, color = colors[n], label = "$(times[n]) years")
end
axislegend(ax; position = :lt, fontsize = 20)
fig

We can clearly see the footprint of the cold front in the aquifer (2400-2500 m depth) as it approaches the production well.

Reservoir state evolution

Another informative plot is the change in reservoir states over time. We compute the change in reservoir states from the initial state and plot the results interactively. The change in temperature is particularly interesting as it shows the evolution of the cold front in the aquifer

julia
Δstates = []
for state in results.states
    Δstate = Dict{Symbol, Any}()
    for (k, v) in state
        v0 = case.state0[:Reservoir][k]
        Δstate[k] = v .- v0
    end
    push!(Δstates, Δstate)
end
plot_reservoir(case.model, Δstates;
colormap = :seaborn_icefire_gradient, key = :Temperature, plot_args...)

3D visualization of temperature changes

Finally, we plot the change in temperature at the same timesteps highlighted in the production well temperature above. We cut away parts of the model for better visualization. Define the temperature range and create a mask to cut away parts of the model

julia
T_min = minimum(Δstates[end][:Temperature])
T_max = maximum(Δstates[1][:Temperature])
cells = geo.cell_centroids[1, :] .> -1000.0/2
cells = cells .&& geo.cell_centroids[2, :] .> 50.0
cells = cells .|| geo.cell_centroids[3, :] .> 2475.0
80808-element BitVector:
 0
 0
 0
 0
 0
 0
 0
 0
 0
 0

 1
 1
 1
 1
 1
 1
 1
 1
 1

Create subplots for each highlighted timestep

julia
fig = Figure(size = (800, 800))
for (i, n) in enumerate(timesteps)
    ax = Axis3(fig[(i-1)÷2+1, (i-1)%2+1]; title = "$(times[n]) years",
    zreversed = true, elevation = pi/8, aspect = :data)

    ΔT = Δstates[n][:Temperature]

    plot_cell_data!(ax, msh, ΔT;
        cells = cells, colormap = :seaborn_icefire_gradient, colorrange = (T_min, T_max))
    plot_well!(ax, msh, wells[:Injector];
    color = :black, linewidth = 4, top_factor = 0.4, markersize = 0.0)
    plot_well!(ax, msh, wells[:Producer];
    color = :black, linewidth = 4, markersize = 0.0)
    hidedecorations!(ax)
end
Colorbar(fig[3, 1:2];
    colormap = :seaborn_icefire_gradient, colorrange = (T_min, T_max),
    label = "ΔT (°C)", vertical = false)
fig

Example on GitHub

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

This example took 295.1396488 seconds to complete.

This page was generated using Literate.jl.