examples.egg
The Egg model, flattened to 2D, against its published 3D solution. In metric units.
The Egg model (Jansen et al., 2014) is a synthetic
benchmark reservoir, much used in history-matching and optimization studies: an
egg-shaped region of 60 × 60 × 7 cells (8 × 8 × 4 m, 18553 of them active) of
channelized permeability (50-7000 mD; an ensemble of 101 realizations, of which
this is the first) and porosity 0.2, flooded for 3600 days by 8 water injectors at
79.5 m³/day into 4 producers held at 395 bar, from an initial 400 bar. Oil 5 cP,
water 1 cP, both at 1e-5/bar; Corey relative permeabilities with exponents 3 and 4.
The data (egg.npz, ref the "Data" section below) are JutulDarcy's copy of the
ECLIPSE deck, which comes with the deck's solution by ECLIPSE 100 -- in 3D, with
gravity -- as its reference.
The 2D reduction
This simulator is areal, so the 7 layers are vertically averaged: for flow along
the layers, the exact upscaling of a stack is the arithmetic mean of their
permeabilities (each carries flux in proportion to its own), the inactive ones
counting as 0 over the 28 m column, and the porosity likewise; the egg's outline
becomes the ResSim.active mask. What is lost is
gravity (a mere 0.27 bar of hydrostatic contrast over the column, against a 5 bar
drawdown) and the layers' individual channel patterns, through the fastest of
which the water arrives first in 3D. The wells, being vertical through all 7
layers, are just one completion each here; the rate, per unit thickness (ref
ResSim.cdarcy), is the 79.5 m³/day over the 28 m.
The deck's own quirks are kept: the water starts at $S_w = 0.1$, below the 0.2 at
which it becomes mobile (so the first tenth of a pore volume injected only fills up
the immobile saturation), and the injectors' 420 bar limit, which never binds. The
relative permeabilities are the deck's Corey curves (minires.fluids.Fluid): exponents 3
and 4 with end-points 0.6 and 0.8 at the residuals $ S_{wc} = 0.2 $, $ S_{or} = 0.15 $
(the table runs on to $ S_w = 0.9 $, but nothing but water moves there). Their
fractional flow is steeper than the quadratic default's (its maximal slope 5.65,
against 3.5), which the CFL estimate follows (ResSim.estimate_1CFL).
Validation
The 2D model reproduces the 3D reference to within an RMS of 0.01 in the water cut of every producer, and to within 4-6% on average in their oil rates; the recovery after 3600 days is 0.57 of the oil in place, against 0.59. Breakthrough comes 15-30 days later than in 3D at three of the four producers -- the vertical averaging smoothing the fastest layer's channel away, as said -- and PROD2, the strongest well, produces 7% less oil.
That a 2D areal model gets this close is because the reservoir is thin (28 m against 480 m across), the wells penetrate all of it, and the channels are largely stacked, so that the flow is nearly layer-parallel. It is not generic: had the layers had separate channel systems, the average would have connected wells that none of the layers connects.
The same flattened model run in JutulDarcy (a fully implicit code, reading the reduction as an ECLIPSE deck) agrees with this one to within 0.007 RMS in the water cut, and lies as far from the 3D original as this one does (0.008 against 0.009): the residual is the flattening, not the simulator.
In the figures:
- "fields" (left): the vertically averaged permeability, its channels
(the yellow bands) running roughly N-S; injectors are black triangles, producers
white. Outside the egg the cells are inactive (
ResSim.active), hence blank. - "fields" (right three): the oil being displaced -- fastest along the channels, and first towards PROD2 (the producer sitting on the widest one, near the centre), which breaks through after 6 months.
- "production" (left): the water cut, this model (solid) against the 3D reference (dashed), per producer. The curves lie on top of each other at the scale of the plot; the 2D breakthroughs come slightly later.
- "production" (right): the oil rate, on a log axis, likewise; the 3D reference's early points are its finer initial report steps. PROD2's initial rate is set by its well index -- the same Peaceman formula in both codes -- and its later decline by how fast the water arrives.
Data
egg.npz (73 KB) holds, x-first as the simulator orders its fields: perm, the
deck's PERMX in mD, (60, 60, 7) = (Nx, Ny, Nz), float32 (PERMY = PERMX and
PERMZ = 0.1 PERMX in the deck, irrelevant in 2D); active, its ACTNUM, bool;
and the reference solution's 135 report times (ref_time, in days) with its oil and
water production rates (ref_orat, ref_wrat, (135, 4), m³/day, PROD1..4).
Everything else the model needs -- cell size, porosity, fluids, wells, controls -- is
a handful of numbers, kept in this script.
The source is the deck as JutulDarcy ships it (GeoEnergyIO's EGG test input:
EGG.DATA with MDARCY.INC, ACTIVE.INC, and REFERENCE.CSV, the ECLIPSE 100
solution in SI, production signed negative), i.e. realization 1 of the 101 published
by TU Delft (doi:10.4121/uuid:916c86cd-3558-4672-829a-105c62985ab2, 4TU General
Terms of Use). With d that directory, the file was made by
import re, numpy as np
def keyword(path, kw): # a GRID keyword's numbers, as (Nx, Ny, Nz)
body = re.sub(r"--.*", "", open(path).read().split(kw, 1)[1].split("/", 1)[0])
vals = [float(v) for tok in body.split() for n, _, v in [tok.rpartition("*")]
for _ in range(int(n) if n else 1)] # ECLIPSE's `n*value`
return np.array(vals).reshape(7, 60, 60).transpose(2, 1, 0) # I fastest
perm = keyword(f"{d}/MDARCY.INC", "PERMX")
active = keyword(f"{d}/ACTIVE.INC", "ACTNUM").astype(bool)
ref = np.genfromtxt(f"{d}/REFERENCE.CSV", delimiter=",", names=True)
prods = [f"PROD{i}" for i in range(1, 5)]
orat = -np.column_stack([ref[w + "orat"] for w in prods]) * 86400
wrat = -np.column_stack([ref[w + "wrat"] for w in prods]) * 86400
wrat[np.abs(wrat) < 1e-9] = 0 # the reference's round-off
np.savez_compressed("egg.npz", perm=perm.astype(np.float32), active=active,
ref_time=ref["time"] / 86400, ref_orat=orat, ref_wrat=wrat)
1"""The Egg model, flattened to 2D, against its published 3D solution. **In metric units.** 2 3The [Egg model](https://doi.org/10.1002/gdj3.21) (Jansen et al., 2014) is a synthetic 4benchmark reservoir, much used in history-matching and optimization studies: an 5egg-shaped region of 60 × 60 × 7 cells (8 × 8 × 4 m, 18553 of them active) of 6**channelized** permeability (50-7000 mD; an ensemble of 101 realizations, of which 7this is the first) and porosity 0.2, flooded for 3600 days by 8 water injectors at 879.5 m³/day into 4 producers held at 395 bar, from an initial 400 bar. Oil 5 cP, 9water 1 cP, both at 1e-5/bar; Corey relative permeabilities with exponents 3 and 4. 10The data (`egg.npz`, ref the "Data" section below) are JutulDarcy's copy of the 11ECLIPSE deck, which comes with the deck's solution by ECLIPSE 100 -- in 3D, with 12gravity -- as its reference. 13 14## The 2D reduction 15 16This simulator is areal, so the 7 layers are **vertically averaged**: for flow along 17the layers, the exact upscaling of a stack is the arithmetic mean of their 18permeabilities (each carries flux in proportion to its own), the inactive ones 19counting as 0 over the 28 m column, and the porosity likewise; the egg's outline 20becomes the `ResSim.active` mask. What is lost is 21gravity (a mere 0.27 bar of hydrostatic contrast over the column, against a 5 bar 22drawdown) and the layers' *individual* channel patterns, through the fastest of 23which the water arrives first in 3D. The wells, being vertical through all 7 24layers, are just one completion each here; the rate, per unit thickness (ref 25`ResSim.cdarcy`), is the 79.5 m³/day over the 28 m. 26 27The deck's own quirks are kept: the water starts at $S_w = 0.1$, *below* the 0.2 at 28which it becomes mobile (so the first tenth of a pore volume injected only fills up 29the immobile saturation), and the injectors' 420 bar limit, which never binds. The 30relative permeabilities are the deck's Corey curves (`minires.fluids.Fluid`): exponents 3 31and 4 with end-points 0.6 and 0.8 at the residuals $ S_{wc} = 0.2 $, $ S_{or} = 0.15 $ 32(the table runs on to $ S_w = 0.9 $, but nothing but water moves there). Their 33fractional flow is steeper than the quadratic default's (its maximal slope 5.65, 34against 3.5), which the CFL estimate follows (`ResSim.estimate_1CFL`). 35 36## Validation 37 38The 2D model reproduces the 3D reference to within an RMS of **0.01** in the water 39cut of every producer, and to within 4-6% on average in their oil rates; the 40recovery after 3600 days is 0.57 of the oil in place, against 0.59. Breakthrough 41comes 15-30 days later than in 3D at three of the four producers -- the vertical 42averaging smoothing the fastest layer's channel away, as said -- and PROD2, the 43strongest well, produces 7% less oil. 44 45That a 2D areal model gets this close is because the reservoir is thin (28 m 46against 480 m across), the wells penetrate all of it, and the channels are largely 47stacked, so that the flow is nearly layer-parallel. It is *not* generic: had the 48layers had separate channel systems, the average would have connected wells that 49none of the layers connects. 50 51The same flattened model run in JutulDarcy (a fully implicit code, reading the 52reduction as an ECLIPSE deck) agrees with this one to within 0.007 RMS in the water 53cut, and lies as far from the 3D original as this one does (0.008 against 0.009): 54the residual is the flattening, not the simulator. 55 56In the figures: 57 58- "fields" (left): the vertically averaged permeability, its channels 59 (the yellow bands) running roughly N-S; injectors are black triangles, producers 60 white. Outside the egg the cells are inactive (`ResSim.active`), hence blank. 61- "fields" (right three): the oil being displaced -- fastest along the channels, 62 and first towards PROD2 (the producer sitting on the widest one, near the centre), 63 which breaks through after 6 months. 64- "production" (left): the water cut, this model (solid) against the 3D reference 65 (dashed), per producer. The curves lie on top of each other at the scale of the 66 plot; the 2D breakthroughs come slightly later. 67- "production" (right): the oil rate, on a log axis, likewise; the 3D reference's 68 early points are its finer initial report steps. PROD2's initial rate is set by 69 its well index -- the same Peaceman formula in both codes -- and its later 70 decline by how fast the water arrives. 71 72## Data 73 74`egg.npz` (73 KB) holds, `x`-first as the simulator orders its fields: `perm`, the 75deck's `PERMX` in mD, `(60, 60, 7)` = `(Nx, Ny, Nz)`, `float32` (`PERMY = PERMX` and 76`PERMZ = 0.1 PERMX` in the deck, irrelevant in 2D); `active`, its `ACTNUM`, `bool`; 77and the reference solution's 135 report times (`ref_time`, in days) with its oil and 78water production rates (`ref_orat`, `ref_wrat`, `(135, 4)`, m³/day, `PROD1..4`). 79Everything else the model needs -- cell size, porosity, fluids, wells, controls -- is 80a handful of numbers, kept in this script. 81 82The source is the deck as JutulDarcy ships it (`GeoEnergyIO`'s `EGG` test input: 83`EGG.DATA` with `MDARCY.INC`, `ACTIVE.INC`, and `REFERENCE.CSV`, the ECLIPSE 100 84solution in SI, production signed negative), i.e. realization 1 of the 101 published 85by TU Delft (doi:10.4121/uuid:916c86cd-3558-4672-829a-105c62985ab2, 4TU General 86Terms of Use). With `d` that directory, the file was made by 87 88 import re, numpy as np 89 def keyword(path, kw): # a GRID keyword's numbers, as (Nx, Ny, Nz) 90 body = re.sub(r"--.*", "", open(path).read().split(kw, 1)[1].split("/", 1)[0]) 91 vals = [float(v) for tok in body.split() for n, _, v in [tok.rpartition("*")] 92 for _ in range(int(n) if n else 1)] # ECLIPSE's `n*value` 93 return np.array(vals).reshape(7, 60, 60).transpose(2, 1, 0) # I fastest 94 perm = keyword(f"{d}/MDARCY.INC", "PERMX") 95 active = keyword(f"{d}/ACTIVE.INC", "ACTNUM").astype(bool) 96 ref = np.genfromtxt(f"{d}/REFERENCE.CSV", delimiter=",", names=True) 97 prods = [f"PROD{i}" for i in range(1, 5)] 98 orat = -np.column_stack([ref[w + "orat"] for w in prods]) * 86400 99 wrat = -np.column_stack([ref[w + "wrat"] for w in prods]) * 86400 100 wrat[np.abs(wrat) < 1e-9] = 0 # the reference's round-off 101 np.savez_compressed("egg.npz", perm=perm.astype(np.float32), active=active, 102 ref_time=ref["time"] / 86400, ref_orat=orat, ref_wrat=wrat) 103""" 104 105from pathlib import Path 106 107from mpl_tools.place import freshfig 108import numpy as np 109 110from minires import ResSim 111from minires.plotting import show 112 113## The data: realization 1 of the Egg ensemble (ref the module docstring) 114data = np.load(Path(__file__).with_name("egg.npz")) 115perm, active = data["perm"], data["active"] # (Nx, Ny, Nz) = (60, 60, 7) 116Nx, Ny, Nz = perm.shape 117h, dz = 8.0, 4.0 # cell size [m] 118H = Nz*dz # thickness [m] 119 120## The 2D reduction: vertical averaging 121# For flow along the layers, the exact upscaling of a stack of layers is the 122# arithmetic mean of their permeabilities (each carries flux in proportion to 123# its own), with the inactive layers (`K = 0`) counted in the thickness. The pore 124# volume is likewise the column's. The egg-shaped footprint -- the cells active in 125# any layer -- becomes the model's `active` mask; outside it, `K` and `por` are 126# immaterial (but `K` must be finite and positive, so the mean is filled in). 127K = (perm * active).sum(-1) / Nz 128por = 0.2 * active.mean(-1) 129footprint = active.any(-1) 130K = np.where(footprint, K, K[footprint].mean()) 131 132## Wells: 8 injectors on rate, 4 producers on BHP, from the deck's `WELSPECS` 133# (1-based cell indices) and `SCHEDULE`. The rate is per unit thickness (ref 134# `ResSim.cdarcy`), the whole column being one cell here. 135ij = dict(INJECT1=(5, 57), INJECT2=(30, 53), INJECT3=(2, 35), INJECT4=(27, 29), 136 INJECT5=(50, 35), INJECT6=(8, 9), INJECT7=(32, 2), INJECT8=(57, 6), 137 PROD1=(16, 43), PROD2=(35, 40), PROD3=(23, 16), PROD4=(43, 18)) 138q_inj, p_prod, p_max, rw = 79.5/H, 395, 420, 0.2 # m²/day, bar, bar, m 139wells = {name: dict(xy=[(i - .5)*h, (j - .5)*h], rw=rw, 140 **(dict(rate=q_inj) if name.startswith("INJ") else dict(bhp=p_prod))) 141 for name, (i, j) in ij.items()} 142producers = [i for i, name in enumerate(ij) if name.startswith("PROD")] 143 144## Fluids: 1 / 5 cP, both 1e-5/bar (rock incompressible), and the deck's `SWOF` 145C = 86400 * 9.869233e-16 * 1e5 / 1e-3 # m, day, bar, mD, cP: 0.008527 146Sw0 = 0.1 # initial water saturation 147p0 = 400 # initial pressure [bar] 148 149 150# The deck's `SWOF` table is Corey (ref `minires.fluids.Fluid`): exponents 3 (water) and 4 151# (oil), end-points 0.6 and 0.8, water immobile below Sw = 0.2 and oil below So = 0.15. 152fluid = dict(vw=1, vo=5, swc=0.2, sor=0.15, nw=3, no=4, krw0=0.6, kro0=0.8) 153 154model = ResSim(Lx=Nx*h, Ly=Ny*h, Nx=Nx, Ny=Ny, cdarcy=C, K=K, por=por, active=footprint, 155 ct=1e-5, fluid=fluid, wells=wells) 156 157## Simulate 3600 days in 30-day steps 158dt, nSteps = 30, 120 159tt = dt * np.arange(1, nSteps + 1) 160SS, PP = model.sim(dt, nSteps, np.full(model.Nxy, Sw0), P0=np.full(model.Nxy, p0), pbar=False) 161 162# The injectors' 420 bar limit (which the deck also has) never binds, so rate 163# control is the whole story, as in the reference. 164bhp_inj = model.wells.actual_bhp[:8] 165assert bhp_inj.max() < p_max, "the injectors' BHP limit binds" 166 167## Production, per well: water cut and oil rate 168cells = model.xy2ind(*model.wells.xy[producers].T) 169cut = model.fluid.fractional_flow(SS[1:][:, cells]) # (nSteps, 4) 170rate = -model.wells.actual_rates[producers].T * H # m³/day, total, positive 171oil = rate * (1 - cut) 172 173## The reference: the 3D model (ECLIPSE 100, via JutulDarcy's copy of the deck) 174ref_t, ref_o, ref_w = data["ref_time"], data["ref_orat"], data["ref_wrat"] 175ref_cut = ref_w / (ref_w + ref_o) 176sel = ref_t >= dt # our first report time 177cut_at_ref = np.column_stack([np.interp(ref_t[sel], tt, cut[:, j]) for j in range(4)]) 178oil_at_ref = np.column_stack([np.interp(ref_t[sel], tt, oil[:, j]) for j in range(4)]) 179rms_cut = np.sqrt(np.mean((cut_at_ref - ref_cut[sel])**2, axis=0)) 180rel_oil = np.abs(oil_at_ref - ref_o[sel]).mean(axis=0) / ref_o[sel].mean(axis=0) 181ooip = 0.2 * h*h*dz * active.sum() * (1 - Sw0) # m³ 182recovery = oil.sum()*dt / ooip 183recovery_ref = np.trapezoid(ref_o, ref_t, axis=0).sum() / ooip 184assert (rms_cut < 0.02).all() and (rel_oil < 0.1).all(), "the 2D model has drifted from the 3D reference" 185 186print(f"RMS water-cut misfit per producer: {rms_cut.round(3)}") 187print(f"Mean relative oil-rate misfit per producer: {rel_oil.round(3)}") 188print(f"Recovery (fraction of OOIP): {recovery:.3f}, 3D reference {recovery_ref:.3f}") 189print(f"Injector BHP: {bhp_inj.min():.1f}-{bhp_inj.max():.1f} bar") 190 191## Plot: the permeability, and the water's advance 192fig, axs = freshfig("Egg -- fields", ncols=4, figsize=(15, 4), sharex=True, sharey=True) 193kws: dict = dict(colorbar=False, finalize=False, wells=dict(size=.4, text=False)) 194model.plt_field(axs[0], np.log10(np.where(footprint, K, np.nan)).ravel(), cmap="viridis", 195 levels=np.linspace(2, 3.7, 18), title="$\\log_{10} K$ [mD]", **kws) 196for ax, k in zip(axs[1:], [20, 60, nSteps]): 197 model.plt_field(ax, SS[k], "oil", title=f"Oil saturation, t = {k*dt} days", labels=False, **kws) 198fig.tight_layout() 199 200## Plot: production, against the 3D reference 201fig, (ax1, ax2) = freshfig("Egg -- production", ncols=2, figsize=(12, 4)) 202names = [list(ij)[i] for i in producers] 203for j, name in enumerate(names): 204 ax1.plot(tt, cut[:, j], c=f"C{j}", label=name) 205 ax1.plot(ref_t, ref_cut[:, j], "--", c=f"C{j}", lw=1) 206 ax2.plot(tt, oil[:, j], c=f"C{j}", label=name) 207 ax2.plot(ref_t, ref_o[:, j], "--", c=f"C{j}", lw=1) 208ax1.plot([], [], "k-", label="2D (this)") 209ax1.plot([], [], "k--", lw=1, label="3D (ECLIPSE 100)") 210ax1.set(title="Water cut", xlabel="Time [day]", ylabel="$f_w$") 211ax1.legend(fontsize="small", ncols=2) 212ax2.set(title="Oil rate", xlabel="Time [day]", ylabel="[m³/day]", yscale="log") 213fig.tight_layout() 214 215# Regression values, checked by `tests/test_examples.py`. 216__digest__ = dict(water_cut = cut, 217 oil_rate = oil, 218 S_final = SS[-1], 219 bhp_inj = bhp_inj[:, -1], 220 rms_cut = rms_cut, 221 rel_oil = rel_oil, 222 recovery = [recovery, recovery_ref]) 223 224if __name__ == "__main__": 225 show()