Exerciseschevron_rightChapter 19chevron_right19.2
fitness_center

Exercise 19.2

Does the Stress Case Still Clear? - Break-even Oil Price

Level 2
Chapter 19: Real-World Projects
descriptionProblem

Re-run Project 2's economics across a sweep of oil prices and find the break-even, the lowest price at which the field NPV is still positive. If your asset team's hurdle is a positive NPV at \$50/bbl, does this development clear it? Then push further: NPV here is linear in oil price but not in the discount rate; re-run at a 15% discount and see how much the answer moves. Which assumption (price deck, opex, discount rate, terminal decline) would you most want to defend in the meeting, and why is it not the one with the widest swing?

---

The reserves meeting will not ask for barrels; it will ask whether the development survives a low oil price. Project 2 already fit each well's decline; here you turn those fits into the one number an asset manager reads first: the break-even oil price, the price at which the field's NPV crosses zero.

The four wells are fit for you in the do-not-edit block (FITS[well] = (qi, Di, b)), along with the decline machinery and the cost constants (ECON, DAYS, DMIN, OPEX_VAR, OPEX_FIX). You write the economics.

Write two functions and two module-level values:

def npv_of(qi, Di, b, price, disc=0.10):
    '''One well's discounted NPV in dollars at a given oil price.'''

def field_npv(price, disc=0.10):
    '''Field NPV in $MM = sum of the four wells at this price.'''

Exact procedure (mirror the chapter's npv_from):

  1. In npv_of, build the forecast q = modified_hyperbolic(t, qi, Di, b, DMIN)

over t = np.arange(0, 360), keep months above the economic limit (q = q[q >= ECON]), form the monthly cash flow cf = q DAYS (price - OPEX_VAR) - OPEX_FIX, and discount it: np.sum(cf / np.power(1 + disc, np.arange(len(q)) / 12.0)).

  1. field_npv(price, disc=0.10) sums npv_of(*FITS[w], price, disc) over the

wells and divides by 1e6 (so it returns $MM).

  1. breakeven = the lowest whole-dollar price in range(10, 80) for which

field_npv(price) > 0.

  1. clears_50 = bool(field_npv(50) > 0): does it clear a \$50/bbl hurdle?

Expose exactly these names: field_npv (the function), breakeven, clears_50.

> Think about it: the field NPV falls roughly \6 MM for every \1 the oil > price drops, yet break-even sits near \21/bbl. The development clears the > \55 stress case with room to spare. Which input would you least want to be > wrong about in the meeting: the price deck, the fixed opex, or the terminal > decline you assumed?

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 io
import numpy as np
import pandas as pd
from scipy.optimize import curve_fit
import warnings
warnings.simplefilter("ignore")


# ── Verified Chapter 19 production data + decline machinery (do not edit) ─
def hyperbolic(t, qi, Di, b):
    return qi / np.power(1 + b * Di * t, 1.0 / b)


def modified_hyperbolic(t, qi, Di, b, Dmin):
    """Arps hyperbolic that switches to a terminal exponential decline at Dmin."""
    t_sw = (Di / Dmin - 1) / (b * Di) if (b > 0 and Di > Dmin) else 1e9
    return np.where(t <= t_sw, hyperbolic(t, qi, Di, b),
                    hyperbolic(t_sw, qi, Di, b) * np.exp(-Dmin * (t - t_sw)))


PROD = """well,date,oil_bopd
OD-001,2025-01,2347.9
OD-001,2025-02,2278.1
OD-001,2025-03,2276.2
OD-001,2025-04,2200.2
OD-001,2025-05,2151.0
OD-001,2025-06,2000.3
OD-001,2025-07,1827.7
OD-001,2025-08,1807.2
OD-001,2025-09,1657.7
OD-001,2025-10,1674.6
OD-001,2025-11,1570.1
OD-001,2025-12,1561.9
OD-001,2026-01,1532.1
OD-001,2026-02,1510.5
OD-001,2026-03,
OD-001,2026-04,1280.1
OD-001,2026-05,1333.5
OD-001,2026-06,1158.4
OD-001,2026-07,1224.0
OD-001,2026-08,1103.0
OD-001,2026-09,1138.6
OD-001,2026-10,1021.0
OD-001,2026-11,1095.7
OD-001,2026-12,965.1
OD-003,2025-01,3081.7
OD-003,2025-02,3007.0
OD-003,2025-03,2792.2
OD-003,2025-04,-200.0
OD-003,2025-05,2431.6
OD-003,2025-06,2247.2
OD-003,2025-07,2180.3
OD-003,2025-08,2171.0
OD-003,2025-09,1979.1
OD-003,2025-10,1788.0
OD-003,2025-11,1694.2
OD-003,2025-12,1576.2
OD-003,2026-01,1568.3
OD-003,2026-02,1428.9
OD-003,2026-03,1270.9
OD-003,2026-04,1315.6
OD-003,2026-05,1207.4
OD-003,2026-06,1177.8
OD-003,2026-07,1074.7
OD-003,2026-08,960.8
OD-003,2026-09,968.1
OD-003,2026-10,899.4
OD-003,2026-11,806.9
OD-003,2026-12,770.1
OD-005,2025-01,1807.2
OD-005,2025-02,1737.1
OD-005,2025-03,1720.5
OD-005,2025-04,1644.8
OD-005,2025-05,1593.0
OD-005,2025-06,1539.4
OD-005,2025-07,1535.7
OD-005,2025-08,15000.0
OD-005,2025-09,1450.4
OD-005,2025-10,1329.5
OD-005,2025-11,1324.6
OD-005,2025-12,1331.2
OD-005,2026-01,1266.3
OD-005,2026-02,1234.4
OD-005,2026-03,1129.9
OD-005,2026-04,1123.2
OD-005,2026-05,1135.1
OD-005,2026-06,1080.7
OD-005,2026-07,1084.6
OD-005,2026-08,1061.8
OD-005,2026-09,1038.6
OD-005,2026-10,1004.2
OD-005,2026-11,885.3
OD-005,2026-12,890.1
OD-007,2025-01,2871.6
OD-007,2025-02,2862.5
OD-007,2025-03,2779.9
OD-007,2025-04,2584.2
OD-007,2025-05,2406.8
OD-007,2025-06,2317.7
OD-007,2025-07,2126.1
OD-007,2025-08,2064.2
OD-007,2025-09,1929.2
OD-007,2025-10,1911.9
OD-007,2025-11,1777.9
OD-007,2025-12,1671.4
OD-007,2026-01,1627.4
OD-007,2026-02,1509.9
OD-007,2026-03,1353.1
OD-007,2026-04,1332.1
OD-007,2026-05,1272.4
OD-007,2026-06,1204.4
OD-007,2026-07,1175.7
OD-007,2026-08,1082.7
OD-007,2026-09,1110.0
OD-007,2026-10,1099.1
OD-007,2026-11,953.7
OD-007,2026-12,980.7
"""
prod = pd.read_csv(io.StringIO(PROD))

ECON, DAYS, DMIN = 20.0, 30.4, 0.06 / 12        # economic limit, days/month, 6%/yr terminal decline
OPEX_VAR, OPEX_FIX = 18.0, 45000.0              # $/bbl variable, $/well/month fixed


def clean_mask(q):
    med = np.nanmedian(q)
    return np.isfinite(q) & (q > 0) & (q < 3 * med)


# Fit each well's decline ONCE (price-independent). FITS[well] = (qi, Di, b).
FITS = {}
for _w in sorted(prod.well.unique()):
    _q = prod[prod.well == _w].oil_bopd.values.astype(float)
    _ok = clean_mask(_q)
    _t = np.arange(len(_q))
    _popt, _ = curve_fit(hyperbolic, _t[_ok], _q[_ok], p0=[_q[_ok][0], 0.05, 0.5],
                         bounds=([0, 0, 0.01], [8000, 1, 0.99]), maxfev=5000)
    FITS[_w] = (float(_popt[0]), float(_popt[1]), float(_popt[2]))
# ── end do-not-edit ──────────────────────────────────────────────────────


def npv_of(qi, Di, b, price, disc=0.10):
    """One well's discounted NPV in dollars at a given oil price."""
    t = np.arange(0, 360)
    q = modified_hyperbolic(t, qi, Di, b, DMIN)
    q = q[q >= ECON]
    cf = q * DAYS * (price - OPEX_VAR) - OPEX_FIX                 # monthly cash flow, $
    return float(np.sum(cf / np.power(1 + disc, np.arange(len(q)) / 12.0)))


def field_npv(price, disc=0.10):
    """Field NPV in $MM = sum of the four wells at this price."""
    return sum(npv_of(*FITS[w], price, disc) for w in FITS) / 1e6


# Lowest whole-dollar oil price at which the field NPV is still positive.
breakeven = min(p for p in range(10, 80) if field_npv(p) > 0)

# Does the development clear an asset hurdle of positive NPV at $50/bbl?
clears_50 = bool(field_npv(50) > 0)

print(f"field NPV @ $75: {field_npv(75):.0f} MM")
print(f"field NPV @ $55: {field_npv(55):.0f} MM")
print(f"field NPV @ $50: {field_npv(50):.0f} MM")
print(f"break-even oil price: ~${breakeven}/bbl")
print(f"clears the $50/bbl hurdle: {clears_50}")

lockCopying code is a Full Access feature.