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