Chris Parmer — home

production decline curve with exponential fit

Petroleum Engineering

Example from the compendium of canonical charts

Petroleum Engineering — production decline curve with exponential fit

Python Code

"""Petroleum Engineering — production decline curve with exponential fit."""


import numpy as np
import pandas as pd
import plotly.graph_objects as go

URL = "https://raw.githubusercontent.com/Jeffalltogether/well_decline_curve_analysis/master/data/Production_Time_Series.CSV"


def fit_exponential(t, q):
    """Fit q = qi * exp(-Di * t) via log-linear regression."""
    mask = q > 0
    if mask.sum() < 3:
        return None, None
    log_q = np.log(q[mask])
    t_fit = t[mask]
    coeffs = np.polyfit(t_fit, log_q, 1)
    Di = -coeffs[0]
    qi = np.exp(coeffs[1])
    return qi, Di


def generate():
    try:
        print("fetching production time series …")
        df = fetch_csv(URL)
        # Normalize column names
        df.columns = [c.strip() for c in df.columns]

        # Find the liquid/oil column
        liq_col = None
        for c in df.columns:
            if "liquid" in c.lower() or "oil" in c.lower() or "bbl" in c.lower():
                liq_col = c
                break
        if liq_col is None:
            raise ValueError(f"No liquid column found. Columns: {list(df.columns)}")

        # Find date and well ID columns
        date_col = None
        for c in df.columns:
            if "date" in c.lower():
                date_col = c
                break
        well_col = None
        for c in df.columns:
            if "api" in c.lower() or "uwi" in c.lower() or "well" in c.lower():
                well_col = c
                break

        df[date_col] = pd.to_datetime(df[date_col])
        df[liq_col] = pd.to_numeric(df[liq_col], errors="coerce")

        # Pick the most productive well
        if well_col:
            best_well = df.groupby(well_col)[liq_col].sum().idxmax()
            wdf = df[df[well_col] == best_well].copy()
        else:
            wdf = df.copy()

        wdf = wdf.dropna(subset=[liq_col]).sort_values(date_col)
        wdf = wdf[wdf[liq_col] > 0]

        # Skip ramp-up: start from peak month
        peak_idx = wdf[liq_col].idxmax()
        wdf = wdf.loc[peak_idx:].reset_index(drop=True)

        t = np.arange(len(wdf), dtype=float)
        q = wdf[liq_col].values.astype(float)

        qi, Di = fit_exponential(t, q)

    except Exception as e:
        print(f"  data fetch failed ({e}); using synthetic fallback")
        # Synthetic Arps exponential decline
        np.random.seed(42)
        t = np.arange(60, dtype=float)
        qi_true, Di_true = 800.0, 0.06
        q = qi_true * np.exp(-Di_true * t) * (1 + np.random.normal(0, 0.05, len(t)))
        q = np.maximum(q, 10)
        dates = pd.date_range("2020-01", periods=60, freq="MS")
        wdf = pd.DataFrame({date_col if 'date_col' in dir() else "date": dates, "Liquid (bbl)": q})
        date_col = list(wdf.columns)[0]
        liq_col = "Liquid (bbl)"
        qi, Di = qi_true, Di_true

    t_fit = np.arange(len(wdf), dtype=float)
    q_fit = qi * np.exp(-Di * t_fit) if (qi is not None and Di is not None) else None

    dates_arr = wdf[date_col]

    fig = go.Figure()

    # Actual production
    fig.add_trace(go.Scatter(
        x=dates_arr,
        y=wdf[liq_col],
        mode="lines+markers",
        name="Actual production",
        marker=dict(size=5, color=VIOLET),
        line=dict(color=VIOLET, width=1.5),
        hovertemplate="%{x|%b %Y}: %{y:,.0f} bbl<extra></extra>",
    ))

    # Exponential fit
    if q_fit is not None:
        fig.add_trace(go.Scatter(
            x=dates_arr,
            y=q_fit,
            mode="lines",
            name=f"Exp. fit (Dᵢ={Di:.3f}/mo)",
            line=dict(color=TEAL, width=2, dash="dash"),
            hovertemplate="%{x|%b %Y}: %{y:,.0f} bbl<extra>Fit</extra>",
        ))

    fig.update_layout(
        xaxis=dict(title=""),
        yaxis=dict(title="Liquid Production (bbl)", type="log"),
        legend=dict(x=0.98, y=0.98, xanchor="right", yanchor="top"),
        margin=dict(t=50, b=50, l=70, r=60),
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly