Exercise 7.5
Net Pay Cutoff Sensitivity
Cutoffs are where petrophysics meets the bank. On OD-003 (OML 58) the full evaluation is already done for you: every sample in logs now carries VSHALE, PHIE, and SW. What's left is the decision: which samples count as pay, and what that decision is worth.
A sample is net pay when it clears all three cutoffs at once:
> VSHALE < vsh_max and PHIE > phie_min and SW < sw_max
Each qualifying sample is step_ft thick (here 0.5 ft), so net pay in feet is just count(passing) × step_ft.
Write two functions:
net_pay_ft(logs, vsh_max, phie_min, sw_max, step_ft=STEP_FT): return the
net pay (feet) for one set of cutoffs.
pay_value_usd(net_pay_ft, per_ft=2_000_000): at $2 million per foot
of recoverable reserves, return the dollar value of that pay.
Then build a summary dict mapping each cutoff name to its net pay, running all three sets:
| Set | vsh_max | phie_min | sw_max |
|---|---|---|---|
| Tight | 0.30 | 0.12 | 0.50 |
| Base | 0.40 | 0.08 | 0.60 |
| Relaxed | 0.50 | 0.05 | 0.70 |
summary = {
"Tight": net_pay_ft(logs, 0.30, 0.12, 0.50),
"Base": net_pay_ft(logs, 0.40, 0.08, 0.60),
"Relaxed": net_pay_ft(logs, 0.50, 0.05, 0.70),
}Looser cutoffs can only add footage, never remove it, so the numbers must climb Tight ≤ Base ≤ Relaxed. The gap between them, multiplied out, is the reserves you're betting on a cutoff call.
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
import pandas as pd
np.random.seed(7)
depth = np.arange(7000.0, 7850.0, 0.5)
_cond = [depth < 7200, depth < 7400, depth < 7600, depth < 7700, depth >= 7700]
_gr = np.select(_cond, [105.0, 40.0, 32.0, 110.0, 45.0])
_rt = np.select(_cond, [4.0, 2.2, 35.0, 4.0, 2.5])
_rhob = np.select(_cond, [2.54, 2.31, 2.26, 2.55, 2.33])
_nphi = np.select(_cond, [0.33, 0.21, 0.17, 0.34, 0.22])
logs = pd.DataFrame({
"DEPTH": depth,
"GR": _gr + np.random.normal(0, 4.0, len(depth)),
"RT": _rt * np.exp(np.random.normal(0, 0.10, len(depth))),
"RHOB": _rhob + np.random.normal(0, 0.02, len(depth)),
"NPHI": np.clip(_nphi + np.random.normal(0, 0.015, len(depth)), 0.0, 1.0),
})
STEP_FT = 0.5
# --- Petrophysical evaluation (done for you on OD-003) -----------------------
# Adds VSHALE, PHIE, SW columns to `logs`. You only decide the cutoffs.
A, M, N, RW = 0.81, 2.0, 2.0, 0.04
RHO_MA, RHO_FL = 2.65, 1.0
_gr_clean = logs["GR"].quantile(0.05)
_gr_shale = logs["GR"].quantile(0.95)
_igr = (logs["GR"] - _gr_clean) / (_gr_shale - _gr_clean)
logs["VSHALE"] = _igr.clip(0.0, 1.0)
_phid = ((RHO_MA - logs["RHOB"]) / (RHO_MA - RHO_FL)).clip(0.0, 0.45)
_phia = (_phid + logs["NPHI"]) / 2.0
logs["PHIE"] = (_phia * (1.0 - logs["VSHALE"])).clip(0.0, 0.40)
_sw = (((A * RW) / (logs["PHIE"] ** M * logs["RT"])) ** (1.0 / N)).clip(0.0, 1.0)
_sw[logs["PHIE"] <= 0.01] = 1.0
logs["SW"] = _sw
# -----------------------------------------------------------------------------
def net_pay_ft(logs, vsh_max, phie_min, sw_max, step_ft=STEP_FT):
"""Feet of net pay: samples passing VSHALE<vsh_max AND PHIE>phie_min AND SW<sw_max."""
pay = (logs["VSHALE"] < vsh_max) & (logs["PHIE"] > phie_min) & (logs["SW"] < sw_max)
return int(pay.sum()) * step_ft
def pay_value_usd(net_pay_ft, per_ft=2_000_000):
"""Dollar value of net pay at `per_ft` USD per foot of recoverable reserves."""
return net_pay_ft * per_ft
summary = {
"Tight": net_pay_ft(logs, 0.30, 0.12, 0.50),
"Base": net_pay_ft(logs, 0.40, 0.08, 0.60),
"Relaxed": net_pay_ft(logs, 0.50, 0.05, 0.70),
}
print(summary)
print(f"Base net pay value: ${pay_value_usd(summary['Base']):,.0f}")
print(f"Tight -> Relaxed swing: ${pay_value_usd(summary['Relaxed'] - summary['Tight']):,.0f}")
lockCopying code is a Full Access feature.