Exercise 20.1
A KPI That Changes the Call -- Days of Inventory
Add a fifth KPI to well_kpis: days of inventory, at the current producing rate and a given economic limit, how many days until the well drops below it (project the recent 30-day decline forward). Which well in the field is closest to its economic limit, and is it the same well the decline alert already flags? When would days-of-inventory flag a well that the 30-day-decline threshold misses?
---
The surveillance scorecard already reports each well's 30-day decline. This exercise adds the KPI that turns a decline rate into a deadline: days of inventory -- how many days until the well's producing rate falls to the economic limit, if its current decline continues.
The verified field engine (make_field, well_kpis) is embedded under a do-not-edit banner. Write two functions:
def days_of_inventory(kpis, econ=50.0):
"""Days until each well hits the economic limit, projecting its 30-day decline
forward (exponential). Returns {well: days}; flat/growing wells -> 99999."""
def closest_to_econ(kpis, econ=50.0):
"""The well with the FEWEST days of inventory."""Exact procedure: for each well let d = decline_30d_pct / 100. If d <= 0 (flat or growing) or last_rate <= econ, set days to 99999. Otherwise the rate after t months is last_rate (1 - d)t; solve for the t where it equals econ -- t = log(econ / last_rate) / log(1 - d) -- and return int(round(t 30)). closest_to_econ returns min(inventory, key=inventory.get).
Expose: days_of_inventory, closest_to_econ, inventory = days_of_inventory(KPIS), closest = closest_to_econ(KPIS).
> Think about it: the well closest to its economic limit is OD-003 -- the same > well the steep-decline alert already flags, because a 27%/month decline burns > through inventory fast. When would days-of-inventory flag a well that the > decline-percentage threshold misses (hint: a well already producing near the > limit, declining slowly)?
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
# ── Verified Chapter 20 field surveillance engine (do not edit) ──────────
# Per-well profiles: (id, qi, annual Di, b, problem). The field is mostly healthy;
# three wells carry the problems a morning surveillance check exists to catch.
WELLS = [
("OD-001", 1500, 0.22, 0.6, "stale"), # feed stopped reporting 3 days ago
("OD-002", 2100, 0.30, 0.7, None),
("OD-003", 2600, 0.28, 0.8, "decline"), # recent step drop -> steep 30-day decline
("OD-004", 1800, 0.20, 0.5, None), # the steady earner
("OD-005", 1200, 0.35, 0.7, "downtime"), # frequent outages -> low uptime
("OD-006", 2000, 0.26, 0.6, None),
]
DAYS = 730
def make_field(seed=11):
"""A field's DAILY production surveillance feed (long format). Sensor noise,
real outages, and three planted problems -- a stale feed, a steep decline, and
a low-uptime well -- the raw stream a monitoring dashboard sits on top of."""
rng = np.random.default_rng(seed)
rows = []
for wid, qi, Di_yr, b, problem in WELLS:
Di = Di_yr / 365.0
t = np.arange(DAYS)
q = qi / np.power(1 + b * Di * t, 1.0 / b)
q = q * rng.normal(1.0, 0.03, DAYS)
if problem == "decline": # a pressure/liquid-loading hit: -45% over last 40 d
q[-40:] *= np.linspace(1.0, 0.55, 40)
if problem == "downtime": # chronic intermittent producer
q[rng.random(DAYS) < 0.20] = 0.0 # ~20% of days down -> ~80% uptime
else:
for _ in range(rng.integers(2, 5)): # occasional multi-day outage
s = rng.integers(0, DAYS - 6)
q[s:s + rng.integers(1, 6)] = 0.0
last = DAYS - (3 if problem == "stale" else rng.integers(0, 2))
q = np.maximum(q[:last], 0.0)
rows.append(pd.DataFrame({"well": wid, "day": np.arange(len(q)), "oil_bopd": q}))
return pd.concat(rows, ignore_index=True)
def well_kpis(field):
"""The surveillance scorecard: one row per well, the numbers a foreman reads."""
asof = field.day.max()
out = []
for w, g in field.groupby("well"):
g = g.sort_values("day")
rate = g.oil_bopd.values
prod = rate > 0
last_rate = float(rate[prod][-1]) if prod.any() else 0.0
recent, prior = rate[-30:], rate[-60:-30]
a_recent = recent[recent > 0].mean() if (recent > 0).any() else 0.0
a_prior = prior[prior > 0].mean() if (prior > 0).any() else np.nan
decl = (a_prior - a_recent) / a_prior * 100 if a_prior and not np.isnan(a_prior) else 0.0
out.append(dict(well=w, last_rate=round(last_rate, 1), decline_30d_pct=round(float(decl), 1),
uptime_pct=round(float(prod.mean() * 100), 1),
cum_mbbl=round(rate.sum() / 1000, 1), days_stale=int(asof - g.day.max())))
return pd.DataFrame(out)
def downsample(day, rate, n_buckets=120):
"""min/max decimation: per bucket keep BOTH the lowest and highest sample, so a
spike or a zero (outage) is never averaged away. Returns reduced (day, rate)."""
if len(day) <= 2 * n_buckets:
return day, rate
keep = []
for chunk in np.array_split(np.arange(len(day)), n_buckets):
r = rate[chunk]
keep.append(chunk[r.argmin()])
keep.append(chunk[r.argmax()])
keep = np.unique(keep)
return day[keep], rate[keep]
# ── end do-not-edit ───────────────────────────────────────────
def days_of_inventory(kpis, econ=50.0):
"""Days until each well hits the economic limit at its current 30-day decline."""
out = {}
for _, r in kpis.iterrows():
d = r.decline_30d_pct / 100.0
if d <= 0 or r.last_rate <= econ:
out[r.well] = 99999
else:
t_months = np.log(econ / r.last_rate) / np.log(1 - d)
out[r.well] = int(round(t_months * 30))
return out
def closest_to_econ(kpis, econ=50.0):
inv = days_of_inventory(kpis, econ)
return min(inv, key=inv.get)
FIELD = make_field()
KPIS = well_kpis(FIELD)
inventory = days_of_inventory(KPIS)
closest = closest_to_econ(KPIS)
print("days of inventory:", inventory)
print("closest to the economic limit:", closest)
lockCopying code is a Full Access feature.