Exercise 9.3
Forecasting with Limited Data
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 (, , ) 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).
fit_eur_for_length(t, q, n_months, q_abandon=20): fitarps_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.
eur_by_length(t, q, lengths): return a dict mapping eachnin
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.
Stuck? Reveal hints one at a time — they progress from nudge to near-solution.
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.