Coverage for src/gwtransport/residence_time.py: 0%
326 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 20:54 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 20:54 +0000
1"""
2Residence Time Calculations for Retarded Compound Transport.
4This module provides functions to compute residence times for compounds traveling through
5aquifer systems, accounting for flow variability, pore volume, and retardation due to
6physical or chemical interactions with the aquifer matrix. Residence time represents the
7duration a compound spends traveling from infiltration to extraction points, depending on
8flow rate (higher flow yields shorter residence time), pore volume (larger volume yields
9longer residence time), and retardation factor (interaction with matrix yields longer
10residence time).
12The residence times are resolved per pore volume (:func:`full`), collapsed over a discrete
13equally-weighted pore-volume distribution (:func:`mean`), or in closed form over a (shifted) gamma
14aquifer pore-volume distribution with no discretization (:func:`gamma`).
16Spin-up period
17--------------
18The spin-up **region** is determined entirely by the supplied flow record (``tedges``, which
19fixes the cumulative throughflow volume ``V`` from ``0`` at the record start to ``V_end`` at the
20record end) together with the retarded pore volume ``retardation_factor * V_p`` -- it is not a
21length you set. A residence time for an output time needs the corresponding parcel to stay inside
22the flow record:
24* ``direction='extraction_to_infiltration'`` looks **back** to the infiltration event, so the
25 spin-up sits at the **start** of the output record: the residence time of a pore volume ``V_p``
26 needs ``V(t) >= retardation_factor * V_p`` (the extracted water was infiltrated before the record
27 began otherwise).
28* ``direction='infiltration_to_extraction'`` looks **forward** to the extraction event, so the
29 spin-up sits at the **end** of the output record: it needs ``V_end - V(t) >= retardation_factor *
30 V_p`` (the infiltrated water is extracted after the record ends otherwise).
32The spin-up therefore lengthens with both the pore volume and the retardation factor, and is
33longest for the largest pore volumes of a distribution.
35What happens in that region is governed by a ``spinup`` policy, following the package convention
36(see :mod:`gwtransport.advection`); :func:`full`, :func:`mean` and
37:func:`gamma` all share the contract ``spinup={'constant'} | None | float in
38[0, 1]`` and the default is ``"constant"`` everywhere:
40* ``"constant"`` (default) **warm-starts** by extrapolating the boundary flow (flow held constant
41 at its first/last value), so no in-record output is ``NaN``.
42* ``None`` is strict (no extrapolation), marking a pore volume ``NaN`` for any output bin its parcel
43 leaves the record within. Where the pore-volume axis is collapsed -- :func:`mean` over a
44 discrete set, :func:`gamma` over the continuum -- the bin mean then **renormalizes**
45 over the covered streamtubes / sub-mass, emitted wherever any coverage remains.
46* a ``float`` covered-fraction threshold is the strict mode with a minimum coverage gate: the
47 renormalized mean is emitted only where the covered streamtube fraction / sub-mass fraction is at
48 least ``spinup`` (``0.0`` matches ``None``; larger values demand more coverage). For the
49 per-pore-volume :func:`full` there is no axis to collapse, so the ``float`` behaves
50 exactly like ``None``.
52Output bins lying wholly outside ``tedges`` are ``NaN`` under every policy.
54The :func:`fraction_explained_full` / :func:`fraction_explained_mean` /
55:func:`fraction_explained_gamma` diagnostics report, per output bin, the advective fraction of the
56pore-volume distribution that is out of spin-up (``1.0`` = advectively fully informed, ``0.0`` =
57entirely in spin-up) and are the way to locate the spin-up region when the means warm-start over it.
58They are **purely advective** -- molecular diffusion and microdispersion spread each bin over a
59range of infiltration times that is not captured there, so no bin is fully informed once dispersion
60is present (for that dispersive informed fraction use the captured kernel mass of the diffusion
61coefficient matrix).
63Available functions:
65- :func:`full` - Flow-weighted mean residence time [days] over each output bin, resolved per pore volume:
66 the full ``(n_pore_volumes, n_output_bins)`` array, without collapsing the pore-volume axis. The bin
67 average is uniform in cumulative throughflow volume, and ``direction`` selects whether the time is the
68 look-back to infiltration (``extraction_to_infiltration``) or the look-forward to extraction
69 (``infiltration_to_extraction``).
71- :func:`mean` - Mean residence time [days] per output bin for a discrete aquifer pore-volume
72 distribution: the :func:`full` array collapsed by averaging over the equally-weighted streamtubes that
73 are valid in each bin, shape ``(n_output_bins,)``.
75- :func:`gamma` - Mean residence time [days] per output bin for a continuous (shifted) gamma aquifer
76 pore-volume distribution, parameterized by either ``(mean, std, loc)`` or ``(alpha, beta, loc)``. The
77 expectation is taken in closed form from regularized incomplete-gamma partial moments, so there is no
78 pore-volume discretization and no accuracy/cost knob; shape ``(n_output_bins,)``.
80- :func:`fraction_explained_full` - Advective coverage per pore volume: the flow-weighted fraction of each
81 output bin, in ``[0, 1]``, whose retarded parcel lies inside the supplied flow record. Returned as the
82 full ``(n_pore_volumes, n_output_bins)`` array, mirroring :func:`full`.
84- :func:`fraction_explained_mean` - Equally-weighted mean of :func:`fraction_explained_full` over the
85 discrete streamtubes, shape ``(n_output_bins,)``.
87- :func:`fraction_explained_gamma` - Closed-form expectation of the advective in-record indicator over a
88 (shifted) gamma pore-volume distribution, shape ``(n_output_bins,)`` -- the continuum analogue of
89 :func:`fraction_explained_mean`, evaluated from the antiderivative of the shifted-gamma CDF.
91- :func:`freundlich_retardation` - Concentration-dependent retardation factors
92 ``R = 1 + (rho_b / theta) * k_f * (1 / n) * C ** (1 / n - 1)`` from the Freundlich isotherm
93 ``s = k_f * C ** (1 / n)``, one per concentration entry, for use as the ``retardation_factor`` input of
94 the transport functions.
96This file is part of gwtransport which is released under AGPL-3.0 license.
97See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details.
98"""
100import numpy as np
101import numpy.typing as npt
102import pandas as pd
103from scipy.stats import gamma as gamma_dist
105from gwtransport._time import tedges_to_days
106from gwtransport.gamma import parse_parameters
107from gwtransport.utils import cumulative_flow_volume, linear_interpolate
109# Relative slack on the covered-fraction spin-up gate. The covered sub-mass ``den`` equals its
110# fully-covered reference only up to summation-reassociation ulps, so an exact ``den >= threshold *
111# reference`` comparison spuriously rejects fully-covered bins at the strictest threshold (1.0). The
112# slack is far above that float noise (band widths reach ~1e4 pieces, ~1e-12 relative) yet negligible
113# as a physical coverage tolerance.
114_SPINUP_GATE_RELTOL = 1e-9
117def _resolve_spinup(spinup: str | float | None) -> tuple[bool, float]:
118 """Normalize the residence-time ``spinup`` policy to ``(extrapolate, threshold)``.
120 ``'constant'`` -> ``(True, 0.0)``, ``None`` -> ``(False, 0.0)``, and a ``float`` in ``[0, 1]`` ->
121 ``(False, float)``. See the module docstring (``Spin-up period``) for the public contract.
123 Returns
124 -------
125 extrapolate : bool
126 Whether to warm-start by extrapolating the boundary flow.
127 threshold : float
128 Covered-fraction gate applied where the pore-volume axis is collapsed (ignored by the
129 per-pore-volume :func:`full`, which is strict per bin).
131 Raises
132 ------
133 ValueError
134 If ``spinup`` is not ``'constant'``, ``None``, or a float in ``[0, 1]``.
135 """
136 if spinup == "constant":
137 return True, 0.0
138 if spinup is None:
139 return False, 0.0
140 if isinstance(spinup, int | float) and not isinstance(spinup, bool) and 0.0 <= spinup <= 1.0:
141 return False, float(spinup)
142 msg = "spinup should be 'constant', None, or a float in [0, 1]"
143 raise ValueError(msg)
146def _phi_setup(
147 flow: npt.NDArray[np.floating],
148 flow_cum: npt.NDArray[np.floating],
149 tedges_days: npt.NDArray[np.floating],
150 *,
151 extrapolate: bool,
152 pad: float,
153) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating], npt.NDArray[np.floating], npt.NDArray[np.floating]]:
154 """Build the antiderivative ``phi(x) = int_0^x T(w) dw`` of the cumulative-volume -> time map ``T``.
156 ``T`` is piecewise-linear (knots at the cumulative-volume edges), so ``phi`` is piecewise-quadratic
157 with the same knots. With ``extrapolate`` the map is extended one anchor past each end of the record
158 at the boundary flow rate (padded by ``pad``) for the ``"constant"`` warm-start; otherwise it is the
159 raw record. Shared by :func:`full` and :func:`gamma`.
161 Returns
162 -------
163 phi_v : ndarray
164 Cumulative-volume knots of the map (extended by ``pad`` at each end when ``extrapolate``).
165 phi_t : ndarray
166 Time knots aligned with ``phi_v``.
167 phi_knot : ndarray
168 ``phi`` evaluated at each volume knot.
169 phi_rate : ndarray
170 Per-segment ``dV/dt`` (flow) rate between consecutive knots.
171 """
172 # pad == 0 (no spin-up reach, e.g. retardation_factor or all pore volumes 0) carries no
173 # extrapolation and would only add zero-width boundary segments (0/0 -> NaN phi_rate), so skip it.
174 if extrapolate and pad > 0.0 and np.any(flow > 0.0):
175 # The boundary extrapolation slope is 1/Q, anchored on the nearest strictly-positive flow so
176 # a zero-flow boundary bin (whose flow_cum step is only the strictly-monotone ulp bump) does
177 # not give a 1/0 extrapolation.
178 positive_flow = flow[flow > 0.0]
179 phi_v = np.concatenate([[flow_cum[0] - pad], flow_cum, [flow_cum[-1] + pad]])
180 phi_t = np.concatenate([
181 [tedges_days[0] - pad * (1.0 / positive_flow[0])],
182 tedges_days,
183 [tedges_days[-1] + pad * (1.0 / positive_flow[-1])],
184 ])
185 else:
186 phi_v, phi_t = flow_cum, tedges_days
187 phi_dv = phi_v[1:] - phi_v[:-1]
188 phi_rate = phi_dv / (phi_t[1:] - phi_t[:-1])
189 phi_knot = np.concatenate([[0.0], np.cumsum(phi_t[:-1] * phi_dv + phi_dv**2 / (2 * phi_rate))])
190 return phi_v, phi_t, phi_knot, phi_rate
193def _eval_phi(
194 x: npt.NDArray[np.floating],
195 phi_v: npt.NDArray[np.floating],
196 phi_t: npt.NDArray[np.floating],
197 phi_knot: npt.NDArray[np.floating],
198 phi_rate: npt.NDArray[np.floating],
199 *,
200 strict_nan: bool = False,
201) -> npt.NDArray[np.floating]:
202 """Evaluate the piecewise-quadratic antiderivative ``phi`` from :func:`_phi_setup` at ``x``.
204 ``x`` is clipped to the map range ``[phi_v[0], phi_v[-1]]`` (the warm-start extrapolation lives in
205 that range when padded). With ``strict_nan`` any ``x`` outside the range returns ``NaN`` instead --
206 used by :func:`full` so an output bin whose parcel leaves the record is ``NaN``.
208 Returns
209 -------
210 ndarray
211 ``phi`` evaluated at ``x``, with the same shape as ``x``.
212 """
213 x = np.asarray(x, dtype=float)
214 xc = np.clip(x, phi_v[0], phi_v[-1])
215 j = np.clip(np.searchsorted(phi_v, xc, side="right") - 1, 0, len(phi_rate) - 1)
216 dv = xc - phi_v[j]
217 out = phi_knot[j] + phi_t[j] * dv + dv * dv / (2 * phi_rate[j])
218 if strict_nan:
219 out = np.where((x < phi_v[0]) | (x > phi_v[-1]), np.nan, out)
220 return out
223def full(
224 *,
225 flow: npt.ArrayLike,
226 tedges: pd.DatetimeIndex | np.ndarray,
227 cout_tedges: pd.DatetimeIndex | np.ndarray,
228 aquifer_pore_volumes: npt.ArrayLike,
229 direction: str = "extraction_to_infiltration",
230 retardation_factor: float = 1.0,
231 spinup: str | float | None = "constant",
232) -> npt.NDArray[np.floating]:
233 r"""
234 Compute the mean residence time over output bins, per pore volume.
236 The flow-weighted mean residence time is computed over each output interval
237 ``[cout_tedges[i], cout_tedges[i + 1])`` and returned as the full
238 ``(n_pore_volumes, n_output_bins)`` array -- one row per entry in
239 ``aquifer_pore_volumes``, without collapsing the pore-volume axis. The average is uniform in
240 cumulative throughflow volume, matching the package's bin-edge convention.
242 Parameters
243 ----------
244 flow : array-like
245 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one.
246 tedges : pandas.DatetimeIndex
247 Time edges for the flow data, as datetime64 objects, defining the flow intervals.
248 cout_tedges : pandas.DatetimeIndex
249 Output time edges as datetime64 objects; ``n + 1`` edges define ``n`` output bins.
250 aquifer_pore_volumes : float or array-like
251 Pore volume(s) of the aquifer [m³]. A single value or an array of pore volumes
252 representing different flow paths.
253 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional
254 Direction of the flow calculation:
256 * 'extraction_to_infiltration':
257 Extraction to infiltration modeling - how many days ago was the extracted water infiltrated.
258 * 'infiltration_to_extraction':
259 Infiltration to extraction modeling - how many days until the infiltrated water is extracted.
261 Default is 'extraction_to_infiltration'.
262 retardation_factor : float, optional
263 Retardation factor of the compound in the aquifer [dimensionless]. A value greater
264 than 1.0 indicates the compound moves slower than water. Default is 1.0.
265 spinup : {'constant'}, None, or float in [0, 1], optional
266 How to treat the spin-up zone, where a pore volume's retarded look-back/forward parcel
267 leaves the flow record. Matches the package convention (see :mod:`gwtransport.advection`).
269 * ``'constant'`` (default): warm-start -- extrapolate the cumulative-volume-to-time map past
270 the record at the boundary flow rates (flow held constant at its first/last value), so
271 the residence time stays finite. No left-edge (extraction) or right-edge (infiltration)
272 spin-up ``NaN``.
273 * ``None`` or a ``float`` in ``[0, 1]``: strict -- a pore volume whose parcel leaves the
274 record at any point within an output bin is ``NaN`` for that bin (all-or-nothing per bin),
275 with no extrapolation. This function returns the full per-pore-volume array, so there is no
276 pore-volume axis to collapse; the ``float`` covered-fraction threshold therefore behaves
277 identically to ``None`` here and only takes effect once the axis is collapsed in
278 :func:`mean` / :func:`gamma`.
280 Output bins lying wholly outside ``tedges`` are ``NaN`` under either policy.
282 Returns
283 -------
284 numpy.ndarray
285 Mean residence time [days], shape ``(n_pore_volumes, n_output_bins)``. The first
286 dimension corresponds to the pore volumes and the second to the ``cout_tedges`` bins.
287 Negative or ``NaN`` ``flow`` makes the cumulative-volume map non-monotone or undefined; the
288 whole array is returned as ``NaN`` (the function refuses rather than raising).
290 Raises
291 ------
292 ValueError
293 If ``tedges`` does not have exactly one more element than ``flow``. If
294 ``direction`` is not ``'extraction_to_infiltration'`` or
295 ``'infiltration_to_extraction'``. If ``spinup`` is not ``'constant'``, ``None``, or a float
296 in ``[0, 1]``.
298 See Also
299 --------
300 fraction_explained_full : Advective fraction of each output bin explained, per pore volume
301 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction
302 :ref:`concept-transport-equation` : Flow-weighted averaging convention
304 Notes
305 -----
306 With the default ``spinup='constant'`` the spin-up zone is warm-started by extrapolating the
307 boundary flow, so no in-record bin is ``NaN``; use :func:`fraction_explained_mean` (or
308 ``spinup=None``) to locate the spin-up region. See the module docstring (``Spin-up period``)
309 for the full rule.
311 The single-streamtube residence time :math:`\tau(V) = \mathrm{sign}\,[T(V + \mathrm{sign}\,R V_p)
312 - T(V)]` is piecewise-linear in cumulative throughflow volume :math:`V` (:math:`T` is the
313 volume :math:`\to` time map, :math:`\mathrm{sign} = -1` for ``extraction_to_infiltration`` and
314 :math:`+1` for ``infiltration_to_extraction``). Its flow-weighted bin average is therefore a
315 closed-form difference of the antiderivative :math:`\Phi(x) = \int_0^x T(w)\,dw` (piecewise-
316 quadratic), evaluated at four points per pore volume and output bin:
318 .. math::
320 \bar\tau
321 = \frac{1}{\Delta V}\int_{V_\mathrm{lo}}^{V_\mathrm{hi}} \tau(V)\,dV
322 = \frac{\mathrm{sign}}{\Delta V}\bigl[
323 \Phi(V_\mathrm{hi} + \mathrm{sign}\,R V_p) - \Phi(V_\mathrm{lo} + \mathrm{sign}\,R V_p)
324 - \Phi(V_\mathrm{hi}) + \Phi(V_\mathrm{lo})\bigr],
326 where :math:`V` is cumulative throughflow volume (:math:`dV = Q\,dt`). This avoids materialising a
327 per-streamtube integration grid, so memory and time scale as the output size
328 :math:`O(n_\mathrm{pore\ volumes}\cdot n_\mathrm{bins})`. A zero-throughflow output bin
329 (:math:`\Delta V \to 0`) has a fixed volume while output time advances, so it degenerates to the
330 pointwise residence time at the bin's time midpoint.
332 Examples
333 --------
334 >>> import pandas as pd
335 >>> import numpy as np
336 >>> from gwtransport.residence_time import full
337 >>> flow_dates = pd.date_range(start="2023-01-01", end="2023-01-10", freq="D")
338 >>> flow_values = np.full(len(flow_dates) - 1, 100.0) # Constant flow of 100 m³/day
339 >>> mean_times = full(
340 ... flow=flow_values,
341 ... tedges=flow_dates,
342 ... cout_tedges=flow_dates,
343 ... aquifer_pore_volumes=200.0,
344 ... direction="extraction_to_infiltration",
345 ... )
346 >>> # 200 m³ / 100 m³/day = 2 days residence time; the default constant warm-start
347 >>> # extrapolates the boundary flow, so the left-edge spin-up bins are also 2 days
348 >>> print(mean_times) # doctest: +NORMALIZE_WHITESPACE
349 [[2. 2. 2. 2. 2. 2. 2. 2. 2.]]
350 """
351 if direction not in {"extraction_to_infiltration", "infiltration_to_extraction"}:
352 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'"
353 raise ValueError(msg)
354 extrapolate, _ = _resolve_spinup(spinup)
356 aquifer_pore_volumes = np.atleast_1d(aquifer_pore_volumes)
357 tedges = pd.DatetimeIndex(tedges)
358 cout_tedges = pd.DatetimeIndex(cout_tedges)
359 flow = np.asarray(flow, dtype=float)
360 n_pv = len(aquifer_pore_volumes)
361 n_out = len(cout_tedges) - 1
363 if len(tedges) != len(flow) + 1:
364 msg = "tedges must have one more element than flow"
365 raise ValueError(msg)
366 if np.any(flow < 0) or np.any(np.isnan(flow)):
367 return np.full((n_pv, n_out), np.nan)
369 tedges_days = tedges_to_days(tedges)
370 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0])
371 # Plateaus in flow_cum from Q = 0 bins make the V -> t inversion multi-valued; bump duplicates by
372 # the smallest representable amount so the inverse map is single-valued.
373 flow_cum = cumulative_flow_volume(flow, np.diff(tedges_days), strictly_monotone=True)
375 # Sign convention: sign = -1 for extraction_to_infiltration, +1 for infiltration_to_extraction;
376 # the look-back/forward parcel sits at volume V + shift and tau(V) = sign * (T(V + shift) - T(V)) is
377 # piecewise-linear in V (T is the volume -> time map). Its flow-weighted bin average is a closed-
378 # form difference of an antiderivative phi with phi' = T (any additive constant cancels in the
379 # difference below; over the extrapolated map T is the extended map), so no per-streamtube
380 # integration grid is built (memory/time O(n_pore_volumes * n_bins), not O(n_pore_volumes^2 * n_flow)).
381 sign = -1.0 if direction == "extraction_to_infiltration" else 1.0
382 shift = sign * retardation_factor * aquifer_pore_volumes # (n_pv,)
384 # phi over the cumulative-volume -> time map. With spinup="constant" the map is extrapolated past
385 # the record at the boundary flow (padded by the largest reach R * max(V_p)) so phi warm-starts the
386 # spin-up; otherwise phi is NaN outside the record so a bin whose parcel leaves it becomes NaN.
387 pad = retardation_factor * float(aquifer_pore_volumes.max()) if aquifer_pore_volumes.size else 0.0
388 phi_v, phi_t, phi_knot, phi_rate = _phi_setup(flow, flow_cum, tedges_days, extrapolate=extrapolate, pad=pad)
389 # The map is only actually extended when there is a positive boundary flow to extrapolate; with
390 # all-zero flow (or spinup=None) it stays the raw record, so out-of-record look-backs are NaN.
391 strict = not (extrapolate and bool(np.any(flow > 0.0)))
393 vol_out = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days, left=np.nan, right=np.nan)
394 v_lo = vol_out[:-1]
395 v_hi = vol_out[1:]
396 dvol = v_hi - v_lo
397 bins_within = np.isfinite(v_lo) & np.isfinite(v_hi)
399 phi_base = _eval_phi(vol_out, phi_v, phi_t, phi_knot, phi_rate, strict_nan=strict) # (n_out + 1,)
400 phi_shift = _eval_phi(vol_out[None, :] + shift[:, None], phi_v, phi_t, phi_knot, phi_rate, strict_nan=strict)
402 tol = 1e6 * np.spacing(float(np.max(np.abs(flow_cum)))) if flow_cum.size else 0.0
403 with np.errstate(divide="ignore", invalid="ignore"):
404 result = (
405 sign * ((phi_shift[:, 1:] - phi_shift[:, :-1]) - (phi_base[1:] - phi_base[:-1])[None, :]) / dvol[None, :]
406 )
407 # Zero-throughflow output bin (dvol -> 0): the volume is fixed while output time advances, so the
408 # flow-weighted average degenerates to the pointwise tau at the bin time midpoint, sign*(T(V_lo +
409 # shift) - t_mid). A direct ratio there would catastrophically cancel. Only in-record zero-flow
410 # bins reach it, so skip the interp entirely when none are present.
411 degenerate = bins_within & (dvol <= tol)
412 if np.any(degenerate):
413 t_mid = 0.5 * (cout_tedges_days[:-1] + cout_tedges_days[1:])
414 nan_outside = np.nan if strict else None
415 with np.errstate(divide="ignore", invalid="ignore"):
416 t_lookback = linear_interpolate(
417 x_ref=phi_v, y_ref=phi_t, x_query=v_lo[None, :] + shift[:, None], left=nan_outside, right=nan_outside
418 )
419 point = sign * (t_lookback - t_mid[None, :])
420 result = np.where(dvol[None, :] > tol, result, point)
421 return np.where(bins_within[None, :], result, np.nan)
424def mean(
425 *,
426 flow: npt.ArrayLike,
427 tedges: pd.DatetimeIndex | np.ndarray,
428 cout_tedges: pd.DatetimeIndex | np.ndarray,
429 aquifer_pore_volumes: npt.ArrayLike,
430 direction: str = "extraction_to_infiltration",
431 retardation_factor: float = 1.0,
432 spinup: str | float | None = "constant",
433) -> npt.NDArray[np.floating]:
434 r"""
435 Compute the mean residence time over output bins for a discrete APVD.
437 The mean is taken over a **discrete** set of equally-weighted aquifer pore volumes -- one
438 streamtube per entry in ``aquifer_pore_volumes``. Each streamtube's flow-weighted bin average
439 is computed with :func:`full` and the pore-volume axis is then collapsed to a
440 single per-output-bin series by averaging over the streamtubes that are valid in each bin. For
441 a continuous (shifted) gamma pore-volume distribution evaluated in closed form, use
442 :func:`gamma`.
444 The mean is over the valid streamtubes,
446 .. math::
448 \bar\tau_b = \frac{1}{|V_b|}\sum_{i \in V_b} \tau_{i,b},
449 \qquad V_b = \{\, i : \tau_{i,b}\ \mathrm{finite} \,\}.
451 With the default ``spinup='constant'`` every streamtube is finite within the flow record
452 (the boundary flow is extrapolated), so this is simply the mean over all pore volumes; with
453 ``spinup=None`` it renormalizes over the streamtubes that have broken through.
455 Parameters
456 ----------
457 flow : array-like
458 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one.
459 tedges : pandas.DatetimeIndex
460 Time edges for the flow data, as datetime64 objects, defining the flow intervals.
461 cout_tedges : pandas.DatetimeIndex
462 Output time edges as datetime64 objects; ``n + 1`` edges define ``n`` output bins.
463 aquifer_pore_volumes : array-like
464 Discrete pore volumes [m³], one per (equally-weighted) streamtube. A single value
465 collapses to the per-streamtube mean of :func:`full`.
466 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional
467 Direction of the flow calculation:
468 * 'extraction_to_infiltration': how many days ago was the extracted water infiltrated
469 * 'infiltration_to_extraction': how many days until the infiltrated water is extracted
470 Default is 'extraction_to_infiltration'.
471 retardation_factor : float, optional
472 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.
473 spinup : {'constant'}, None, or float in [0, 1], optional
474 Spin-up policy, sharing the contract of :func:`gamma`. ``'constant'``
475 (default) warm-starts by extrapolating the boundary flow so no in-record bin is ``NaN``;
476 ``None`` leaves spin-up streamtubes ``NaN`` and the mean renormalizes over those that have
477 broken through (emitted wherever at least one streamtube is valid). A ``float`` in
478 ``[0, 1]`` is the covered-fraction threshold: the renormalized mean is emitted only where
479 the fraction of valid streamtubes is at least ``spinup`` (``0.0`` matches ``None``; ``1.0``
480 demands every streamtube; larger values demand more streamtubes to have broken through). Use
481 :func:`fraction_explained_mean` to
482 locate the spin-up region.
484 Returns
485 -------
486 numpy.ndarray
487 Mean residence time [days], shape ``(n_output_bins,)``. Output bins with no valid
488 streamtube (outside the flow record, or -- with ``spinup=None`` -- fully in the spin-up
489 zone) are NaN; with a ``float`` ``spinup`` so are bins whose valid-streamtube fraction is
490 below the threshold. Negative or ``NaN`` ``flow`` makes the cumulative-volume map non-monotone
491 or undefined; the whole series is returned as ``NaN`` (the function refuses rather than raising).
493 See Also
494 --------
495 gamma : Exact closed-form mean for a continuous (shifted) gamma APVD
496 full : Per-pore-volume mean residence time over output bins
497 fraction_explained_mean : Advective fraction of each output bin explained by the record
498 gwtransport.gamma.bins : Discretize a gamma APVD into pore-volume bins
499 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction
501 Notes
502 -----
503 With ``spinup=None`` the spin-up is **all-or-nothing per streamtube**: a streamtube whose
504 look-back/forward parcel leaves the flow record part-way through an output bin has a ``NaN`` bin
505 average (inherited from :func:`full`) and is dropped from that bin's mean
506 entirely, rather than contributing its partially-covered share; the bin is ``NaN`` only once
507 every streamtube is in spin-up. In that mode the discrete mean differs from
508 :func:`gamma`, which renormalizes over the covered sub-mass exactly. See the
509 module docstring (``Spin-up period``) for the full rule.
511 Examples
512 --------
513 >>> import pandas as pd
514 >>> import numpy as np
515 >>> from gwtransport.residence_time import mean
516 >>> flow_dates = pd.date_range(start="2023-01-01", end="2023-02-10", freq="D")
517 >>> flow_values = np.full(len(flow_dates) - 1, 100.0) # 100 m³/day
518 >>> tau_bar = mean(
519 ... flow=flow_values,
520 ... tedges=flow_dates,
521 ... cout_tedges=flow_dates,
522 ... aquifer_pore_volumes=[400.0, 600.0], # two equally-weighted streamtubes
523 ... )
524 >>> # Deep in the record: mean pore volume 500 / 100 m³/day = 5 days
525 >>> float(np.round(tau_bar[-1], 6))
526 5.0
527 """
528 _, threshold = _resolve_spinup(spinup)
529 rt = full(
530 flow=flow,
531 tedges=tedges,
532 cout_tedges=cout_tedges,
533 aquifer_pore_volumes=aquifer_pore_volumes,
534 direction=direction,
535 retardation_factor=retardation_factor,
536 spinup=spinup,
537 )
539 # Mean over the streamtubes that are valid (non-NaN) in each output bin; bins with no valid
540 # streamtube reduce to 0/0 and are NaN. With spinup='constant' every in-record streamtube is
541 # finite, so this is the plain mean; otherwise it renormalizes over the broken-through set and
542 # a float covered-fraction threshold further NaNs bins where too few streamtubes have arrived.
543 n_streamtubes = rt.shape[0]
544 valid_count = np.isfinite(rt).sum(axis=0)
545 with np.errstate(invalid="ignore"):
546 bin_mean = np.nansum(rt, axis=0) / valid_count
547 if threshold > 0.0:
548 bin_mean = np.where(valid_count >= threshold * n_streamtubes, bin_mean, np.nan)
549 return bin_mean
552def gamma(
553 *,
554 flow: npt.ArrayLike,
555 tedges: pd.DatetimeIndex | np.ndarray,
556 cout_tedges: pd.DatetimeIndex | np.ndarray,
557 mean: float | None = None,
558 std: float | None = None,
559 loc: float = 0.0,
560 alpha: float | None = None,
561 beta: float | None = None,
562 direction: str = "extraction_to_infiltration",
563 retardation_factor: float = 1.0,
564 spinup: str | float | None = "constant",
565 _max_tile_elements: int = 1_000_000,
566) -> npt.NDArray[np.floating]:
567 r"""
568 Compute the mean residence time over output bins for a (shifted) gamma APVD.
570 The expectation over a (shifted) gamma aquifer pore-volume distribution (APVD),
571 parameterized by either ``(mean, std, loc)`` or ``(alpha, beta, loc)``, is taken in closed
572 form -- no pore-volume binning and no ``n_bins`` accuracy/cost knob. The bin mean is
573 flow-weighted (uniform in cumulative volume), matching the bin-edge convention of the
574 package, and a single per-output-bin series is returned.
576 The single-streamtube residence time is piecewise-linear in the pore volume :math:`V_p`, so
577 its per-bin time integral :math:`G_b(V_p) = \int_{\mathrm{bin}} \tau\,dV` is piecewise-
578 quadratic in :math:`V_p` and the covered length :math:`L_b(V_p)` piecewise-linear. The bin
579 mean is the ratio of two closed-form integrals against the gamma density -- its zeroth,
580 first and second partial moments (regularized incomplete gamma) -- formed once after
581 integrating. The ``spinup`` policy sets what happens where part of the APVD lacks flow
582 history: ``'constant'`` (default) extrapolates the boundary flow over the full distribution
583 (the package default warm-start), while a ``float`` threshold renormalizes the mean over the
584 covered sub-mass (``0.0`` reproduces the exact covered-sub-mass conditional mean).
586 Parameters
587 ----------
588 flow : array-like
589 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one.
590 tedges : pandas.DatetimeIndex
591 Time edges for the flow data, as datetime64 objects, defining the flow intervals.
592 cout_tedges : pandas.DatetimeIndex
593 Output time edges as datetime64 objects; ``n + 1`` edges define ``n`` output bins.
594 mean : float, optional
595 Mean of the gamma APVD [m³]. Must be strictly greater than ``loc``. Provide either
596 ``(mean, std)`` or ``(alpha, beta)``.
597 std : float, optional
598 Standard deviation of the gamma APVD [m³]. Must be positive.
599 loc : float, optional
600 Location (lower bound of support) of the gamma APVD [m³]; a guaranteed minimum pore
601 volume. Must satisfy ``0 <= loc < mean``. Default is 0.0.
602 alpha : float, optional
603 Shape parameter of the gamma APVD (must be > 0).
604 beta : float, optional
605 Scale parameter of the gamma APVD (must be > 0).
606 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional
607 Direction of the flow calculation:
608 * 'extraction_to_infiltration': how many days ago was the extracted water infiltrated
609 * 'infiltration_to_extraction': how many days until the infiltrated water is extracted
610 Default is 'extraction_to_infiltration'.
611 retardation_factor : float, optional
612 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.
613 spinup : {'constant'}, None, or float in [0, 1], optional
614 How to treat the spin-up zone, where part of the gamma APVD lacks flow history. Matches
615 the package convention (see :mod:`gwtransport.advection`).
617 * ``'constant'`` (default): warm-start -- extrapolate the cumulative-volume-to-time map past
618 the record at the boundary flow rates (flow held constant at its first/last value) and
619 integrate the full distribution, so no in-record bin is ``NaN``.
620 * ``None`` or a ``float`` in ``[0, 1]``: renormalize the mean over the covered sub-mass,
621 emitting a bin only where the covered fraction of the distribution is at least the
622 threshold. ``None`` and ``0.0`` both give the exact covered-sub-mass conditional mean
623 (emit whenever any sub-mass is covered); larger values demand a larger covered fraction,
624 and ``1.0`` requires the full distribution to be covered.
626 Output bins lying wholly outside ``tedges`` are ``NaN`` under either policy.
628 Returns
629 -------
630 numpy.ndarray
631 APVD-mean residence time [days], shape ``(n_output_bins,)``. Output bins outside the flow
632 record are NaN; with a ``float`` ``spinup`` so are bins whose covered fraction is below the
633 threshold. Negative or ``NaN`` ``flow`` makes the cumulative-volume map non-monotone or
634 undefined; the whole series is returned as ``NaN`` (the function refuses rather than raising).
636 Raises
637 ------
638 ValueError
639 If ``tedges`` does not have exactly one more element than ``flow``. If ``direction``
640 is not ``'extraction_to_infiltration'`` or ``'infiltration_to_extraction'``. If ``spinup``
641 is not ``'constant'``, ``None``, or a float in ``[0, 1]``. Gamma parameter validation is
642 delegated to :func:`gwtransport.gamma.parse_parameters`.
644 See Also
645 --------
646 mean : Equally-weighted mean for a discrete set of pore volumes
647 full : Per-pore-volume mean residence time over output bins
648 fraction_explained_mean : Advective fraction of each output bin explained by the record
649 gwtransport.gamma.bins : Discretize a gamma APVD into pore-volume bins
650 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction
651 :ref:`concept-gamma-distribution` : Two-parameter pore volume model
653 Notes
654 -----
655 With the default ``spinup='constant'`` the spin-up is warm-started exactly as in
656 :func:`mean` (constant-boundary-flow extrapolation) -- the same warm-start policy applies across
657 the whole distribution (``mean`` discretizes it, ``gamma`` integrates it in closed form). With
658 ``spinup=0.0`` the spin-up is instead handled by exact covered-sub-mass renormalization: each
659 output bin integrates over only the pore-volume sub-range with sufficient flow history. See the
660 module docstring (``Spin-up period``) for the full rule.
662 The closed form uses regularized incomplete-gamma CDFs. For an extremely narrow APVD
663 (``alpha = (mean / std) ** 2`` above ~1e7, i.e. ``std / mean`` below ~3e-4) SciPy's incomplete
664 gamma loses precision in its far tail and the result degrades to ~1e-5 relative; such a
665 distribution is effectively a single pore volume, so :func:`mean` with one bin is the exact
666 alternative there.
668 Examples
669 --------
670 >>> import pandas as pd
671 >>> import numpy as np
672 >>> from gwtransport.residence_time import gamma
673 >>> flow_dates = pd.date_range(start="2023-01-01", end="2023-02-10", freq="D")
674 >>> flow_values = np.full(len(flow_dates) - 1, 100.0) # 100 m³/day
675 >>> tau_bar = gamma(
676 ... flow=flow_values,
677 ... tedges=flow_dates,
678 ... cout_tedges=flow_dates,
679 ... mean=500.0,
680 ... std=100.0,
681 ... direction="extraction_to_infiltration",
682 ... )
683 >>> # Deep in the record the mean residence time approaches mean / flow = 5 days
684 >>> float(np.round(tau_bar[-1], 6))
685 5.0
686 """
687 if direction not in {"extraction_to_infiltration", "infiltration_to_extraction"}:
688 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'"
689 raise ValueError(msg)
690 extrapolate, spinup_threshold = _resolve_spinup(spinup)
692 alpha, beta, loc = parse_parameters(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta)
694 flow = np.asarray(flow, dtype=float)
695 tedges = pd.DatetimeIndex(tedges)
696 cout_tedges = pd.DatetimeIndex(cout_tedges)
697 n_out = len(cout_tedges) - 1
699 if len(tedges) != len(flow) + 1:
700 msg = "tedges must have one more element than flow"
701 raise ValueError(msg)
702 if np.any(flow < 0) or np.any(np.isnan(flow)):
703 return np.full(n_out, np.nan)
705 sign = -1.0 if direction == "extraction_to_infiltration" else 1.0
706 r = retardation_factor
708 tedges_days = tedges_to_days(tedges)
709 flow_cum = cumulative_flow_volume(flow, np.diff(tedges_days), strictly_monotone=True)
710 v_end = flow_cum[-1]
711 n_edges = len(flow_cum)
713 # Finite support: drop the gamma tails (mass ~1e-13, far below the discretization error this
714 # closed form replaces). Restricting the integral to [support_lo, support_hi] is what keeps
715 # the per-bin flow-edge band -- and the gamma CDF evaluations -- bounded.
716 tail = 1e-13
717 support_lo = float(loc + gamma_dist.ppf(tail, alpha, scale=beta))
718 support_hi = float(loc + gamma_dist.ppf(1.0 - tail, alpha, scale=beta))
720 # phi(v) = int_0^v T(w) dw with T the inverse cumulative-volume map (piecewise-linear); phi is
721 # piecewise-quadratic with knots at the cumulative-volume edges, so the per-bin time integral of
722 # tau is a difference of phi at the look-back/forward limits. With spinup="constant" the map is
723 # extended past the record at the boundary flow rates (one anchor each end, padded by the largest
724 # reach r*support_hi) so phi extrapolates the spin-up; with a float spinup it stays clipped to
725 # [0, v_end] (the covered sub-mass only).
726 phi_v, phi_t, phi_knot, phi_rate = _phi_setup(
727 flow, flow_cum, tedges_days, extrapolate=extrapolate, pad=r * support_hi
728 )
730 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0])
731 vol_out = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days, left=np.nan, right=np.nan)
732 good_all = np.isfinite(vol_out[:-1]) & np.isfinite(vol_out[1:]) & (vol_out[1:] > vol_out[:-1])
733 if not np.any(good_all):
734 return np.full(n_out, np.nan)
735 v_lo_all = np.where(good_all, vol_out[:-1], 0.0)
736 v_hi_all = np.where(good_all, vol_out[1:], 1.0)
738 # Fixed band of flow edges each bin's look-back/forward sweep can cross over the supported
739 # pore volumes. band_width is the global maximum so every tile shares one column layout; a
740 # spurious column clips to the support and merely splits a quadratic piece without changing
741 # its integral, so the band is an exact superset.
742 a_min = v_lo_all - r * support_hi if sign < 0 else v_lo_all + r * support_lo
743 a_max = v_hi_all - r * support_lo if sign < 0 else v_hi_all + r * support_hi
744 jlo_all = np.clip(np.searchsorted(flow_cum, a_min, "left") - 1, 0, n_edges - 1)
745 jhi_all = np.clip(np.searchsorted(flow_cum, a_max, "right"), 0, n_edges - 1)
746 band_width = int((jhi_all - jlo_all).max()) + 1
747 band = np.arange(band_width)
749 # Tile over output bins to bound peak memory. Each bin carries ~3*band_width pieces through a
750 # (3 nodes, 3 phi-args) stack of 9 * n_pieces elements; size the tile to that element budget.
751 n_pieces = 3 * band_width + 3
752 tile = max(1, _max_tile_elements // (9 * n_pieces))
754 result = np.full(n_out, np.nan)
755 for t0 in range(0, n_out, tile):
756 t1 = min(t0 + tile, n_out)
757 nt = t1 - t0
758 good = good_all[t0:t1]
759 v_lo = v_lo_all[t0:t1]
760 v_hi = v_hi_all[t0:t1]
761 cols = np.clip(jlo_all[t0:t1, None] + band[None, :], 0, n_edges - 1)
762 fcb = flow_cum[cols]
763 vlo = v_lo[:, None]
764 vhi = v_hi[:, None]
766 # Direction-specific breakpoint families: the V_p values where a phi argument (a clipped
767 # look-back/forward limit) crosses a flow edge or a validity bound. Clip to the support
768 # and sort to form the integration pieces (zero-width pieces contribute nothing).
769 if sign < 0:
770 cand = np.concatenate([(vhi - fcb) / r, (vlo - fcb) / r, fcb / r, vlo / r, vhi / r], axis=1)
771 else:
772 cand = np.concatenate(
773 [(fcb - vlo) / r, (fcb - vhi) / r, (v_end - fcb) / r, (v_end - vlo) / r, (v_end - vhi) / r], axis=1
774 )
775 np.clip(cand, support_lo, support_hi, out=cand)
776 cand.sort(axis=1)
777 edges = np.concatenate([np.full((nt, 1), support_lo), cand, np.full((nt, 1), support_hi)], axis=1)
778 lo = edges[:, :-1]
779 hi = edges[:, 1:]
780 mid = 0.5 * (lo + hi)
781 half = 0.5 * (hi - lo)
782 nodes = np.stack([lo, mid, hi], axis=0) # (3, nt, n_pieces)
784 # G(V_p) at the three quadrature nodes (piece lo/mid/hi) as a difference of phi. The constant
785 # term phi(v_hi)/phi(v_lo) does not depend on V_p, so evaluate it once per bin and batch only
786 # the three V_p-dependent phi arguments. With spinup="constant" the full bin is integrated
787 # against the extrapolated phi (length is the full bin width); with a float spinup the limits
788 # clamp to the streamtube's covered sub-interval and the covered length renormalizes.
789 if sign < 0:
790 a_hi = vhi[None] - r * nodes
791 if extrapolate:
792 v_start = np.broadcast_to(vlo[None], a_hi.shape)
793 a_lo = vlo[None] - r * nodes
794 length = np.broadcast_to(vhi[None] - vlo[None], a_hi.shape)
795 else:
796 v_start = np.maximum(vlo[None], r * nodes)
797 a_lo = np.maximum(vlo[None] - r * nodes, 0.0)
798 length = np.maximum(vhi[None] - v_start, 0.0)
799 phi_const = _eval_phi(v_hi, phi_v, phi_t, phi_knot, phi_rate)
800 phi_stack = _eval_phi(np.stack([v_start, a_hi, a_lo]), phi_v, phi_t, phi_knot, phi_rate)
801 g = phi_const[None, :, None] - phi_stack[0] - phi_stack[1] + phi_stack[2]
802 else:
803 a_lo = vlo[None] + r * nodes
804 if extrapolate:
805 v_stop = np.broadcast_to(vhi[None], a_lo.shape)
806 a_hi = vhi[None] + r * nodes
807 length = np.broadcast_to(vhi[None] - vlo[None], a_lo.shape)
808 else:
809 v_stop = np.minimum(vhi[None], v_end - r * nodes)
810 a_hi = np.minimum(vhi[None] + r * nodes, v_end)
811 length = np.maximum(v_stop - vlo[None], 0.0)
812 phi_const = _eval_phi(v_lo, phi_v, phi_t, phi_knot, phi_rate)
813 phi_stack = _eval_phi(np.stack([a_hi, a_lo, v_stop]), phi_v, phi_t, phi_knot, phi_rate)
814 g = phi_stack[0] - phi_stack[1] - phi_stack[2] + phi_const[None, :, None]
815 g = np.where(length > 0, g, 0.0)
816 length = np.where(length > 0, length, 0.0)
817 g_lo, g_mid, g_hi = g
818 l_lo, l_mid, l_hi = length
820 # Gamma partial moments over each piece: one CDF per shape on the piece edges, then diff.
821 # M1/M2 follow from the shifted-gamma partial-moment identities.
822 cdf_edges = edges - loc
823 f0 = gamma_dist.cdf(cdf_edges, alpha, scale=beta)
824 f1 = gamma_dist.cdf(cdf_edges, alpha + 1, scale=beta)
825 f2 = gamma_dist.cdf(cdf_edges, alpha + 2, scale=beta)
826 m0 = np.diff(f0, axis=1)
827 d1 = np.diff(f1, axis=1)
828 m1 = alpha * beta * d1 + loc * m0
829 d2 = np.diff(f2, axis=1)
830 m2 = alpha * (alpha + 1) * beta**2 * d2 + 2 * loc * alpha * beta * d1 + loc * loc * m0
832 # Piece-centred gamma moments mu_k = int (x - mid)^k f over each piece. They are formed by
833 # shifting the raw partial moments (m1 - mid m0, m2 - 2 mid m1 + mid^2 m0), which for R > 1
834 # subtracts large near-equal terms (|mid| up to support_hi, with m1/m2 carrying the matching
835 # mid powers) and catastrophically cancels on near-degenerate pieces. They are, however,
836 # bounded purely by the piece geometry -- |x - mid| <= half over the piece -- so clip each to
837 # its exact range. This is not a strict no-op on well-conditioned pieces (a raw value may sit
838 # a rounding step outside the tight bound; clamping it back is a negligible correction). It
839 # earns its place on the near-degenerate pieces, where the cancellation noise -- which
840 # otherwise pairs with the 1/half^2 second-difference below to manufacture a spurious
841 # contribution -- is forced back into the physically valid band.
842 b0 = np.maximum(m0, 0.0)
843 mu1 = np.clip(m1 - mid * m0, -half * b0, half * b0)
844 mu2 = np.clip(m2 - 2 * mid * m1 + mid**2 * m0, 0.0, half**2 * b0)
846 # G is quadratic per piece, L linear; reconstruct each from its three nodes (Lagrange-
847 # exact) centred at mid and contract with the moments: int (a(x-mid)^2 + b(x-mid) + c) f
848 # = a mu2 + b mu1 + c mu0. Zero-width pieces (duplicate breakpoints) divide by half == 0;
849 # their masked result is discarded by np.where.
850 safe = half > 0
851 with np.errstate(divide="ignore", invalid="ignore"):
852 g_a = np.where(safe, (g_lo - 2 * g_mid + g_hi) / (2 * half**2), 0.0)
853 g_b = np.where(safe, (g_hi - g_lo) / (2 * half), 0.0)
854 l_b = np.where(safe, (l_hi - l_lo) / (2 * half), 0.0)
855 int_g = g_a * mu2 + g_b * mu1 + g_mid * m0
856 int_l = l_b * mu1 + l_mid * m0
858 num = int_g.sum(axis=1)
859 den = int_l.sum(axis=1)
860 covered = good & (den > 0)
861 if spinup_threshold > 0.0:
862 # float spinup: emit only where the covered fraction of the APVD reaches the threshold.
863 # den is the covered sub-mass; (v_hi - v_lo) * m0.sum is the fully-covered reference (equal
864 # up to reassociation ulps when fully covered). The relative slack keeps the strictest
865 # threshold (1.0) from rejecting fully-covered bins on that float noise.
866 covered &= den >= (spinup_threshold - _SPINUP_GATE_RELTOL) * (v_hi - v_lo) * m0.sum(axis=1)
867 tile_result = np.full(nt, np.nan)
868 tile_result[covered] = num[covered] / den[covered]
869 result[t0:t1] = tile_result
871 # Zero-throughflow output bins (Q = 0 over the bin) have a cumulative-volume window only as wide
872 # as the strictly-monotone ulp bump, so the bin-average num/den ratio above catastrophically
873 # cancels. There the bin-average degenerates to its well-defined zero-width-bin limit: the
874 # pointwise gamma-mean residence time at the bin's cumulative volume (matching full).
875 dvol = vol_out[1:] - vol_out[:-1]
876 tol = 1e6 * np.spacing(float(np.max(np.abs(flow_cum)))) if flow_cum.size else 0.0
877 degenerate = good_all & (dvol <= tol)
878 if np.any(degenerate):
879 v = vol_out[:-1][degenerate] # (k,) bin cumulative volume (constant over a zero-flow bin)
880 # Over a zero-flow bin the volume is fixed but output time advances, so tau ramps linearly with
881 # output time; its bin-average is the value at the bin's time midpoint, not at T(v).
882 t_v = (0.5 * (cout_tedges_days[:-1] + cout_tedges_days[1:]))[degenerate]
883 # Upper V_p integration bound, mirroring the main loop's spin-up handling. With
884 # spinup="constant" the extrapolated phi map warm-starts the full support; otherwise a V_p
885 # whose look-back/forward parcel leaves the record (e2i: v - r*V_p < 0, i2e: v + r*V_p >
886 # v_end) is in spin-up, so integrate only the covered sub-range [support_lo, vp_hi] and let the
887 # covered sub-mass renormalize the mean (den_pt below), keeping the parcel inside the raw phi map.
888 # Warm-start the full support only when the phi map was actually extended -- i.e. there is a
889 # positive boundary flow to extrapolate from (matching full's `strict`). With all-zero flow the
890 # map stays the raw record (see _phi_setup), so an out-of-record parcel is in spin-up (-> NaN);
891 # the raw `extrapolate` flag alone would instead clamp it to a finite pointwise value.
892 if extrapolate and bool(np.any(flow > 0.0)):
893 vp_hi = np.full(v.shape, support_hi)
894 else:
895 vp_hi = np.clip((v if sign < 0 else v_end - v) / r, support_lo, support_hi)
896 # tau(v, V_p) = sign * (T(v + sign*r*V_p) - t_mid) is piecewise-linear in V_p with knots where
897 # v + sign*r*V_p crosses a phi_v edge; integrate it against the gamma density over the sub-range.
898 bp = np.clip(sign * (phi_v[None, :] - v[:, None]) / r, support_lo, vp_hi[:, None])
899 bp.sort(axis=1)
900 edges_pt = np.concatenate([np.full((v.size, 1), support_lo), bp, vp_hi[:, None]], axis=1)
901 lo_pt, hi_pt = edges_pt[:, :-1], edges_pt[:, 1:]
902 nodes_pt = np.stack([lo_pt, 0.5 * (lo_pt + hi_pt), hi_pt]) # (3, k, n_pieces)
903 a_pt = v[None, :, None] + sign * r * nodes_pt
904 tau_lo, tau_mid, tau_hi = sign * (
905 np.interp(a_pt.ravel(), phi_v, phi_t).reshape(a_pt.shape) - t_v[None, :, None]
906 )
907 width = np.where(hi_pt > lo_pt, hi_pt - lo_pt, 1.0)
908 slope = np.where(hi_pt > lo_pt, (tau_hi - tau_lo) / width, 0.0)
909 mid_pt = 0.5 * (lo_pt + hi_pt)
910 cdf_pt = edges_pt - loc
911 m0_pt = np.diff(gamma_dist.cdf(cdf_pt, alpha, scale=beta), axis=1)
912 m1_pt = alpha * beta * np.diff(gamma_dist.cdf(cdf_pt, alpha + 1, scale=beta), axis=1) + loc * m0_pt
913 # Clip the piece-centred first moment mu1 = int (V_p - mid) f to its exact geometric bound
914 # |mu1| <= half * m0, mirroring the main loop's mu1 clip above. On a piece whose V_p sweep
915 # crosses another zero-flow plateau, the raw (m1 - mid m0) subtracts large near-equal terms and
916 # catastrophically cancels; the large piecewise slope (~ r / ulp-bump) then amplifies the noise
917 # into a spurious residence-time contribution. Only mu1 is needed here: tau is piecewise-linear
918 # in V_p (no quadratic mu2 term).
919 b0_pt = np.maximum(m0_pt, 0.0)
920 half_pt = 0.5 * (hi_pt - lo_pt)
921 mu1_pt = np.clip(m1_pt - mid_pt * m0_pt, -half_pt * b0_pt, half_pt * b0_pt)
922 num_pt = (tau_mid * m0_pt + slope * mu1_pt).sum(axis=1)
923 den_pt = m0_pt.sum(axis=1) # covered sub-mass over [support_lo, vp_hi]
924 covered_pt = den_pt > 0
925 if spinup_threshold > 0.0:
926 # float spinup: emit only where the covered fraction reaches the threshold. den_pt is the
927 # covered sub-mass; the full-support mass is the fully-covered reference. The same relative
928 # slack as the main loop keeps threshold 1.0 robust to reassociation ulps in den_pt.
929 full_mass = gamma_dist.cdf(support_hi - loc, alpha, scale=beta) - gamma_dist.cdf(
930 support_lo - loc, alpha, scale=beta
931 )
932 covered_pt &= den_pt >= (spinup_threshold - _SPINUP_GATE_RELTOL) * full_mass
933 with np.errstate(divide="ignore", invalid="ignore"):
934 result[degenerate] = np.where(covered_pt, num_pt / den_pt, np.nan)
935 return result
938def fraction_explained_full(
939 *,
940 flow: npt.ArrayLike,
941 tedges: pd.DatetimeIndex | np.ndarray,
942 cout_tedges: pd.DatetimeIndex | np.ndarray,
943 aquifer_pore_volumes: npt.ArrayLike,
944 direction: str = "extraction_to_infiltration",
945 retardation_factor: float = 1.0,
946) -> npt.NDArray[np.floating]:
947 r"""
948 Advective coverage per pore volume: the fraction of each output bin explained by the record.
950 For each streamtube (entry in ``aquifer_pore_volumes``) and each output bin
951 ``[cout_tedges[i], cout_tedges[i + 1])`` this returns the flow-weighted fraction of the bin whose
952 retarded **advective** parcel lies inside the supplied flow record -- the share of the bin's
953 throughflow volume for which the look-back infiltration (``extraction_to_infiltration``) or
954 look-forward extraction (``infiltration_to_extraction``) event is covered by ``cin``. ``1.0``
955 means the whole bin is explained for that pore volume, ``0.0`` that none of it is. The full
956 ``(n_pore_volumes, n_output_bins)`` array is returned -- one row per pore volume, mirroring
957 :func:`full`.
959 .. warning::
961 This is a **purely advective** diagnostic: it uses only the cumulative-volume look-back
962 ``V(t) - retardation_factor * V_p`` and ignores molecular diffusion and longitudinal
963 dispersion. Those spread each output bin over a *range* of infiltration times whose kernel
964 tails extend outside any finite record, so a bin that is advectively "fully explained"
965 (``1.0``) is not fully informed once dispersion is present. For the dispersive informed
966 fraction of an advection-dispersion model use the captured kernel mass (the column sum of the
967 diffusion coefficient matrix), not this function.
969 Parameters
970 ----------
971 flow : array-like
972 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one.
973 tedges : pandas.DatetimeIndex
974 Time edges for the flow data; ``n + 1`` edges for ``n`` flow values.
975 cout_tedges : pandas.DatetimeIndex
976 Output time edges; ``n + 1`` edges define ``n`` output bins.
977 aquifer_pore_volumes : float or array-like
978 Pore volume(s) of the aquifer [m³], one per streamtube.
979 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional
980 Direction of the flow calculation. Default is 'extraction_to_infiltration'.
981 retardation_factor : float, optional
982 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.
984 Returns
985 -------
986 numpy.ndarray
987 Advective coverage [dimensionless], shape ``(n_pore_volumes, n_output_bins)``, values in
988 ``[0, 1]``. Output bins lying wholly outside ``tedges`` are ``NaN``. Negative or ``NaN``
989 ``flow`` makes the cumulative-volume map non-monotone or undefined; the whole array is
990 returned as ``NaN`` (the function refuses rather than raising).
992 Raises
993 ------
994 ValueError
995 If ``tedges`` does not have exactly one more element than ``flow``, or if ``direction`` is not
996 ``'extraction_to_infiltration'`` or ``'infiltration_to_extraction'``.
998 See Also
999 --------
1000 fraction_explained_mean : Equal-weight mean of this over a discrete APVD
1001 fraction_explained_gamma : Closed-form coverage for a (shifted) gamma APVD
1002 full : Per-pore-volume mean residence time over output bins
1003 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction
1004 """
1005 if direction not in {"extraction_to_infiltration", "infiltration_to_extraction"}:
1006 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'"
1007 raise ValueError(msg)
1009 aquifer_pore_volumes = np.atleast_1d(aquifer_pore_volumes)
1010 tedges = pd.DatetimeIndex(tedges)
1011 cout_tedges = pd.DatetimeIndex(cout_tedges)
1012 flow = np.asarray(flow, dtype=float)
1014 if len(tedges) != len(flow) + 1:
1015 msg = "tedges must have one more element than flow"
1016 raise ValueError(msg)
1018 n_out = len(cout_tedges) - 1
1019 # Negative or non-finite flow makes V(t) non-monotone or undefined; refuse to answer (match siblings).
1020 if np.any(flow < 0) or np.any(np.isnan(flow)):
1021 return np.full((len(aquifer_pore_volumes), n_out), np.nan)
1023 tedges_days = tedges_to_days(tedges)
1024 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0])
1025 flow_cum = cumulative_flow_volume(flow, np.diff(tedges_days))
1026 v_total = flow_cum[-1]
1028 vol_out = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days, left=np.nan, right=np.nan)
1029 v_lo = vol_out[:-1]
1030 v_hi = vol_out[1:]
1031 dvol = v_hi - v_lo
1032 r_vp = retardation_factor * aquifer_pore_volumes
1034 # The flow-weighted (uniform-in-volume) coverage of [v_lo, v_hi] is a clipped ramp of the
1035 # in-record indicator: e2i needs V >= R*V_p, i2e needs V <= v_total - R*V_p. A zero-throughflow
1036 # bin (dvol <= tol) has no volume to average over, so use the pointwise indicator at its volume.
1037 tol = 1e6 * np.spacing(float(np.max(np.abs(flow_cum)))) if flow_cum.size else 0.0
1038 with np.errstate(divide="ignore", invalid="ignore"):
1039 if direction == "extraction_to_infiltration":
1040 frac = np.clip((v_hi[None, :] - r_vp[:, None]) / dvol[None, :], 0.0, 1.0)
1041 point = (r_vp[:, None] <= v_lo[None, :]).astype(float)
1042 else:
1043 frac = np.clip(((v_total - r_vp[:, None]) - v_lo[None, :]) / dvol[None, :], 0.0, 1.0)
1044 point = (r_vp[:, None] <= v_total - v_lo[None, :]).astype(float)
1045 out = np.where(dvol[None, :] > tol, frac, point)
1046 out[:, ~(np.isfinite(v_lo) & np.isfinite(v_hi))] = np.nan
1047 return out
1050def fraction_explained_mean(
1051 *,
1052 flow: npt.ArrayLike,
1053 tedges: pd.DatetimeIndex | np.ndarray,
1054 cout_tedges: pd.DatetimeIndex | np.ndarray,
1055 aquifer_pore_volumes: npt.ArrayLike,
1056 direction: str = "extraction_to_infiltration",
1057 retardation_factor: float = 1.0,
1058) -> npt.NDArray[np.floating]:
1059 """
1060 Advective coverage for a discrete APVD: equal-weight mean of :func:`fraction_explained_full`.
1062 Collapses the pore-volume axis of :func:`fraction_explained_full` to a single per-output-bin
1063 series by averaging over the equally-weighted streamtubes in ``aquifer_pore_volumes`` -- the
1064 coverage analogue of :func:`mean`. ``1.0`` means every streamtube fully explains the
1065 bin, ``0.0`` that none do.
1067 .. warning::
1069 Purely advective -- see :func:`fraction_explained_full`. Molecular diffusion and longitudinal
1070 dispersion spreading are not captured, so a value of ``1.0`` is advective coverage, not full
1071 dispersive information.
1073 Parameters
1074 ----------
1075 flow : array-like
1076 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one.
1077 tedges : pandas.DatetimeIndex
1078 Time edges for the flow data; ``n + 1`` edges for ``n`` flow values.
1079 cout_tedges : pandas.DatetimeIndex
1080 Output time edges; ``n + 1`` edges define ``n`` output bins.
1081 aquifer_pore_volumes : array-like
1082 Discrete pore volumes [m³], one per (equally-weighted) streamtube.
1083 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional
1084 Direction of the flow calculation. Default is 'extraction_to_infiltration'.
1085 retardation_factor : float, optional
1086 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.
1088 Returns
1089 -------
1090 numpy.ndarray
1091 Advective coverage [dimensionless], shape ``(n_output_bins,)``, values in ``[0, 1]``.
1092 Output bins lying wholly outside ``tedges`` are ``NaN``. Negative or ``NaN`` ``flow`` makes
1093 the cumulative-volume map non-monotone or undefined; the whole series is returned as ``NaN``
1094 (the function refuses rather than raising).
1096 See Also
1097 --------
1098 fraction_explained_full : Per-pore-volume coverage (the array this averages)
1099 fraction_explained_gamma : Closed-form coverage for a (shifted) gamma APVD
1100 mean : Equally-weighted mean residence time for a discrete APVD
1102 Examples
1103 --------
1104 >>> import numpy as np
1105 >>> import pandas as pd
1106 >>> from gwtransport.residence_time import fraction_explained_mean
1107 >>> tedges = pd.date_range("2020-01-01", periods=11, freq="D")
1108 >>> flow = np.full(10, 100.0)
1109 >>> fraction_explained_mean(
1110 ... flow=flow,
1111 ... tedges=tedges,
1112 ... cout_tedges=tedges,
1113 ... aquifer_pore_volumes=[200.0, 1500.0],
1114 ... ).tolist()
1115 [0.0, 0.0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
1116 """
1117 return fraction_explained_full(
1118 flow=flow,
1119 tedges=tedges,
1120 cout_tedges=cout_tedges,
1121 aquifer_pore_volumes=aquifer_pore_volumes,
1122 direction=direction,
1123 retardation_factor=retardation_factor,
1124 ).mean(axis=0)
1127def fraction_explained_gamma(
1128 *,
1129 flow: npt.ArrayLike,
1130 tedges: pd.DatetimeIndex | np.ndarray,
1131 cout_tedges: pd.DatetimeIndex | np.ndarray,
1132 mean: float | None = None,
1133 std: float | None = None,
1134 loc: float = 0.0,
1135 alpha: float | None = None,
1136 beta: float | None = None,
1137 direction: str = "extraction_to_infiltration",
1138 retardation_factor: float = 1.0,
1139) -> npt.NDArray[np.floating]:
1140 r"""
1141 Closed-form advective coverage for a (shifted) gamma APVD.
1143 The expectation of the advective in-record indicator over a (shifted) gamma aquifer pore-volume
1144 distribution (APVD), parameterized by either ``(mean, std, loc)`` or ``(alpha, beta, loc)``, is
1145 taken in closed form -- the continuum analogue of :func:`fraction_explained_mean`, with no
1146 pore-volume binning. For each output bin it returns the flow-weighted fraction of the bin whose
1147 advective parcel lies inside the flow record.
1149 The flow-weighted bin average :math:`\frac{1}{\Delta V}\int_{V_\mathrm{lo}}^{V_\mathrm{hi}}
1150 F_{V_p}(\mathrm{threshold}(V))\,dV` (with :math:`\mathrm{threshold}(V) = V / R` for
1151 ``extraction_to_infiltration`` and :math:`(V_\mathrm{end} - V) / R` for
1152 ``infiltration_to_extraction``) is evaluated from the antiderivative of the shifted-gamma CDF,
1154 .. math::
1156 \Phi(x) = \int_\mathrm{loc}^{x} F_{V_p}(s)\,ds
1157 = y\,P(\alpha, y/\beta) - \alpha\beta\,P(\alpha + 1, y/\beta),
1158 \qquad y = \max(x - \mathrm{loc},\, 0),
1160 with :math:`P` the regularized lower incomplete gamma function -- two CDF evaluations per output
1161 edge, no quadrature and no pore-volume binning.
1163 .. warning::
1165 Purely advective -- see :func:`fraction_explained_full`. Molecular diffusion and longitudinal
1166 dispersion are not captured; a value of ``1.0`` is advective coverage, not full dispersive
1167 information.
1169 Parameters
1170 ----------
1171 flow : array-like
1172 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one.
1173 tedges : pandas.DatetimeIndex
1174 Time edges for the flow data; ``n + 1`` edges for ``n`` flow values.
1175 cout_tedges : pandas.DatetimeIndex
1176 Output time edges; ``n + 1`` edges define ``n`` output bins.
1177 mean : float, optional
1178 Mean of the gamma APVD [m³]. Must be strictly greater than ``loc``. Provide either
1179 ``(mean, std)`` or ``(alpha, beta)``.
1180 std : float, optional
1181 Standard deviation of the gamma APVD [m³]. Must be positive.
1182 loc : float, optional
1183 Location (lower bound of support) of the gamma APVD [m³]. Must satisfy ``0 <= loc < mean``.
1184 Default is 0.0.
1185 alpha : float, optional
1186 Shape parameter of the gamma APVD (must be > 0).
1187 beta : float, optional
1188 Scale parameter of the gamma APVD (must be > 0).
1189 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional
1190 Direction of the flow calculation. Default is 'extraction_to_infiltration'.
1191 retardation_factor : float, optional
1192 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.
1194 Returns
1195 -------
1196 numpy.ndarray
1197 Advective coverage [dimensionless], shape ``(n_output_bins,)``, values in ``[0, 1]``.
1198 Output bins lying wholly outside ``tedges`` are ``NaN``. Negative or ``NaN`` ``flow`` makes
1199 the cumulative-volume map non-monotone or undefined; the whole series is returned as ``NaN``
1200 (the function refuses rather than raising).
1202 Raises
1203 ------
1204 ValueError
1205 If ``tedges`` does not have exactly one more element than ``flow``, or if ``direction`` is
1206 not ``'extraction_to_infiltration'`` or ``'infiltration_to_extraction'``. Gamma parameter
1207 validation is delegated to :func:`gwtransport.gamma.parse_parameters`.
1209 See Also
1210 --------
1211 fraction_explained_mean : Discrete equal-weight APVD coverage
1212 fraction_explained_full : Per-pore-volume coverage
1213 gamma : Closed-form mean residence time for a (shifted) gamma APVD
1214 :ref:`concept-gamma-distribution` : Two-parameter pore volume model
1215 """
1216 if direction not in {"extraction_to_infiltration", "infiltration_to_extraction"}:
1217 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'"
1218 raise ValueError(msg)
1219 alpha, beta, loc = parse_parameters(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta)
1221 tedges = pd.DatetimeIndex(tedges)
1222 cout_tedges = pd.DatetimeIndex(cout_tedges)
1223 flow = np.asarray(flow, dtype=float)
1225 if len(tedges) != len(flow) + 1:
1226 msg = "tedges must have one more element than flow"
1227 raise ValueError(msg)
1229 n_out = len(cout_tedges) - 1
1230 if np.any(flow < 0) or np.any(np.isnan(flow)):
1231 return np.full(n_out, np.nan)
1233 r = retardation_factor
1234 tedges_days = tedges_to_days(tedges)
1235 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0])
1236 flow_cum = cumulative_flow_volume(flow, np.diff(tedges_days))
1237 v_total = flow_cum[-1]
1239 vol_out = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days, left=np.nan, right=np.nan)
1240 v_lo = vol_out[:-1]
1241 v_hi = vol_out[1:]
1242 dvol = v_hi - v_lo
1244 # threshold(V) per output edge, evaluated once on the full edge array so each shared interior
1245 # edge's CDF is computed a single time (the per-bin lo/hi are then slices of these):
1246 # Phi(x) = int_loc^x F_Vp(s) ds = y P(alpha, y/beta) - alpha beta P(alpha+1, y/beta), y = max(x-loc, 0)
1247 # cdf(x) = F_Vp(x) = P(alpha, y/beta)
1248 threshold = (vol_out if direction == "extraction_to_infiltration" else v_total - vol_out) / r
1249 y = np.maximum(threshold - loc, 0.0)
1250 cdf_edge = gamma_dist.cdf(y, alpha, scale=beta)
1251 phi_edge = y * cdf_edge - alpha * beta * gamma_dist.cdf(y, alpha + 1, scale=beta)
1253 # Flow-weighted bin average (1/dvol) int F_Vp(threshold(V)) dV = (R/dvol) [Phi(hi) - Phi(lo)]; the
1254 # i2e threshold decreases in V so its edge order flips. A zero-throughflow bin (dvol <= tol)
1255 # degenerates to the pointwise CDF at the bin's lower-edge volume.
1256 tol = 1e6 * np.spacing(float(np.max(np.abs(flow_cum)))) if flow_cum.size else 0.0
1257 with np.errstate(divide="ignore", invalid="ignore"):
1258 if direction == "extraction_to_infiltration":
1259 ratio = (r / dvol) * (phi_edge[1:] - phi_edge[:-1])
1260 else:
1261 ratio = (r / dvol) * (phi_edge[:-1] - phi_edge[1:])
1262 point = cdf_edge[:-1]
1263 out = np.where(dvol > tol, ratio, point)
1264 out[~(np.isfinite(v_lo) & np.isfinite(v_hi))] = np.nan
1265 return out
1268def freundlich_retardation(
1269 *,
1270 concentration: npt.ArrayLike,
1271 freundlich_k: float,
1272 freundlich_n: float,
1273 bulk_density: float,
1274 porosity: float,
1275) -> npt.NDArray[np.floating]:
1276 """
1277 Compute concentration-dependent retardation factors using Freundlich isotherm.
1279 The Freundlich isotherm relates sorbed concentration s to aqueous concentration C using the
1280 heterogeneity-index convention (matching :class:`gwtransport.fronttracking.math.FreundlichSorption`
1281 and :func:`gwtransport.advection.infiltration_to_extraction_nonlinear_sorption`, so a fitted
1282 ``freundlich_n`` is portable across the package)::
1284 s = k_f * C ^ (1 / n)
1286 The retardation factor is computed as::
1288 R = 1 + (rho_b/θ) * ds/dC = 1 + (rho_b/θ) * k_f * (1/n) * C^(1/n - 1)
1290 Parameters
1291 ----------
1292 concentration : array-like
1293 Concentration of compound in water [mass/volume]. One value per time bin, consistent
1294 with the ``flow`` array passed to the transport function.
1295 freundlich_k : float
1296 Freundlich coefficient [(m³/kg)^(1/n)] (under s = k_f * C^(1/n) with s dimensionless
1297 and C in [kg/m³]).
1298 freundlich_n : float
1299 Freundlich sorption exponent [dimensionless] (heterogeneity index; ``n = 1`` recovers a
1300 linear isotherm).
1301 bulk_density : float
1302 Bulk density of aquifer material [mass/volume].
1303 porosity : float
1304 Porosity of aquifer [dimensionless, 0-1].
1306 Returns
1307 -------
1308 numpy.ndarray
1309 Retardation factors for each flow interval.
1310 Length equals len(concentration) for use as retardation_factor in the transport functions.
1312 Raises
1313 ------
1314 ValueError
1315 If ``porosity`` is not in ``(0, 1)``, if ``bulk_density`` is not positive, if
1316 ``freundlich_k`` is negative, or if any ``concentration`` is non-positive while
1317 ``freundlich_n > 1`` (the retardation factor diverges as ``C -> 0``).
1319 See Also
1320 --------
1321 full : Compute residence times from flow and pore volume
1322 gwtransport.advection.infiltration_to_extraction_nonlinear_sorption : Transport with nonlinear sorption
1323 :ref:`concept-nonlinear-sorption` : Freundlich isotherm and concentration-dependent retardation
1325 Examples
1326 --------
1327 >>> concentration = np.array([0.1, 0.2, 0.3]) # same length as flow
1328 >>> R = freundlich_retardation(
1329 ... concentration=concentration,
1330 ... freundlich_k=0.5,
1331 ... freundlich_n=2.0,
1332 ... bulk_density=1600, # kg/m³
1333 ... porosity=0.35,
1334 ... )
1335 >>> # Use R as retardation_factor in the transport functions
1336 """
1337 concentration = np.asarray(concentration)
1339 if not 0 < porosity < 1:
1340 msg = f"Porosity must be in (0, 1), got {porosity}"
1341 raise ValueError(msg)
1342 if bulk_density <= 0:
1343 msg = f"Bulk density must be positive, got {bulk_density}"
1344 raise ValueError(msg)
1345 if freundlich_k < 0:
1346 msg = f"Freundlich K must be non-negative, got {freundlich_k}"
1347 raise ValueError(msg)
1349 # For n > 1 the Freundlich retardation factor 1 + (rho_b/theta) * k_f * (1/n) * C^(1/n-1)
1350 # diverges as C -> 0 (the exponent 1/n - 1 < 0). Silently clamping concentration would produce
1351 # a very large but finite value that depends on an arbitrary regularization constant; instead,
1352 # refuse the call so the user can decide how to handle non-positive concentrations.
1353 if freundlich_n > 1.0 and np.any(concentration <= 0):
1354 msg = "concentration must be strictly positive when freundlich_n > 1 (retardation diverges as C -> 0)"
1355 raise ValueError(msg)
1357 inv_n = 1.0 / freundlich_n
1358 return 1.0 + (bulk_density / porosity) * freundlich_k * inv_n * concentration ** (inv_n - 1.0)