Exerciseschevron_rightChapter 9chevron_right9.5
fitness_center

Exercise 9.5

Modified Hyperbolic - Bounding a b>1 Shale Well

Level 3
Chapter 9: Decline Curve Analysis
descriptionProblem

A shale gas well has b=1.4b = 1.4 from a standard hyperbolic fit. Implement the Modified Hyperbolic model with DminD_{min} values of 0.3%, 0.5%, and 1.0% per month. Compare the EUR from each DminD_{min} assumption. Why is the choice of DminD_{min} so consequential for unconventional reserves?

---

Shale well OD-014 on OML 58 was fit with a standard Arps hyperbolic decline and came back with b_factor = 1.4, a value greater than 1. That is physically common for transient-flow unconventional wells, but it breaks the reserves math: the closed-form hyperbolic EUR

EUR=qibDi(1b)(qi1bqab1b)\text{EUR} = \frac{q_i^{\,b}}{D_i\,(1-b)}\Big(q_i^{\,1-b} - q_{ab}^{\,1-b}\Big)

diverges for b1b \ge 1 (the 1b1-b term flips sign and the well never stops declining fast enough). You cannot book infinite reserves. The industry fix is the modified hyperbolic model: the well declines hyperbolically until its instantaneous decline rate falls to a terminal floor DminD_{min}, then switches to exponential decline at DminD_{min} forever after. The exponential tail makes the integral converge, so EUR is finite, and the value of DminD_{min} you assume controls how much you book.

The well parameters: qi_bopd = 3000 (bbl per day), di_per_month = 0.30, b_factor = 1.4. Time t and the decline rates are per month.

Your tasks (the Arps + modified_hyperbolic functions are embedded; do not re-derive them):

  1. Write eur_modified(qi, Di, b, Dmin, q_abandon=10.0, t_max=3000).
  • Build a fine monthly time grid t = np.linspace(0, t_max, ...) (use at

least a few thousand points for a smooth integral). Make t_max long enough that the rate actually falls below q_abandon for every Dmin: the integral should be abandonment-limited, not chopped off by the window. (A 0.3%/month tail takes a long time to die; t_max = 3000 months is comfortably sufficient.)

  • Evaluate rate with modified_hyperbolic(t, qi, Di, b, Dmin).
  • Numerically integrate the producing portion only: keep the points

where q >= q_abandon and integrate them with np.trapz(q_keep, t_keep).

  • q is in bbl/day but t is in months, so the trapz area is in

bopd·months. Multiply by DAYS_PER_MONTH = 30.44 to convert to barrels and return the cumulative volume in bbl as eur_bbl.

  1. Write compare_dmin(qi, Di, b, dmin_list) returning a dict mapping each

Dmin to its eur_bbl ({dmin: eur_modified(qi, Di, b, dmin)}).

  1. Call compare_dmin(3000, 0.30, 1.4, [0.003, 0.005, 0.01]) and store it in

eur_by_dmin (the three DminD_{min} values are 0.3%, 0.5%, and 1.0% per month).

> Think about it: a lower terminal decline DminD_{min} means a flatter, longer > tail: more booked barrels (roughly 2–3 million bbl here once converted to > barrels). Confirm the standard eur_hyperbolic(3000, 0.30, 1.4, 10) returns > nan (its b1b \ge 1 guard), and that every modified EUR is a finite number. > Why is the DminD_{min} assumption one of the most litigated numbers in > unconventional reserves reporting?

lightbulbHints (0/3)

Stuck? Reveal hints one at a time — they progress from nudge to near-solution.

codeYour solution
main.py
visibilityReveal reference solutionexpand_more

Try solving it yourself first — the hints walk you through it. The solution below is one valid approach; yours may differ and still be correct.

import numpy as np


# ── Embedded Arps + modified-hyperbolic functions (do not edit) ──────────
def arps_hyperbolic(t, qi, Di, b):
    return qi / (1.0 + b * Di * t) ** (1.0 / b)


def eur_hyperbolic(qi, Di, b, q_abandon):
    if qi <= q_abandon or b >= 1:
        return float("nan")
    return (qi ** b / (Di * (1.0 - b))) * (qi ** (1.0 - b) - q_abandon ** (1.0 - b))


def modified_hyperbolic(t, qi, Di, b, Dmin):
    t = np.asarray(t, dtype=float)
    q = np.zeros_like(t)
    for i, ti in enumerate(t):
        D_t = Di / (1.0 + b * Di * ti)
        if D_t > Dmin:
            q[i] = qi / (1.0 + b * Di * ti) ** (1.0 / b)
        else:
            t_switch = (Di / Dmin - 1.0) / (b * Di)
            q_switch = qi / (1.0 + b * Di * t_switch) ** (1.0 / b)
            q[i] = q_switch * np.exp(-Dmin * (ti - t_switch))
    return q


# ── OD-014 shale well: standard hyperbolic fit returned b = 1.4 (b > 1!) ─
QI_BOPD = 3000.0  # qi is in bbl/DAY (bopd); t and Di are per-MONTH
DI_PER_MONTH = 0.30
B_FACTOR = 1.4
DMIN_LIST = [0.003, 0.005, 0.01]  # 0.3% / 0.5% / 1.0% per month
DAYS_PER_MONTH = 30.44  # bopd * months -> bbl


def eur_modified(qi, Di, b, Dmin, q_abandon=10.0, t_max=3000):
    """EUR (bbl) by numerically integrating modified_hyperbolic over months.

    qi is in bopd and t/Di are per month, so trapz gives bopd*months; multiply
    by DAYS_PER_MONTH to report barrels. t_max is large enough that the integral
    is abandonment-limited (q drops below q_abandon) for every Dmin, not cut off
    by the window.
    """
    t = np.linspace(0.0, float(t_max), 6001)
    q = modified_hyperbolic(t, qi, Di, b, Dmin)
    mask = q >= q_abandon
    if not np.any(mask):
        return 0.0
    return float(np.trapz(q[mask], t[mask]) * DAYS_PER_MONTH)


def compare_dmin(qi, Di, b, dmin_list):
    """Dict mapping each Dmin -> its modified-hyperbolic EUR (bbl)."""
    return {dmin: eur_modified(qi, Di, b, dmin) for dmin in dmin_list}


eur_by_dmin = compare_dmin(QI_BOPD, DI_PER_MONTH, B_FACTOR, DMIN_LIST)

print("standard hyperbolic EUR (b=1.4):", eur_hyperbolic(QI_BOPD, DI_PER_MONTH, B_FACTOR, 10.0))
print("modified EUR by Dmin:", eur_by_dmin)

lockCopying code is a Full Access feature.