examples.buckley_leverett

The Buckley--Leverett solution -- the one exact answer we can be checked against.

Every other example here is an illustration: we plot what the code does, and argue that it looks right. This one is a verification: in 1D, with constant total velocity, the water equation has an analytic solution (Buckley & Leverett, 1942), so the numerical profile can be compared with the truth, and the error made to shrink under refinement.

The construction. With $ ∇ ⋅ v = 0 $ the transport equation collapses to the scalar conservation law $$ φ \, ∂s/∂t + v \, ∂f(s)/∂x = 0 \,, $$ whose characteristics carry a given $s$ at speed $ v f'(s)/φ $. Since $f$ is S-shaped, $f'$ is not monotone, so those characteristics cross: the profile would become multivalued, and a shock forms instead. Its saturation, $S_f$, is fixed by requiring that the shock speed (from mass balance across it) equal the characteristic speed just behind it -- which is the Welge (1952) tangent construction: the chord from the initial state to $(S_f, f(S_f))$ must be tangent to $f$, $$ \frac{f(S_f)}{S_f - s_\mathrm{wc}} = f'(S_f) \,. $$ Everything else follows: the shock travels at that chord's slope, breakthrough occurs when it reaches the outlet, and thereafter the outlet saturation is read off $ f'(s) = 1/t_D $ (which is Welge's production forecast).

Closed form for this model. The relative permeabilities (minires.fluids.Fluid.RelPerm) being quadratic, the tangent condition can be solved by hand. In terms of the normalized saturation (ref minires.fluids.Fluid.rescale_sat) and the endpoint mobility ratio $ M = v_o/v_w $, $$ S_f^* = \frac{1}{\sqrt{1 + M}} \,, \qquad t_D^\mathrm{bt} = (1 - s_\mathrm{wc} - s_\mathrm{or}) \, \frac{2 \, (1 + M - \sqrt{1+M})}{M \, \sqrt{1+M}} \,, $$ so for the default unit-viscosity fluids $ S_f = 1/\sqrt{2} ≈ 0.7071 $ and breakthrough comes at $ 2(\sqrt{2}-1) ≈ 0.8284 $ pore volumes injected. Below, the tangent is located numerically (from the model's own minires.fluids.Fluid.fractional_flow) and asserted to agree with these -- so the check cuts both ways: it validates the analytic solution we then compare the simulation against.

Notes on the setup:

  • Time is measured in pore volumes injected ($t_D$), and distance in fractions of the length ($x_D$). The rate, pore volume and length are all $1$ here, so $ t_D = t $ and $ x_D = y $ -- no scaling clutters the plots.
  • The 1D domain is a single column of cells (Nx=1), i.e. the flow is along $y$. A row (Ny=1) works just the same (tests/test_transport.py checks that the two agree); the column is merely what the plots below assume.
  • The ends are wells (a source and a sink), not boundary conditions, which costs two $O(h)$ discrepancies with the textbook problem: the inlet cell only approaches $ s = 1 - s_\mathrm{or} $ (it is filled at a finite rate, not held at a value), and the outlet is sampled half a cell short of $ x_D = 1 $. Both are visible below, and both vanish under refinement.

In the figures:

  • "fractional flow": the construction itself. The tangent from the initial state finds $ S_f = 0.71 $ for the unit-viscosity fluids, but only $0.44$ for the case with $ M = 5 $ -- a more adverse mobility ratio gives a weaker shock, arriving sooner (at $ t_D = 0.35 $ rather than $0.83$), which is the whole reason mobility ratio matters to a waterflood. Where the tangent reaches $ f = 1 $ (open squares) is Welge's second reading of it: the average saturation behind the front -- and hence, nothing having been produced yet, the recovery at breakthrough. Asserted against the simulation below.
  • "saturation profile": the numerical and analytic profiles, at a time before breakthrough. The explicit scheme follows the rarefaction to within 0.017 in saturation (0.005 on average), and smears the shock over some 4 cells. The implicit scheme is four times as far off in the rarefaction, and spreads the shock over five times as many cells -- as in examples.quarter_five_spot, it is the more diffusive of the two. Note that neither overshoots.
  • "verification" (left): the water cut at the producer, against Welge's forecast. It breaks through a shade early (the producer being half a cell inside), then follows the analytic curve to within 0.004.
  • "verification" (right): the $L_1$ error of the profile, which shrinks as $ h^{0.87} $ -- first-order convergence, all but the last 13% of it, the shortfall being the usual one for a monotone first-order scheme resolving a discontinuity. (The zig-zag about that fit is also expected: the error depends on where the shock happens to fall within a cell.) The rate is what matters: it certifies that the scheme is consistent, i.e. that the error is discretization, not bug.
Buckley-Leverett -- fractional flow
Buckley-Leverett -- fractional flow
Buckley-Leverett -- saturation profile
Buckley-Leverett -- saturation profile
Buckley-Leverett -- verification
Buckley-Leverett -- verification
  1"""The Buckley--Leverett solution -- the one *exact* answer we can be checked against.
  2
  3Every other example here is an *illustration*: we plot what the code does, and
  4argue that it looks right. This one is a **verification**: in 1D, with constant
  5total velocity, the water equation has an analytic solution
  6(Buckley & Leverett, 1942), so the numerical profile can be compared with the
  7truth, and the error made to shrink under refinement.
  8
  9**The construction.** With $ ∇ ⋅ v = 0 $ the transport equation collapses to the
 10scalar conservation law
 11$$ φ \\, ∂s/∂t + v \\, ∂f(s)/∂x = 0 \\,, $$
 12whose characteristics carry a given $s$ at speed $ v f'(s)/φ $. Since $f$ is
 13S-shaped, $f'$ is *not* monotone, so those characteristics cross: the profile
 14would become multivalued, and a **shock** forms instead. Its saturation,
 15$S_f$, is fixed by requiring that the shock speed (from mass balance across it)
 16equal the characteristic speed just behind it -- which is the **Welge (1952)
 17tangent** construction: the chord from the initial state to $(S_f, f(S_f))$ must
 18be tangent to $f$,
 19$$ \\frac{f(S_f)}{S_f - s_\\mathrm{wc}} = f'(S_f) \\,. $$
 20Everything else follows: the shock travels at that chord's slope, breakthrough
 21occurs when it reaches the outlet, and thereafter the outlet saturation is read
 22off $ f'(s) = 1/t_D $ (which is Welge's production forecast).
 23
 24**Closed form for this model.** The relative permeabilities
 25(`minires.fluids.Fluid.RelPerm`) being quadratic, the tangent condition can
 26be solved by hand. In terms of the normalized saturation (ref
 27`minires.fluids.Fluid.rescale_sat`) and the endpoint mobility ratio $ M = v_o/v_w $,
 28$$ S_f^* = \\frac{1}{\\sqrt{1 + M}} \\,, \\qquad
 29   t_D^\\mathrm{bt} = (1 - s_\\mathrm{wc} - s_\\mathrm{or})
 30       \\, \\frac{2 \\, (1 + M - \\sqrt{1+M})}{M \\, \\sqrt{1+M}} \\,, $$
 31so for the default unit-viscosity fluids $ S_f = 1/\\sqrt{2} ≈ 0.7071 $ and
 32breakthrough comes at $ 2(\\sqrt{2}-1) ≈ 0.8284 $ pore volumes injected. Below,
 33the tangent is located *numerically* (from the model's own
 34`minires.fluids.Fluid.fractional_flow`) and asserted to agree with these -- so the check cuts both ways:
 35it validates the analytic solution we then compare the simulation against.
 36
 37Notes on the setup:
 38
 39- Time is measured in **pore volumes injected** ($t_D$), and distance in
 40  fractions of the length ($x_D$). The rate, pore volume and length are all
 41  $1$ here, so $ t_D = t $ and $ x_D = y $ -- no scaling clutters the plots.
 42- The 1D domain is a single **column** of cells (`Nx=1`), i.e. the flow is
 43  along $y$. A **row** (`Ny=1`) works just the same (`tests/test_transport.py`
 44  checks that the two agree); the column is merely what the plots below assume.
 45- The ends are **wells** (a source and a sink), not boundary conditions, which
 46  costs two $O(h)$ discrepancies with the textbook problem: the inlet cell only
 47  approaches $ s = 1 - s_\\mathrm{or} $ (it is filled at a finite rate, not
 48  held at a value), and the outlet is sampled half a cell short of $ x_D = 1 $.
 49  Both are visible below, and both vanish under refinement.
 50
 51In the figures:
 52
 53- "fractional flow": the construction itself. The tangent from the initial
 54  state finds $ S_f = 0.71 $ for the unit-viscosity fluids, but only $0.44$ for
 55  the case with $ M = 5 $ -- a *more adverse* mobility ratio gives a *weaker*
 56  shock, arriving sooner (at $ t_D = 0.35 $ rather than $0.83$), which is the
 57  whole reason mobility ratio matters to a waterflood. Where the tangent
 58  reaches $ f = 1 $ (open squares) is Welge's second reading of it: the
 59  *average* saturation behind the front -- and hence, nothing having been
 60  produced yet, the recovery at breakthrough. Asserted against the simulation
 61  below.
 62- "saturation profile": the numerical and analytic profiles, at a time before
 63  breakthrough. The explicit scheme follows the rarefaction to within 0.017 in
 64  saturation (0.005 on average), and smears the shock over some 4 cells. The
 65  implicit scheme is four times as far off in the rarefaction, and spreads the
 66  shock over five times as many cells -- as in `examples.quarter_five_spot`, it is
 67  the more diffusive of the two. Note that neither *overshoots*.
 68- "verification" (left): the water cut at the producer, against Welge's
 69  forecast. It breaks through a shade early (the producer being half a cell
 70  inside), then follows the analytic curve to within 0.004.
 71- "verification" (right): the $L_1$ error of the profile, which shrinks as
 72  $ h^{0.87} $ -- first-order convergence, all but the last 13% of it, the
 73  shortfall being the usual one for a monotone first-order scheme resolving a
 74  discontinuity. (The zig-zag about that fit is also expected: the error
 75  depends on where the shock happens to fall within a cell.) The rate is what
 76  matters: it certifies that the scheme is consistent, i.e. that the error is
 77  *discretization*, not *bug*.
 78"""
 79
 80from mpl_tools.place import freshfig
 81import numpy as np
 82from scipy.optimize import minimize_scalar
 83
 84from minires import ResSim
 85from minires.plotting import show
 86
 87## Setup
 88
 89
 90def make_model(N: int, fluid: dict) -> ResSim:
 91    """A 1D column of `N` cells: injector at the bottom, producer at the top.
 92
 93    Unit length, unit pore volume, unit rate -- so that time *is* $t_D$
 94    (pore volumes injected), and position *is* $x_D$.
 95    """
 96    return ResSim(Lx=1, Ly=1, Nx=1, Ny=N, fluid=fluid,
 97                  wells=[dict(xy=[0, 0], rate=+1),
 98                         dict(xy=[0, 1], rate=-1)])
 99
100
101## The analytic solution
102# NB: built from the model's *own* `fluid.fractional_flow`, so that it cannot
103# drift from the simulator's notion of the fluids -- only from its numerics.
104
105
106def welge_tangent(model) -> tuple:
107    """Locate the shock: `(S_f, shock speed)`, ref the docstring.
108
109    Rather than solving $ f(S)/(S - s_\\mathrm{wc}) = f'(S) $, we *maximize*
110    the chord slope -- the same point, but needing no derivative, and with no
111    root-bracketing to get wrong.
112    """
113    lo, hi = model.fluid.swc, 1 - model.fluid.sor
114    chord = lambda S: model.fluid.fractional_flow(S) / (S - lo)  # noqa: E731
115    opt = minimize_scalar(lambda S: -chord(S), method="bounded",
116                          bounds=(lo + 1e-9, hi), options=dict(xatol=1e-12))
117    return opt.x, chord(opt.x)
118
119
120def analytic(model, tD, xD, S_f):
121    """The saturation at `xD` at time `tD`: the profile, and its shock.
122
123    Behind the shock, $ x_D = t_D \\, f'(s) $ -- monotone in $s$ over
124    $ [S_f, 1 - s_\\mathrm{or}] $ (the tangent point lying beyond $f$'s
125    inflection), so it is inverted by interpolation. The shock itself needs no
126    special treatment: the tangent condition puts its position at exactly the
127    end of that range, so anything ahead of it is simply `right=swc`.
128    """
129    ss = np.linspace(S_f, 1 - model.fluid.sor, 10001)
130    xx = tD * model.fluid.dfractional_flow(ss)  # speed of each s
131    return np.interp(xD, xx[::-1], ss[::-1], right=model.fluid.swc)
132
133
134## Verify the analytic solution against the closed form
135cases: dict = dict(
136    A=dict(),                              # defaults: vw = vo = 1, swc = sor = 0
137    B=dict(vo=5., swc=.2, sor=.2),         # a contrast in both viscosity and endpoints
138)
139
140models = {case: make_model(200, fluid) for case, fluid in cases.items()}
141
142for case, model in models.items():
143    S_f, speed = welge_tangent(model)
144    # The closed form (ref the docstring), in terms of `M` and the endpoints
145    M = model.fluid.vo / model.fluid.vw
146    span = 1 - model.fluid.swc - model.fluid.sor
147    S_f_exact = model.fluid.swc + span / np.sqrt(1 + M)
148    tD_bt_exact = span * 2*(1 + M - np.sqrt(1 + M)) / (M * np.sqrt(1 + M))
149    assert np.isclose(S_f, S_f_exact, rtol=1e-8), "Welge tangent misplaced."
150    assert np.isclose(1/speed, tD_bt_exact, rtol=1e-8), "Closed form disagrees."
151    print(f"Case {case}: M = {M:g}, S_f = {S_f:.4f}, breakthrough at {1/speed:.4f} PVI")
152
153## Simulate: the profile, both schemes, both cases
154tD_snap = dict(A=.5, B=.3)  # a time before breakthrough, for each case
155profiles: dict = {}
156
157for case, model in models.items():
158    S_f, speed = welge_tangent(model)
159    xD = model.mesh[1].ravel()
160    S0 = np.full(model.Nxy, model.fluid.swc)
161
162    nSteps = 50
163    dt = tD_snap[case] / nSteps
164    S_exp, _ = model.sim(dt, nSteps, S0, pbar=False)
165    S_imp, _ = model.sim(dt, nSteps, S0, pbar=False, implicit=True)
166
167    profiles[case] = dict(xD=xD, S_f=S_f, speed=speed,
168                          exact=analytic(model, tD_snap[case], xD, S_f),
169                          explicit=S_exp[-1], implicit=S_imp[-1])
170
171    # Neither scheme may overshoot the physical range: the analytic solution is
172    # bounded by its data, and a monotone scheme must be too.
173    for scheme, S in [("explicit", S_exp), ("implicit", S_imp)]:
174        assert model.fluid.swc - 1e-12 <= S.min() and S.max() <= 1 - model.fluid.sor + 1e-12, (
175            f"Case {case}, {scheme} scheme: saturation out of bounds.")
176
177## Simulate: the production history (case A, past breakthrough)
178model = models["A"]
179S_f, speed = welge_tangent(model)
180tD_bt = 1 / speed
181
182nSteps = 150
183dt = 1.5 / nSteps
184tt = dt * np.arange(nSteps + 1)
185SS, _ = model.sim(dt, nSteps, np.full(model.Nxy, model.fluid.swc), pbar=False)
186
187# The water cut is the fractional flow of the producer's cell -- ref
188# `ResSim.assemble_wells`, which is what draws the produced fluid at that ratio.
189i_prd = model.xy2ind(*model.wells.xy[1])
190water_cut = model.fluid.fractional_flow(SS[:, i_prd])
191# Welge's forecast: the outlet saturation is the one whose characteristic has
192# just arrived. `analytic` returns `swc` (whence a zero water cut) before that.
193water_cut_exact = np.array([model.fluid.fractional_flow(analytic(model, t, 1., S_f))
194                            for t in tt])
195water_cut_exact[0] = 0  # `tD = 0` puts the whole profile at the inlet
196
197# Mass balance: what was injected is either still in place, or was produced.
198# (The tolerance is set by the trapezoidal integration of the jump at
199# breakthrough, not by the scheme, which conserves mass exactly.)
200in_place = (SS[-1] - model.fluid.swc).mean()
201produced = np.trapezoid(water_cut, tt)
202assert np.isclose(in_place + produced, tt[-1], rtol=2e-3), "Water unaccounted for."
203
204# Before breakthrough that balance is *exact*, nothing having been produced.
205# NB: "nothing" is not quite `0`: the explicit scheme's stencil advances one
206# cell per sub-step, so a (multiplicatively vanishing) tail of the front runs
207# ahead of it -- reaching the producer at some $ 10^{-150} $, long before the
208# water does. Hence the threshold, which also defines breakthrough below.
209DRY = 1e-4  # water cut counting as "no water", i.e. $ s ⪅ 0.01 $
210k_pre = round(.7 / dt)
211assert water_cut[k_pre] < DRY, "Breakthrough far too early."
212assert np.isclose((SS[k_pre] - model.fluid.swc).mean(), tt[k_pre]), "Injected water lost."
213
214# Breakthrough should be *slightly* early, the producer sitting half a cell
215# short of the outlet -- i.e. by `(hy/2) / speed`, which is under one `dt` here.
216tD_bt_sim = tt[(water_cut > DRY).argmax()]
217assert 0 <= tD_bt - tD_bt_sim < 2*dt, "Breakthrough mistimed."
218
219# Evaluated at breakthrough, it is the *other* reading of the tangent (ref the
220# "fractional flow" figure): the mean saturation is then the average behind the
221# front, $ s_wc + t_D^bt $ -- to within the smearing.
222k_bt = round(tD_bt / dt)
223assert np.isclose(SS[k_bt].mean(), model.fluid.swc + tD_bt, rtol=1e-2), (
224    "Welge average is off.")
225
226## Simulate: convergence under refinement (case A, explicit scheme)
227NN = np.array([50, 100, 200, 400, 800])
228L1 = np.zeros(len(NN))
229
230for i, N in enumerate(NN):
231    m = make_model(N, cases["A"])
232    S, _ = m.sim(tD_snap["A"]/50, 50, np.full(m.Nxy, m.fluid.swc), pbar=False)
233    # NB: the analytic solution is grid-independent -- only sampled anew
234    exact = analytic(m, tD_snap["A"], m.mesh[1].ravel(), profiles["A"]["S_f"])
235    L1[i] = abs(S[-1] - exact).mean()
236
237fit = np.polyfit(np.log(NN), np.log(L1), 1)
238rate = -fit[0]
239print(f"Convergence: L1 error ~ h^{rate:.2f}")
240assert .7 < rate < 1.1, "Lost (near-)first-order convergence."
241
242## Plot: the fractional-flow curve and the Welge tangent
243fig, ax = freshfig("Buckley-Leverett -- fractional flow", figsize=(6, 5))
244
245for case, p in profiles.items():
246    m = models[case]
247    S_f, tD_bt_ = p["S_f"], 1 / p["speed"]
248    ss = np.linspace(m.fluid.swc, 1 - m.fluid.sor, 201)
249    (h,) = ax.plot(ss, m.fluid.fractional_flow(ss), lw=2,
250                   label=f"$M$ = {m.fluid.vo/m.fluid.vw:g}, "
251                         f"$s_\\mathrm{{wc}}$ = {m.fluid.swc:g}, "
252                         f"$s_\\mathrm{{or}}$ = {m.fluid.sor:g}")
253    # The tangent, from the initial state up to `f = 1`, which it reaches at
254    # the *average* saturation behind the front (Welge's other reading of it).
255    ax.plot([m.fluid.swc, m.fluid.swc + tD_bt_], [0, 1], ":", c=h.get_color(), lw=1)
256    ax.plot(S_f, m.fluid.fractional_flow(S_f), "o", c=h.get_color(), ms=8,
257            label=f"$S_f$ = {S_f:.3f},  $t_D^\\mathrm{{bt}}$ = {tD_bt_:.3f}")
258    ax.plot(m.fluid.swc + tD_bt_, 1, "s", c=h.get_color(), ms=6, mfc="none",
259            label=f"$\\bar{{s}}$ = {m.fluid.swc + tD_bt_:.3f} (at breakthrough)")
260
261ax.set(title="The Welge tangent construction", xlabel="Water saturation, $s$",
262       ylabel="Fractional flow, $f(s)$", xlim=(0, 1), ylim=(0, 1.08))
263ax.legend(fontsize="small", loc="lower right")
264fig.tight_layout()
265
266## Plot: the saturation profiles, numerical vs. analytic
267fig, axs = freshfig("Buckley-Leverett -- saturation profile", ncols=2,
268                    sharey=True, figsize=(10, 4.5))
269
270for ax, (case, p) in zip(axs, profiles.items()):
271    ax.plot(p["xD"], p["exact"], "k-", lw=2, label="Analytic (Buckley-Leverett)")
272    ax.plot(p["xD"], p["explicit"], "C0.", ms=4, label="Explicit (upwind)")
273    ax.plot(p["xD"], p["implicit"], "C1.", ms=4, label="Implicit (Newton)")
274    ax.axvline(tD_snap[case] * p["speed"], c="k", ls=":", lw=1,
275               label="Shock position, $t_D \\, f'(S_f)$")
276    m = models[case]
277    ax.set(title=f"Case {case}:  $M$ = {m.fluid.vo/m.fluid.vw:g},"
278                 f"  $t_D$ = {tD_snap[case]}", xlabel="$x_D$")
279axs[0].set_ylabel("Water saturation, $s$")
280axs[0].legend(fontsize="small")
281fig.tight_layout()
282
283## Plot: water cut, and the convergence of the profile
284fig, (ax1, ax2) = freshfig("Buckley-Leverett -- verification", ncols=2,
285                           figsize=(10, 4.5))
286
287ax1.plot(tt, water_cut_exact, "k-", lw=2, label="Welge forecast")
288ax1.plot(tt, water_cut, "C0.", ms=4, label="Simulated (explicit)")
289ax1.axvline(tD_bt, c="k", ls=":", lw=1,
290            label=f"Breakthrough, $2(\\sqrt{{2}}-1)$ = {tD_bt:.4f}")
291ax1.set(title="Water cut at the producer", xlabel="$t_D$ (pore volumes injected)",
292        ylabel="$f_w$", ylim=(-.03, 1))
293ax1.legend(fontsize="small", loc="lower right")
294
295ax2.loglog(NN, L1, "C0-o", label="$L_1$ error")
296ax2.loglog(NN, np.exp(np.polyval(fit, np.log(NN))), "C0--", lw=1,
297           label=f"Fit: $\\propto h^{{{rate:.2f}}}$")
298ax2.loglog(NN, L1[0] * NN[0]/NN, "k:", lw=1, label="$O(h)$, for reference")
299ax2.set(title=f"Convergence of the profile at $t_D$ = {tD_snap['A']}",
300        xlabel="$N_y$", ylabel="Mean $|s - s_\\mathrm{exact}|$",
301        xticks=NN, xticklabels=[str(N) for N in NN])
302ax2.minorticks_off()
303ax2.legend(fontsize="small")
304fig.tight_layout()
305
306# Regression values, checked by `tests/test_examples.py`.
307__digest__ = dict(explicit  = profiles["A"]["explicit"],
308                  implicit  = profiles["A"]["implicit"],
309                  case_B    = profiles["B"]["explicit"],
310                  welge     = [profiles[c][k] for c in "AB"
311                               for k in ["S_f", "speed"]],
312                  water_cut = water_cut[water_cut > DRY],
313                  bt        = [tD_bt_sim, tD_bt],
314                  L1        = L1)
315
316if __name__ == "__main__":
317    show()