Exerciseschevron_rightChapter 9chevron_right9.3
fitness_center

Exercise 9.3

Forecasting with Limited Data

Level 3
Chapter 9: Decline Curve Analysis
descriptionProblem

Take the 60-month synthetic dataset from this chapter. Fit a hyperbolic model using only the first 12 months, then 24, then 36, then all 60. How do the fitted parameters (qiq_i, DiD_i, bb) and EUR change as more data becomes available? Plot EUR vs. months of data used. What does this tell you about forecasting early in a well's life?

---

OML 58 well OD-014 is a fresh hyperbolic producer. To study how reliable an early forecast is, we use a clean, noise-free 60-month synthetic history generated from known truth: qi_bopd = 2000, di_per_month = 0.12, b_factor = 0.70. Because the truth is known, we can measure exactly how far an EUR estimate built from only the first few months strays from the answer the full history eventually gives.

t_months = np.arange(60)
q_bopd   = arps_hyperbolic(t_months, 2000.0, 0.12, 0.70)

The abandonment rate is q_abandon_bopd = 20. The true EUR (from the known params, via eur_hyperbolic) is the target every forecast is trying to hit.

Write two functions:

Units: qi is in bopd (bbl/day) and t/Di are per-month, so eur_hyperbolic returns bopd*months. Multiply by DAYS_PER_MONTH = 30.44 to report EUR in bbl (the book's own day/month convention).

  1. fit_eur_for_length(t, q, n_months, q_abandon=20): fit arps_hyperbolic

to only the first n_months of (t, q) with scipy.optimize.curve_fit, then return the EUR (bbl) computed from the fitted parameters via eur_hyperbolic(...) * DAYS_PER_MONTH. Use a sensible initial guess (e.g. p0=[q[0], 0.1, 0.5]) and constrain 0 < b < 1 so the EUR stays finite.

  1. eur_by_length(t, q, lengths): return a dict mapping each n in

lengths to fit_eur_for_length(t, q, n).

Then build the study for OD-014 over LENGTHS = [12, 24, 36, 60] and store the result dict in eur_by_n. Also store the true-param EUR in true_eur_bbl.

The lesson: on a clean hyperbolic well even a short window recovers the truth, but the moment real data carries noise, a 12-month forecast is far less trustworthy than one built on the full 60 months; early forecasts are unreliable.

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))


# ── OML 58 well OD-014: noise-free 60-month synthetic from known truth ────
DAYS_PER_MONTH = 30.44  # rates are bopd, t/Di are per-month -> EUR to bbl

TRUE_QI_BOPD = 2000.0
TRUE_DI_PER_MONTH = 0.12
TRUE_B_FACTOR = 0.70
Q_ABANDON_BOPD = 20.0

t_months = np.arange(60)
q_bopd = arps_hyperbolic(t_months, TRUE_QI_BOPD, TRUE_DI_PER_MONTH, TRUE_B_FACTOR)

LENGTHS = [12, 24, 36, 60]


def fit_eur_for_length(t, q, n_months, q_abandon=20):
    """Fit hyperbolic to the FIRST n_months of (t, q); return EUR (bbl).

    eur_hyperbolic returns bopd*months, so multiply by DAYS_PER_MONTH for bbl.
    """
    t_n = np.asarray(t, dtype=float)[:n_months]
    q_n = np.asarray(q, dtype=float)[:n_months]
    popt, _ = curve_fit(
        arps_hyperbolic,
        t_n,
        q_n,
        p0=[q_n[0], 0.1, 0.5],
        bounds=([1e-6, 1e-9, 1e-6], [1e7, 10.0, 0.999]),
        maxfev=100000,
    )
    qi_fit, di_fit, b_fit = popt
    return float(eur_hyperbolic(qi_fit, di_fit, b_fit, q_abandon) * DAYS_PER_MONTH)


def eur_by_length(t, q, lengths):
    """Map each n in `lengths` -> fit_eur_for_length(t, q, n)."""
    return {n: fit_eur_for_length(t, q, n) for n in lengths}


true_eur_bbl = float(
    eur_hyperbolic(TRUE_QI_BOPD, TRUE_DI_PER_MONTH, TRUE_B_FACTOR, Q_ABANDON_BOPD)
    * DAYS_PER_MONTH
)
eur_by_n = eur_by_length(t_months, q_bopd, LENGTHS)

print("true EUR (bbl):", true_eur_bbl)
print("EUR by data length:", eur_by_n)

lockCopying code is a Full Access feature.