"""spi_toolkit.py — Specific Productivity Index (SPI) toolkit for Karama Field. Course 01 · Module 02 · Topic 2.3 — Specific Productivity Index. Reference implementation for the video productions: - Lecture 2.3 "The Specific Productivity Index" - Case Study 2.3C "SPI-Based Well Ranking and New Well J Prediction" Definitions (oilfield units, STB/day/psi/ft for SPI; STB/day/psi for J): SPI = J / h (measured, per foot of net pay) SPI_ideal = 0.00708 * k / (mu * B * (ln(re/rw) - 0.75)) (skin-corrected rock ceiling) J = SPI * h (back to productivity index) FE = SPI_meas / SPI_ideal = J_meas / J_ideal (flow efficiency) KRM-6 prediction (transfer the calibrated field SPI trend onto a seismic thickness map): J_P50 = mean(SPI_ideal over producers) * h J_P10 = min(SPI_ideal) * h (assume the field's worst rock at the new location) J_P90 = max(SPI_ideal) * h (assume the field's best rock) Q = J * (P_bar - P_wf) Partial-perforation correction (Topic 2.3 WE3 — a KRM-2-style well, NOT KRM-4): SPI_corrected = J_meas / (h_perf * CF) CF ~ 0.75-0.95 (vertical-flow convergence factor) J_full = J_meas / ((h_perf / h) * CF) All canonical numbers are from TECH-BRIEF-m02.md sections 1.7-1.8 (the T3 five-well table, which is the CANON where it disagrees with the T2 table for KRM-2 / KRM-5). """ from __future__ import annotations from dataclasses import dataclass from statistics import mean # --- Canonical PSS geometry shared by all Karama producers (TECH-BRIEF 1.7) --- LN_RE_RW = 8.22 # ln(re/rw) for re=1,320 ft, rw=0.354 ft (160-acre spacing) PSS_FACTOR = LN_RE_RW - 0.75 # = 7.47, the PSS geometric denominator factor MU = 1.4 # cp, oil viscosity at P_bar B = 1.25 # RB/STB, oil FVF at P_bar (NOT Bob = 1.28) C_DARCY = 0.00708 # field constant in the radial PSS inflow equation # --- Operating point for rate forecasts (TECH-BRIEF 1.2 / 1.4) --- P_BAR = 4850.0 # psia, current average reservoir pressure P_WF = 3100.0 # psia, flowing pressure set by separator back-pressure DRAWDOWN = P_BAR - P_WF # 1,750 psi Q_TARGET = 1200.0 # STB/d sustained target for every well @dataclass class Well: name: str h: float # net pay, ft k: float # permeability, mD S: float # measured skin J_meas: float # measured productivity index, STB/d/psi SPI_ideal: float # PUBLISHED skin-corrected SPI ceiling (canonical T3 column) J_ideal: float # PUBLISHED ideal J = SPI_ideal * h (canonical T3 column) # Canonical T3 five-well table (TECH-BRIEF 1.7) — the AUTHORITATIVE published values. # NOTE on SPI_ideal: the textbook formula 0.00708*k/(mu*B*(ln(re/rw)-0.75)) with a single # shared geometry reproduces only the k=18 wells (KRM-2, KRM-4 -> 0.00975) exactly. The # published per-well SPI_ideal column carries small well-by-well differences (drainage # geometry / rounding in the source page). Per the production guide we CARRY the published # values and document the quirk in README.md rather than silently recomputing them. KARAMA = [ Well("KRM-1", 110, 22, +2, 1.18, 0.01227, 1.35), Well("KRM-2", 95, 18, +14, 0.41, 0.00975, 0.926), Well("KRM-3", 130, 25, -1, 1.75, 0.01292, 1.68), Well("KRM-4", 95, 18, +5, 0.60, 0.00975, 0.926), Well("KRM-5", 75, 12, +9, 0.18, 0.00767, 0.575), ] def spi(J: float, h: float) -> float: """Measured SPI = J / h (STB/d/psi/ft).""" return J / h def spi_ideal_formula(k: float, mu: float = MU, B: float = B, pss_factor: float = PSS_FACTOR) -> float: """Textbook skin-corrected SPI ceiling = 0.00708*k / (mu*B*(ln(re/rw)-0.75)). Reproduces the k=18 mD wells (KRM-2, KRM-4 -> 0.00975) exactly. The published canonical column (Well.SPI_ideal) carries small per-well departures from this single-geometry formula — see the KARAMA table note and README.md. """ return C_DARCY * k / (mu * B * pss_factor) def flow_efficiency(spi_meas: float, spi_ideal_val: float) -> float: """FE = SPI_meas / SPI_ideal (= J_meas / J_ideal).""" return spi_meas / spi_ideal_val def j_from_spi(spi_val: float, h: float) -> float: """J = SPI * h.""" return spi_val * h def rate(J: float, p_bar: float = P_BAR, p_wf: float = P_WF) -> float: """Linear PI rate Q = J*(P_bar - P_wf), valid above the bubble point.""" return J * (p_bar - p_wf) def well_row(w: Well) -> dict: """Full per-well row using the canonical published columns. SPI_meas and FE are pure arithmetic on the measured inputs; SPI_ideal and J_ideal are the published canonical T3 values carried on the Well record. """ spi_m = spi(w.J_meas, w.h) fe = flow_efficiency(spi_m, w.SPI_ideal) return { "well": w.name, "h": w.h, "k": w.k, "S": w.S, "J_meas": w.J_meas, "SPI_meas": spi_m, "J_ideal": w.J_ideal, "SPI_ideal": w.SPI_ideal, "FE": fe, } def rank_wells(wells=KARAMA): """Return (rock_quality_ranking, measured_ranking) as lists of well names. Rock-quality ranking is by SPI_ideal (descending) — the geology, skin removed. Measured ranking is by SPI_meas (descending) — what the wells actually deliver. KRM-2 and KRM-4 tie on SPI_ideal (0.00975); the sort is stable, keeping table order. """ rows = [well_row(w) for w in wells] rock = [r["well"] for r in sorted(rows, key=lambda r: -r["SPI_ideal"])] meas = [r["well"] for r in sorted(rows, key=lambda r: -r["SPI_meas"])] return rock, meas def field_average_spi_ideal(wells=KARAMA) -> float: """Unweighted mean of producer SPI_ideal — the P50 transfer basis for a new well.""" return mean(w.SPI_ideal for w in wells) def predict_j(h_new: float, wells=KARAMA): """Predict a new well's J at thickness h_new by transferring the field SPI trend. Returns dict with P10/P50/P90 J and their SPI bases: P50 = field-average SPI_ideal (unweighted mean of the five producers) P10 = lowest observed SPI_ideal (KRM-5, worst rock) P90 = highest observed SPI_ideal (KRM-3, best rock) """ spis = [w.SPI_ideal for w in wells] spi_p50 = mean(spis) spi_p10 = min(spis) spi_p90 = max(spis) return { "h": h_new, "SPI_P10": spi_p10, "SPI_P50": spi_p50, "SPI_P90": spi_p90, "J_P10": j_from_spi(spi_p10, h_new), "J_P50": j_from_spi(spi_p50, h_new), "J_P90": j_from_spi(spi_p90, h_new), } def partial_perf_correction(J_meas: float, h: float, h_perf: float, CF: float = 0.88) -> dict: """Back out the full-completion J from a partially perforated test. Naive (correct) SPI uses perforated interval; the common error divides by full h. J_full = J_meas / ((h_perf/h) * CF). Worked on the Topic 2.3 WE3 KRM-2-style well: h=95, h_perf=55, J_meas=0.41, CF=0.88. """ b = h_perf / h spi_naive = J_meas / h_perf # SPI over perforated interval spi_wrong = J_meas / h # the mistake: divide by full pay j_full = J_meas / (b * CF) spi_corr = j_full / h return { "b": b, "SPI_naive": spi_naive, "SPI_wrong": spi_wrong, "J_full": j_full, "SPI_corrected": spi_corr, } def _approx(a: float, b: float, tol: float) -> str: return "OK" if abs(a - b) <= tol else f"MISMATCH (got {a}, want {b})" if __name__ == "__main__": print("=" * 72) print("KARAMA FIVE-WELL SPI TABLE (canonical T3 — TECH-BRIEF 1.7)") print("=" * 72) hdr = f"{'Well':6} {'h':>4} {'k':>4} {'S':>4} {'Jmeas':>6} {'SPImeas':>9} {'Jideal':>7} {'SPIideal':>9} {'FE':>5}" print(hdr) for w in KARAMA: r = well_row(w) print(f"{r['well']:6} {r['h']:>4.0f} {r['k']:>4.0f} {r['S']:>+4.0f} " f"{r['J_meas']:>6.2f} {r['SPI_meas']:>9.5f} {r['J_ideal']:>7.3f} " f"{r['SPI_ideal']:>9.5f} {r['FE']:>5.2f}") # --- Verify against the brief's published SPI_ideal / FE values --- print("\nVerification vs TECH-BRIEF published values:") checks = { "KRM-1": (0.01227, 0.87), "KRM-2": (0.00975, 0.44), "KRM-3": (0.01292, 1.04), "KRM-4": (0.00975, 0.65), "KRM-5": (0.00767, 0.31), } for w in KARAMA: r = well_row(w) spi_want, fe_want = checks[w.name] print(f" {w.name}: SPIideal {_approx(round(r['SPI_ideal'],5), spi_want, 6e-5)}" f" | FE {_approx(round(r['FE'],2), fe_want, 0.02)}") rock, meas = rank_wells() print("\nRock-quality ranking (SPIideal):", " > ".join(rock)) print(" expected: KRM-3 > KRM-1 > KRM-2 = KRM-4 (tied 0.00975) > KRM-5") print("Measured ranking (SPImeas): ", " > ".join(meas)) fa = field_average_spi_ideal() print(f"\nField-average SPIideal = {fa:.5f} STB/d/psi/ft " f"({_approx(round(fa,5), 0.01047, 6e-5)})") print("\n" + "=" * 72) print("KRM-6 PREDICTION (h = 115 ft seismic P50)") print("=" * 72) pred = predict_j(115) print(f" J_P50 = {pred['J_P50']:.3f} ({_approx(round(pred['J_P50'],3), 1.204, 0.002)})") print(f" J_P10 = {pred['J_P10']:.3f} ({_approx(round(pred['J_P10'],3), 0.882, 0.002)})") print(f" J_P90 = {pred['J_P90']:.3f} ({_approx(round(pred['J_P90'],3), 1.486, 0.002)})") print(f"\n Rates at Pwf={P_WF:.0f}, P_bar={P_BAR:.0f} (drawdown {DRAWDOWN:.0f} psi):") q10, q50, q90 = rate(pred['J_P10']), rate(pred['J_P50']), rate(pred['J_P90']) print(f" Q_P10 = {q10:,.0f} STB/d ({_approx(round(q10), 1544, 2)}) " f"margin {q10 - Q_TARGET:+,.0f} ({(q10/Q_TARGET-1)*100:+.0f}%)") print(f" Q_P50 = {q50:,.0f} STB/d ({_approx(round(q50), 2107, 2)}) " f"margin {q50 - Q_TARGET:+,.0f} ({(q50/Q_TARGET-1)*100:+.0f}%)") print(f" Q_P90 = {q90:,.0f} STB/d ({_approx(round(q90), 2601, 2)}) " f"margin {q90 - Q_TARGET:+,.0f} ({(q90/Q_TARGET-1)*100:+.0f}%)") print(f"\n Even the P10 case clears the {Q_TARGET:,.0f} STB/d target.") # Thickness sensitivity at P50 rock quality (slide 9) print("\n Seismic-thickness sensitivity at P50 rock quality:") for h in (100, 130): jp = j_from_spi(fa, h) print(f" h = {h} ft -> J = {jp:.3f}") print("\n" + "=" * 72) print("PARTIAL-PERFORATION CORRECTION (Topic 2.3 WE3 — KRM-2-style well)") print("=" * 72) pp = partial_perf_correction(J_meas=0.41, h=95, h_perf=55, CF=0.88) print(f" b = h_perf/h = {pp['b']:.4f}") print(f" SPI over perforated interval (naive) = {pp['SPI_naive']:.5f}") print(f" SPI over full pay (the mistake) = {pp['SPI_wrong']:.5f}") print(f" J_full (back-out) = {pp['J_full']:.3f} ({_approx(round(pp['J_full'],3), 0.805, 0.003)})") print(f" SPI_corrected = {pp['SPI_corrected']:.6f} ({_approx(round(pp['SPI_corrected'],6), 0.008474, 5e-6)})") print("\nAll checks above should read OK.")