Exerciseschevron_rightChapter 9chevron_right9.7
fitness_center

Exercise 9.7

Detecting a Decline-Regime Change

Level 3
Chapter 9: Decline Curve Analysis
descriptionProblem

Take the 60-month synthetic dataset and modify it: after month 36, increase the decline rate by 50% (simulating water breakthrough or mechanical failure). Fit a single hyperbolic model to the full dataset, then fit two separate models (months 1–36 and 37–60). Compare the EUR estimates. What does this tell you about the importance of identifying regime changes before fitting?

---

OML 58 well OD-047 produced cleanly for three years, then something changed. Around month 36 a water-handling upgrade went offline and the effective decline rate jumped by 50%: the kind of mechanical or reservoir event (water breakthrough, a failing ESP) that quietly rewrites a forecast. The reserves team must decide how much oil this well will still make. If they fit a single Arps curve to all 60 months, the gentle early-life data anchors the model and it badly over-states the remaining reserves. Fitting only the post-break behaviour tells the honest story.

A 60-month rate history q_bopd (one point per month, t_months = 0..59) is already defined for you:

  • Months 0–35: hyperbolic decline, qi_bopd = 2500, di_per_month = 0.10,

b_factor = 0.6.

  • At break_month = 36 the decline steepens by 50% (the post-break segment

is a fresh hyperbolic carried on from the month-35 rate with di_per_month * 1.5, same b_factor).

Use an abandonment rate of q_abandon_bopd = 20 throughout. Rates qi are in bopd and time t (with Di) is in months, so eur_hyperbolic returns bopd*months: multiply by DAYS_PER_MONTH = 30.44 to report the EUR as a true volume in bbl (the book's own convention). Write two functions, each of which fits a hyperbolic with scipy.optimize.curve_fit and returns an EUR (in bbl) via the embedded eur_hyperbolic:

  • fit_single(t_months, q_bopd) -> eur_bbl

Fit one hyperbolic to the full 0–59 history and return its EUR.

  • fit_segmented(t_months, q_bopd, break_month=36) -> eur_bbl

Fit a hyperbolic to only the post-break segment (t_months >= break_month), using local time tau = t_months - break_month so the segment starts at tau = 0. Return the EUR of that post-break model.

Then call both on the provided series and store the results:

eur_single_bbl    = fit_single(t_months, q_bopd)
eur_segmented_bbl = fit_segmented(t_months, q_bopd, break_month=36)

The lesson: identify regime changes before you fit. The single-curve EUR is the optimistic trap; the segmented EUR is the number you would defend in a reserves review.

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
from scipy.optimize import curve_fit


# ── Embedded Arps decline 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))


# ── OD-047 rate history (OML 58): regime change at month 36 ──────────────
QI_BOPD = 2500.0
DI_PER_MONTH = 0.10
B_FACTOR = 0.6
BREAK_MONTH = 36
Q_ABANDON_BOPD = 20.0

# q is in bopd (bbl/DAY) while t and Di are per-MONTH, so eur_hyperbolic returns
# bopd*months. Multiply by days-per-month to report a true volume in barrels.
DAYS_PER_MONTH = 30.44

t_months = np.arange(0, 60, dtype=float)
q_bopd = np.empty(60)
# Months 0..35: clean hyperbolic decline.
q_bopd[:BREAK_MONTH] = arps_hyperbolic(t_months[:BREAK_MONTH], QI_BOPD,
                                       DI_PER_MONTH, B_FACTOR)
# At month 36 the effective decline jumps 50%: carry on from the month-35
# rate as a fresh hyperbolic with Di * 1.5 (same b).
_q_anchor_bopd = float(arps_hyperbolic(35.0, QI_BOPD, DI_PER_MONTH, B_FACTOR))
_di_post = DI_PER_MONTH * 1.5
for _i in range(BREAK_MONTH, 60):
    _tau = t_months[_i] - 35.0
    q_bopd[_i] = arps_hyperbolic(_tau, _q_anchor_bopd, _di_post, B_FACTOR)


def fit_single(t_months, q_bopd):
    """Fit ONE hyperbolic to the full 0..59 history; return its EUR (bbl)."""
    t_arr = np.asarray(t_months, dtype=float)
    q_arr = np.asarray(q_bopd, dtype=float)
    popt, _ = curve_fit(
        arps_hyperbolic, t_arr, q_arr,
        p0=[q_arr[0], 0.1, 0.5],
        bounds=([0.0, 1e-6, 1e-3], [1e7, 5.0, 0.999]),
        maxfev=200000,
    )
    qi, Di, b = popt
    # eur_hyperbolic returns bopd*months; convert to barrels.
    return eur_hyperbolic(qi, Di, b, Q_ABANDON_BOPD) * DAYS_PER_MONTH


def fit_segmented(t_months, q_bopd, break_month=36):
    """Fit a hyperbolic to ONLY the post-break segment; return its EUR (bbl)."""
    t_arr = np.asarray(t_months, dtype=float)
    q_arr = np.asarray(q_bopd, dtype=float)
    mask = t_arr >= break_month
    tau = t_arr[mask] - break_month  # local time, starts at 0
    q_seg = q_arr[mask]
    popt, _ = curve_fit(
        arps_hyperbolic, tau, q_seg,
        p0=[q_seg[0], 0.1, 0.5],
        bounds=([0.0, 1e-6, 1e-3], [1e7, 5.0, 0.999]),
        maxfev=200000,
    )
    qi, Di, b = popt
    # eur_hyperbolic returns bopd*months; convert to barrels.
    return eur_hyperbolic(qi, Di, b, Q_ABANDON_BOPD) * DAYS_PER_MONTH


eur_single_bbl = fit_single(t_months, q_bopd)
eur_segmented_bbl = fit_segmented(t_months, q_bopd, break_month=BREAK_MONTH)

print("single-fit EUR (bbl):   ", eur_single_bbl)
print("segmented-fit EUR (bbl):", eur_segmented_bbl)

lockCopying code is a Full Access feature.