Source code for welltest_pta.analysis.horner
"""
welltest_pta.analysis.horner
============================
Horner (1951) semi-log extrapolation for buildups.
The Horner plot of :math:`p_{ws}` vs :math:`\\log_{10}\\big[(t_p+\\Delta t)/\\Delta t\\big]`
is linear during infinite-acting radial flow, with slope
.. math::
m = \\frac{162.6\\, q\\, \\mu\\, B}{k\\, h}\\quad\\text{(field units)}
Extrapolating the IARF straight line to :math:`(t_p+\\Delta t)/\\Delta t = 1`
(i.e. infinite shut-in) gives the false reservoir pressure :math:`P^*`.
For an infinite-acting reservoir :math:`P^* = \\bar{p}`; for bounded
systems use Matthews–Brons–Hazebroek (MBH) correction.
"""
from __future__ import annotations
import numpy as np
[docs]
def horner_extrapolation(
p: np.ndarray,
t_hr: np.ndarray,
tp_hr: float,
fit_start_frac: float = 0.30,
fit_end_frac: float = 0.85,
) -> dict[str, float]:
r"""
Linear regression of :math:`p_{ws}` vs Horner time on the IARF window.
Parameters
----------
p
Shut-in pressure (smoothed) over the buildup, length :math:`n_b`.
t_hr
Elapsed time in hours (any reference; only differences matter).
tp_hr
Producing time before shut-in (hr). For a Horner plot this is
the duration of the immediately preceding drawdown.
fit_start_frac, fit_end_frac
Fractional positions along the buildup that delimit the
infinite-acting straight line. Defaults skip the early
wellbore-storage transition (≤30 %) and the late boundary
deviation (≥85 %).
Returns
-------
dict with keys
- ``p_star`` : extrapolated pressure at :math:`(t_p+\\Delta t)/\\Delta t = 1`
- ``slope_m`` : Horner slope (psi / log-cycle), used for ``kh``
- ``r2`` : coefficient of determination on the fit window
- ``fit_start_idx``, ``fit_end_idx`` : indices of fit window
"""
p = np.asarray(p, dtype=float)
t_hr = np.asarray(t_hr, dtype=float)
if tp_hr is None or np.isnan(tp_hr) or tp_hr <= 0:
return {"p_star": np.nan, "slope_m": np.nan, "r2": np.nan,
"fit_start_idx": -1, "fit_end_idx": -1}
dt = t_hr - t_hr[0]
dt = np.where(dt <= 0, 1e-6, dt)
horner_x = np.log10((tp_hr + dt) / dt)
n_seg = len(p)
i0 = max(1, int(n_seg * fit_start_frac))
i1 = max(i0 + 5, int(n_seg * fit_end_frac))
if i1 - i0 < 5:
return {"p_star": np.nan, "slope_m": np.nan, "r2": np.nan,
"fit_start_idx": -1, "fit_end_idx": -1}
x_fit, y_fit = horner_x[i0:i1], p[i0:i1]
A = np.vstack([x_fit, np.ones_like(x_fit)]).T
(m, p_star), *_ = np.linalg.lstsq(A, y_fit, rcond=None)
y_pred = m * x_fit + p_star
ss_res = float(np.sum((y_fit - y_pred) ** 2))
ss_tot = float(np.sum((y_fit - y_fit.mean()) ** 2))
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
return {
"p_star": float(p_star),
"slope_m": float(m),
"r2": float(r2),
"fit_start_idx": int(i0),
"fit_end_idx": int(i1),
}
[docs]
def horner_diagnostic_line(
tp_hr: float, dt_min: float, dt_max: float, n: int = 100
) -> tuple[np.ndarray, np.ndarray]:
"""Return (horner_x, dt_grid) for plotting the IARF line of slope ``m``."""
dt = np.geomspace(max(dt_min, 1e-6), dt_max, n)
return np.log10((tp_hr + dt) / dt), dt