Coverage for src/gwtransport/residence_time.py: 95%

328 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 21:17 +0000

1""" 

2Residence Time Calculations for Retarded Compound Transport. 

3 

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). 

11 

12Available functions: 

13 

14- :func:`full` - Compute the flow-weighted mean residence time over 

15 output bins, per pore volume (full ``(n_pore_volumes, n_bins)`` array). Follows the package's 

16 bin-edge convention and is the form consumed elsewhere in the package. Supports both forward 

17 (infiltration to extraction) and reverse (extraction to infiltration) directions. 

18 

19- :func:`mean` - Compute the mean residence time over output bins for a discrete 

20 aquifer pore-volume distribution (an array of equally-weighted pore volumes). Collapses the 

21 pore-volume axis to a single per-bin series. The ``spinup`` policy (default ``"constant"``) 

22 warm-starts the spin-up by extrapolating the boundary flow. 

23 

24- :func:`gamma` - Compute the closed-form mean residence time over output bins for a 

25 (shifted) gamma aquifer pore-volume distribution, with no pore-volume discretization. The 

26 ``spinup`` policy (default ``"constant"``) warm-starts the spin-up; ``spinup=0.0`` instead 

27 renormalizes over the covered sub-mass exactly. 

28 

29- :func:`fraction_explained_full`, :func:`fraction_explained_mean`, 

30 :func:`fraction_explained_gamma` - Compute the **advective** fraction of each output bin that is 

31 explained by the flow record: the flow-weighted share of the bin whose retarded advective parcel 

32 was infiltrated/extracted inside the record. ``full`` returns one row per pore volume, ``mean`` 

33 the equal-weight discrete-APVD mean, and ``gamma`` the closed-form (shifted) gamma-APVD value, 

34 mirroring :func:`full` / :func:`mean` / :func:`gamma`. These are **purely advective** -- molecular 

35 diffusion and microdispersion spread each bin over a range of infiltration times that is 

36 not captured here, so no bin is fully informed once dispersion is present (for that dispersive 

37 informed fraction use the captured kernel mass of the diffusion coefficient matrix). 

38 

39- :func:`freundlich_retardation` - Compute concentration-dependent retardation factors from a 

40 Freundlich isotherm, for use as the ``retardation_factor`` input to the transport functions. 

41 

42Spin-up period 

43-------------- 

44The spin-up **region** is determined entirely by the supplied flow record (``tedges``, which 

45fixes the cumulative throughflow volume ``V`` from ``0`` at the record start to ``V_end`` at the 

46record end) together with the retarded pore volume ``retardation_factor * V_p`` -- it is not a 

47length you set. A residence time for an output time needs the corresponding parcel to stay inside 

48the flow record: 

49 

50* ``direction='extraction_to_infiltration'`` looks **back** to the infiltration event, so the 

51 spin-up sits at the **start** of the output record: the residence time of a pore volume ``V_p`` 

52 needs ``V(t) >= retardation_factor * V_p`` (the extracted water was infiltrated before the record 

53 began otherwise). 

54* ``direction='infiltration_to_extraction'`` looks **forward** to the extraction event, so the 

55 spin-up sits at the **end** of the output record: it needs ``V_end - V(t) >= retardation_factor * 

56 V_p`` (the infiltrated water is extracted after the record ends otherwise). 

57 

58The spin-up therefore lengthens with both the pore volume and the retardation factor, and is 

59longest for the largest pore volumes of a distribution. 

60 

61What happens in that region is governed by a ``spinup`` policy, following the package convention 

62(see :mod:`gwtransport.advection`); :func:`full`, :func:`mean` and 

63:func:`gamma` all share the contract ``spinup={'constant'} | None | float in 

64[0, 1]`` and the default is ``"constant"`` everywhere: 

65 

66* ``"constant"`` (default) **warm-starts** by extrapolating the boundary flow (flow held constant 

67 at its first/last value), so no in-record output is ``NaN``. 

68* ``None`` is strict (no extrapolation), marking a pore volume ``NaN`` for any output bin its parcel 

69 leaves the record within. Where the pore-volume axis is collapsed -- :func:`mean` over a 

70 discrete set, :func:`gamma` over the continuum -- the bin mean then **renormalizes** 

71 over the covered streamtubes / sub-mass, emitted wherever any coverage remains. 

72* a ``float`` covered-fraction threshold is the strict mode with a minimum coverage gate: the 

73 renormalized mean is emitted only where the covered streamtube fraction / sub-mass fraction is at 

74 least ``spinup`` (``0.0`` matches ``None``; larger values demand more coverage). For the 

75 per-pore-volume :func:`full` there is no axis to collapse, so the ``float`` behaves 

76 exactly like ``None``. 

77 

78Output bins lying wholly outside ``tedges`` are ``NaN`` under every policy. 

79 

80The :func:`fraction_explained_full` / :func:`fraction_explained_mean` / 

81:func:`fraction_explained_gamma` diagnostics report, per output bin, the advective fraction of the 

82pore-volume distribution that is out of spin-up (``1.0`` = advectively fully informed, ``0.0`` = 

83entirely in spin-up) and are the way to locate the spin-up region when the means warm-start over it. 

84 

85This file is part of gwtransport which is released under AGPL-3.0 license. 

86See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details. 

87""" 

88 

89import numpy as np 

90import numpy.typing as npt 

91import pandas as pd 

92from scipy.stats import gamma as gamma_dist 

93 

94from gwtransport._time import tedges_to_days 

95from gwtransport.gamma import parse_parameters 

96from gwtransport.utils import cumulative_flow_volume, linear_interpolate 

97 

98# Relative slack on the covered-fraction spin-up gate. The covered sub-mass ``den`` equals its 

99# fully-covered reference only up to summation-reassociation ulps, so an exact ``den >= threshold * 

100# reference`` comparison spuriously rejects fully-covered bins at the strictest threshold (1.0). The 

101# slack is far above that float noise (band widths reach ~1e4 pieces, ~1e-12 relative) yet negligible 

102# as a physical coverage tolerance. 

103_SPINUP_GATE_RELTOL = 1e-9 

104 

105 

106def _boundary_extrapolated_map( 

107 flow: npt.NDArray[np.floating], 

108 flow_cum: npt.NDArray[np.floating], 

109 tedges_days: npt.NDArray[np.floating], 

110 pad: float, 

111) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]: 

112 """Extend the cumulative-volume -> time map past the record for the ``"constant"`` warm-start. 

113 

114 The boundary extrapolation slope is ``1/Q``, anchored on the nearest strictly-positive flow so a 

115 zero-flow boundary bin (whose ``flow_cum`` step is only the strictly-monotone ulp bump) does not 

116 give a ``1/0`` extrapolation. Bit-identical to ``1/Q_boundary`` when the boundary already carries 

117 flow. 

118 

119 Returns 

120 ------- 

121 volume_map : ndarray 

122 ``flow_cum`` padded by ``pad`` on each end. 

123 time_map : ndarray 

124 ``tedges_days`` padded by ``pad * (1/Q)`` on each end, aligned with ``volume_map``. 

125 Shared by :func:`full` and :func:`gamma`. 

126 """ 

127 positive_flow = flow[flow > 0.0] 

128 inv_q_first = 1.0 / positive_flow[0] 

129 inv_q_last = 1.0 / positive_flow[-1] 

130 volume_map = np.concatenate([[flow_cum[0] - pad], flow_cum, [flow_cum[-1] + pad]]) 

131 time_map = np.concatenate([ 

132 [tedges_days[0] - pad * inv_q_first], 

133 tedges_days, 

134 [tedges_days[-1] + pad * inv_q_last], 

135 ]) 

136 return volume_map, time_map 

137 

138 

139def _resolve_spinup(spinup: str | float | None) -> tuple[bool, float]: 

140 """Normalize the residence-time ``spinup`` policy to ``(extrapolate, threshold)``. 

141 

142 The three sibling residence-time functions share one spin-up contract, 

143 ``{'constant'} | None | float in [0, 1]``, matching the package convention (see 

144 :mod:`gwtransport.advection`): 

145 

146 * ``'constant'`` -> ``(True, 0.0)`` -- warm-start by extrapolating the boundary flow. 

147 * ``None`` -> ``(False, 0.0)`` -- strict map (no extrapolation); a collapsed mean is emitted 

148 wherever any covered sub-mass / valid streamtube remains. 

149 * ``float`` in ``[0, 1]`` -> ``(False, float)`` -- strict map, with the covered-fraction 

150 threshold gating a collapsed mean. ``0.0`` matches ``None``; larger values require a larger 

151 covered fraction before a bin is emitted, and ``1.0`` is strictest (full coverage required). 

152 

153 Parameters 

154 ---------- 

155 spinup : {'constant'}, None, or float in [0, 1] 

156 Public spin-up policy. 

157 

158 Returns 

159 ------- 

160 extrapolate : bool 

161 Whether to warm-start by extrapolating the boundary flow. 

162 threshold : float 

163 Covered-fraction gate applied where the pore-volume axis is collapsed (ignored by the 

164 per-pore-volume :func:`full`, which is strict per bin). 

165 

166 Raises 

167 ------ 

168 ValueError 

169 If ``spinup`` is not ``'constant'``, ``None``, or a float in ``[0, 1]``. 

170 """ 

171 if spinup == "constant": 

172 return True, 0.0 

173 if spinup is None: 

174 return False, 0.0 

175 if isinstance(spinup, int | float) and not isinstance(spinup, bool) and 0.0 <= spinup <= 1.0: 

176 return False, float(spinup) 

177 msg = "spinup should be 'constant', None, or a float in [0, 1]" 

178 raise ValueError(msg) 

179 

180 

181def _phi_setup( 

182 flow: npt.NDArray[np.floating], 

183 flow_cum: npt.NDArray[np.floating], 

184 tedges_days: npt.NDArray[np.floating], 

185 *, 

186 extrapolate: bool, 

187 pad: float, 

188) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating], npt.NDArray[np.floating], npt.NDArray[np.floating]]: 

189 """Build the antiderivative ``phi(x) = int_0^x T(w) dw`` of the cumulative-volume -> time map ``T``. 

190 

191 ``T`` is piecewise-linear (knots at the cumulative-volume edges), so ``phi`` is piecewise-quadratic 

192 with the same knots. With ``extrapolate`` the map is extended one anchor past each end of the record 

193 at the boundary flow rate (padded by ``pad``) for the ``"constant"`` warm-start; otherwise it is the 

194 raw record. Shared by :func:`full` and :func:`gamma`. 

195 

196 Returns 

197 ------- 

198 phi_v : ndarray 

199 Cumulative-volume knots of the map (extended by ``pad`` at each end when ``extrapolate``). 

200 phi_t : ndarray 

201 Time knots aligned with ``phi_v``. 

202 phi_knot : ndarray 

203 ``phi`` evaluated at each volume knot. 

204 phi_rate : ndarray 

205 Per-segment ``dV/dt`` (flow) rate between consecutive knots. 

206 """ 

207 # pad == 0 (no spin-up reach, e.g. retardation_factor or all pore volumes 0) carries no 

208 # extrapolation and would only add zero-width boundary segments (0/0 -> NaN phi_rate), so skip it. 

209 if extrapolate and pad > 0.0 and np.any(flow > 0.0): 

210 phi_v, phi_t = _boundary_extrapolated_map(flow, flow_cum, tedges_days, pad) 

211 else: 

212 phi_v, phi_t = flow_cum, tedges_days 

213 phi_dv = phi_v[1:] - phi_v[:-1] 

214 phi_rate = phi_dv / (phi_t[1:] - phi_t[:-1]) 

215 phi_knot = np.concatenate([[0.0], np.cumsum(phi_t[:-1] * phi_dv + phi_dv**2 / (2 * phi_rate))]) 

216 return phi_v, phi_t, phi_knot, phi_rate 

217 

218 

219def _eval_phi( 

220 x: npt.NDArray[np.floating], 

221 phi_v: npt.NDArray[np.floating], 

222 phi_t: npt.NDArray[np.floating], 

223 phi_knot: npt.NDArray[np.floating], 

224 phi_rate: npt.NDArray[np.floating], 

225 *, 

226 strict_nan: bool = False, 

227) -> npt.NDArray[np.floating]: 

228 """Evaluate the piecewise-quadratic antiderivative ``phi`` from :func:`_phi_setup` at ``x``. 

229 

230 ``x`` is clipped to the map range ``[phi_v[0], phi_v[-1]]`` (the warm-start extrapolation lives in 

231 that range when padded). With ``strict_nan`` any ``x`` outside the range returns ``NaN`` instead -- 

232 used by :func:`full` so an output bin whose parcel leaves the record is ``NaN``. 

233 

234 Returns 

235 ------- 

236 ndarray 

237 ``phi`` evaluated at ``x``, with the same shape as ``x``. 

238 """ 

239 x = np.asarray(x, dtype=float) 

240 xc = np.clip(x, phi_v[0], phi_v[-1]) 

241 j = np.clip(np.searchsorted(phi_v, xc, side="right") - 1, 0, len(phi_rate) - 1) 

242 dv = xc - phi_v[j] 

243 out = phi_knot[j] + phi_t[j] * dv + dv * dv / (2 * phi_rate[j]) 

244 if strict_nan: 

245 out = np.where((x < phi_v[0]) | (x > phi_v[-1]), np.nan, out) 

246 return out 

247 

248 

249def full( 

250 *, 

251 flow: npt.ArrayLike, 

252 tedges: pd.DatetimeIndex | np.ndarray, 

253 cout_tedges: pd.DatetimeIndex | np.ndarray, 

254 aquifer_pore_volumes: npt.ArrayLike, 

255 direction: str = "extraction_to_infiltration", 

256 retardation_factor: float = 1.0, 

257 spinup: str | float | None = "constant", 

258) -> npt.NDArray[np.floating]: 

259 r""" 

260 Compute the mean residence time over output bins, per pore volume. 

261 

262 The flow-weighted mean residence time is computed over each output interval 

263 ``[cout_tedges[i], cout_tedges[i + 1])`` and returned as the full 

264 ``(n_pore_volumes, n_output_bins)`` array -- one row per entry in 

265 ``aquifer_pore_volumes``, without collapsing the pore-volume axis. The average is uniform in 

266 cumulative throughflow volume, matching the package's bin-edge convention (and what the diffusion 

267 modules consume to compute a per-bin retarded velocity). 

268 

269 Parameters 

270 ---------- 

271 flow : array-like 

272 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one. 

273 tedges : pandas.DatetimeIndex 

274 Time edges for the flow data, as datetime64 objects, defining the flow intervals. 

275 cout_tedges : pandas.DatetimeIndex 

276 Output time edges as datetime64 objects; ``n + 1`` edges define ``n`` output bins. 

277 aquifer_pore_volumes : float or array-like 

278 Pore volume(s) of the aquifer [m³]. A single value or an array of pore volumes 

279 representing different flow paths. 

280 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

281 Direction of the flow calculation: 

282 

283 * 'extraction_to_infiltration': 

284 Extraction to infiltration modeling - how many days ago was the extracted water infiltrated. 

285 * 'infiltration_to_extraction': 

286 Infiltration to extraction modeling - how many days until the infiltrated water is extracted. 

287 

288 Default is 'extraction_to_infiltration'. 

289 retardation_factor : float, optional 

290 Retardation factor of the compound in the aquifer [dimensionless]. A value greater 

291 than 1.0 indicates the compound moves slower than water. Default is 1.0. 

292 spinup : {'constant'}, None, or float in [0, 1], optional 

293 How to treat the spin-up zone, where a pore volume's retarded look-back/forward parcel 

294 leaves the flow record. Matches the package convention (see :mod:`gwtransport.advection`). 

295 

296 * ``'constant'`` (default): warm-start -- extrapolate the cumulative-volume-to-time map past 

297 the record at the boundary flow rates (flow held constant at its first/last value), so 

298 the residence time stays finite. No left-edge (extraction) or right-edge (infiltration) 

299 spin-up ``NaN``. 

300 * ``None`` or a ``float`` in ``[0, 1]``: strict -- a pore volume whose parcel leaves the 

301 record at any point within an output bin is ``NaN`` for that bin (all-or-nothing per bin), 

302 with no extrapolation. This function returns the full per-pore-volume array, so there is no 

303 pore-volume axis to collapse; the ``float`` covered-fraction threshold therefore behaves 

304 identically to ``None`` here and only takes effect once the axis is collapsed in 

305 :func:`mean` / :func:`gamma`. 

306 

307 Output bins lying wholly outside ``tedges`` are ``NaN`` under either policy. 

308 

309 Returns 

310 ------- 

311 numpy.ndarray 

312 Mean residence time [days], shape ``(n_pore_volumes, n_output_bins)``. The first 

313 dimension corresponds to the pore volumes and the second to the ``cout_tedges`` bins. 

314 Negative or ``NaN`` ``flow`` makes the cumulative-volume map non-monotone or undefined; the 

315 whole array is returned as ``NaN`` (the function refuses rather than raising). 

316 

317 Raises 

318 ------ 

319 ValueError 

320 If ``tedges`` does not have exactly one more element than ``flow``. If 

321 ``direction`` is not ``'extraction_to_infiltration'`` or 

322 ``'infiltration_to_extraction'``. If ``spinup`` is not ``'constant'``, ``None``, or a float 

323 in ``[0, 1]``. 

324 

325 See Also 

326 -------- 

327 fraction_explained_full : Advective fraction of each output bin explained, per pore volume 

328 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction 

329 :ref:`concept-transport-equation` : Flow-weighted averaging convention 

330 

331 Notes 

332 ----- 

333 With the default ``spinup='constant'`` the spin-up zone is warm-started by extrapolating the 

334 boundary flow, so no in-record bin is ``NaN``; use :func:`fraction_explained_mean` (or 

335 ``spinup=None``) to locate the spin-up region. See the module docstring (``Spin-up period``) 

336 for the full rule. 

337 

338 The single-streamtube residence time :math:`\tau(V) = \mathrm{sign}\,[T(V + \mathrm{sign}\,R V_p) 

339 - T(V)]` is piecewise-linear in cumulative throughflow volume :math:`V` (:math:`T` is the 

340 volume :math:`\to` time map, :math:`\mathrm{sign} = -1` for ``extraction_to_infiltration`` and 

341 :math:`+1` for ``infiltration_to_extraction``). Its flow-weighted bin average is therefore a 

342 closed-form difference of the antiderivative :math:`\Phi(x) = \int_0^x T(w)\,dw` (piecewise- 

343 quadratic), evaluated at four points per pore volume and output bin: 

344 

345 .. math:: 

346 

347 \bar\tau 

348 = \frac{1}{\Delta V}\int_{V_\mathrm{lo}}^{V_\mathrm{hi}} \tau(V)\,dV 

349 = \frac{\mathrm{sign}}{\Delta V}\bigl[ 

350 \Phi(V_\mathrm{hi} + \mathrm{sign}\,R V_p) - \Phi(V_\mathrm{lo} + \mathrm{sign}\,R V_p) 

351 - \Phi(V_\mathrm{hi}) + \Phi(V_\mathrm{lo})\bigr], 

352 

353 where :math:`V` is cumulative throughflow volume (:math:`dV = Q\,dt`). This avoids materialising a 

354 per-streamtube integration grid, so memory and time scale as the output size 

355 :math:`O(n_\mathrm{pore\ volumes}\cdot n_\mathrm{bins})`. A zero-throughflow output bin 

356 (:math:`\Delta V \to 0`) has a fixed volume while output time advances, so it degenerates to the 

357 pointwise residence time at the bin's time midpoint. 

358 

359 Examples 

360 -------- 

361 >>> import pandas as pd 

362 >>> import numpy as np 

363 >>> from gwtransport.residence_time import full 

364 >>> flow_dates = pd.date_range(start="2023-01-01", end="2023-01-10", freq="D") 

365 >>> flow_values = np.full(len(flow_dates) - 1, 100.0) # Constant flow of 100 m³/day 

366 >>> mean_times = full( 

367 ... flow=flow_values, 

368 ... tedges=flow_dates, 

369 ... cout_tedges=flow_dates, 

370 ... aquifer_pore_volumes=200.0, 

371 ... direction="extraction_to_infiltration", 

372 ... ) 

373 >>> # 200 m³ / 100 m³/day = 2 days residence time; the default constant warm-start 

374 >>> # extrapolates the boundary flow, so the left-edge spin-up bins are also 2 days 

375 >>> print(mean_times) # doctest: +NORMALIZE_WHITESPACE 

376 [[2. 2. 2. 2. 2. 2. 2. 2. 2.]] 

377 """ 

378 if direction not in {"extraction_to_infiltration", "infiltration_to_extraction"}: 

379 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'" 

380 raise ValueError(msg) 

381 extrapolate, _ = _resolve_spinup(spinup) 

382 

383 aquifer_pore_volumes = np.atleast_1d(aquifer_pore_volumes) 

384 tedges = pd.DatetimeIndex(tedges) 

385 cout_tedges = pd.DatetimeIndex(cout_tedges) 

386 flow = np.asarray(flow, dtype=float) 

387 n_pv = len(aquifer_pore_volumes) 

388 n_out = len(cout_tedges) - 1 

389 

390 if len(tedges) != len(flow) + 1: 

391 msg = "tedges must have one more element than flow" 

392 raise ValueError(msg) 

393 if np.any(flow < 0) or np.any(np.isnan(flow)): 

394 return np.full((n_pv, n_out), np.nan) 

395 

396 tedges_days = tedges_to_days(tedges) 

397 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0]) 

398 # Plateaus in flow_cum from Q = 0 bins make the V -> t inversion multi-valued; bump duplicates by 

399 # the smallest representable amount so the inverse map is single-valued. 

400 flow_cum = cumulative_flow_volume(flow, np.diff(tedges_days), strictly_monotone=True) 

401 

402 # Sign convention: sign = -1 for extraction_to_infiltration, +1 for infiltration_to_extraction; 

403 # the look-back/forward parcel sits at volume V + shift and tau(V) = sign * (T(V + shift) - T(V)) is 

404 # piecewise-linear in V (T is the volume -> time map). Its flow-weighted bin average is a closed- 

405 # form difference of the antiderivative phi(x) = int_0^x T(w) dw, so no per-streamtube integration 

406 # grid is built (memory/time O(n_pore_volumes * n_bins), not O(n_pore_volumes^2 * n_flow)). 

407 sign = -1.0 if direction == "extraction_to_infiltration" else 1.0 

408 shift = sign * retardation_factor * aquifer_pore_volumes # (n_pv,) 

409 

410 # phi over the cumulative-volume -> time map. With spinup="constant" the map is extrapolated past 

411 # the record at the boundary flow (padded by the largest reach R * max(V_p)) so phi warm-starts the 

412 # spin-up; otherwise phi is NaN outside the record so a bin whose parcel leaves it becomes NaN. 

413 pad = retardation_factor * float(aquifer_pore_volumes.max()) if aquifer_pore_volumes.size else 0.0 

414 phi_v, phi_t, phi_knot, phi_rate = _phi_setup(flow, flow_cum, tedges_days, extrapolate=extrapolate, pad=pad) 

415 # The map is only actually extended when there is a positive boundary flow to extrapolate; with 

416 # all-zero flow (or spinup=None) it stays the raw record, so out-of-record look-backs are NaN. 

417 strict = not (extrapolate and bool(np.any(flow > 0.0))) 

418 

419 vol_out = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days, left=np.nan, right=np.nan) 

420 v_lo = vol_out[:-1] 

421 v_hi = vol_out[1:] 

422 dvol = v_hi - v_lo 

423 bins_within = np.isfinite(v_lo) & np.isfinite(v_hi) 

424 

425 phi_base = _eval_phi(vol_out, phi_v, phi_t, phi_knot, phi_rate, strict_nan=strict) # (n_out + 1,) 

426 phi_shift = _eval_phi(vol_out[None, :] + shift[:, None], phi_v, phi_t, phi_knot, phi_rate, strict_nan=strict) 

427 

428 tol = 1e6 * np.spacing(float(np.max(np.abs(flow_cum)))) if flow_cum.size else 0.0 

429 with np.errstate(divide="ignore", invalid="ignore"): 

430 result = ( 

431 sign * ((phi_shift[:, 1:] - phi_shift[:, :-1]) - (phi_base[1:] - phi_base[:-1])[None, :]) / dvol[None, :] 

432 ) 

433 # Zero-throughflow output bin (dvol -> 0): the volume is fixed while output time advances, so the 

434 # flow-weighted average degenerates to the pointwise tau at the bin time midpoint, sign*(T(V_lo + 

435 # shift) - t_mid). A direct ratio there would catastrophically cancel. Only in-record zero-flow 

436 # bins reach it, so skip the interp entirely when none are present. 

437 degenerate = bins_within & (dvol <= tol) 

438 if np.any(degenerate): 

439 t_mid = 0.5 * (cout_tedges_days[:-1] + cout_tedges_days[1:]) 

440 nan_outside = np.nan if strict else None 

441 with np.errstate(divide="ignore", invalid="ignore"): 

442 t_lookback = linear_interpolate( 

443 x_ref=phi_v, y_ref=phi_t, x_query=v_lo[None, :] + shift[:, None], left=nan_outside, right=nan_outside 

444 ) 

445 point = sign * (t_lookback - t_mid[None, :]) 

446 result = np.where(dvol[None, :] > tol, result, point) 

447 return np.where(bins_within[None, :], result, np.nan) 

448 

449 

450def mean( 

451 *, 

452 flow: npt.ArrayLike, 

453 tedges: pd.DatetimeIndex | np.ndarray, 

454 cout_tedges: pd.DatetimeIndex | np.ndarray, 

455 aquifer_pore_volumes: npt.ArrayLike, 

456 direction: str = "extraction_to_infiltration", 

457 retardation_factor: float = 1.0, 

458 spinup: str | float | None = "constant", 

459) -> npt.NDArray[np.floating]: 

460 r""" 

461 Compute the mean residence time over output bins for a discrete APVD. 

462 

463 The mean is taken over a **discrete** set of equally-weighted aquifer pore volumes -- one 

464 streamtube per entry in ``aquifer_pore_volumes``. Each streamtube's flow-weighted bin average 

465 is computed with :func:`full` and the pore-volume axis is then collapsed to a 

466 single per-output-bin series by averaging over the streamtubes that are valid in each bin. For 

467 a continuous (shifted) gamma pore-volume distribution evaluated in closed form, use 

468 :func:`gamma`. 

469 

470 The mean is over the valid streamtubes, 

471 

472 .. math:: 

473 

474 \bar\tau_b = \frac{1}{|V_b|}\sum_{i \in V_b} \tau_{i,b}, 

475 \qquad V_b = \{\, i : \tau_{i,b}\ \mathrm{finite} \,\}. 

476 

477 With the default ``spinup='constant'`` every streamtube is finite within the flow record 

478 (the boundary flow is extrapolated), so this is simply the mean over all pore volumes; with 

479 ``spinup=None`` it renormalizes over the streamtubes that have broken through. 

480 

481 Parameters 

482 ---------- 

483 flow : array-like 

484 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one. 

485 tedges : pandas.DatetimeIndex 

486 Time edges for the flow data, as datetime64 objects, defining the flow intervals. 

487 cout_tedges : pandas.DatetimeIndex 

488 Output time edges as datetime64 objects; ``n + 1`` edges define ``n`` output bins. 

489 aquifer_pore_volumes : array-like 

490 Discrete pore volumes [m³], one per (equally-weighted) streamtube. A single value 

491 collapses to the per-streamtube mean of :func:`full`. 

492 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

493 Direction of the flow calculation: 

494 * 'extraction_to_infiltration': how many days ago was the extracted water infiltrated 

495 * 'infiltration_to_extraction': how many days until the infiltrated water is extracted 

496 Default is 'extraction_to_infiltration'. 

497 retardation_factor : float, optional 

498 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0. 

499 spinup : {'constant'}, None, or float in [0, 1], optional 

500 Spin-up policy, sharing the contract of :func:`gamma`. ``'constant'`` 

501 (default) warm-starts by extrapolating the boundary flow so no in-record bin is ``NaN``; 

502 ``None`` leaves spin-up streamtubes ``NaN`` and the mean renormalizes over those that have 

503 broken through (emitted wherever at least one streamtube is valid). A ``float`` in 

504 ``[0, 1]`` is the covered-fraction threshold: the renormalized mean is emitted only where 

505 the fraction of valid streamtubes is at least ``spinup`` (``0.0`` matches ``None``; ``1.0`` 

506 demands every streamtube; larger values demand more streamtubes to have broken through). Use 

507 :func:`fraction_explained_mean` to 

508 locate the spin-up region. 

509 

510 Returns 

511 ------- 

512 numpy.ndarray 

513 Mean residence time [days], shape ``(n_output_bins,)``. Output bins with no valid 

514 streamtube (outside the flow record, or -- with ``spinup=None`` -- fully in the spin-up 

515 zone) are NaN; with a ``float`` ``spinup`` so are bins whose valid-streamtube fraction is 

516 below the threshold. Negative or ``NaN`` ``flow`` makes the cumulative-volume map non-monotone 

517 or undefined; the whole series is returned as ``NaN`` (the function refuses rather than raising). 

518 

519 See Also 

520 -------- 

521 gamma : Exact closed-form mean for a continuous (shifted) gamma APVD 

522 full : Per-pore-volume mean residence time over output bins 

523 fraction_explained_mean : Advective fraction of each output bin explained by the record 

524 gwtransport.gamma.bins : Discretize a gamma APVD into pore-volume bins 

525 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction 

526 

527 Notes 

528 ----- 

529 With ``spinup=None`` the spin-up is **all-or-nothing per streamtube**: a streamtube whose 

530 look-back/forward parcel leaves the flow record part-way through an output bin has a ``NaN`` bin 

531 average (inherited from :func:`full`) and is dropped from that bin's mean 

532 entirely, rather than contributing its partially-covered share; the bin is ``NaN`` only once 

533 every streamtube is in spin-up. In that mode the discrete mean differs from 

534 :func:`gamma`, which renormalizes over the covered sub-mass exactly. See the 

535 module docstring (``Spin-up period``) for the full rule. 

536 

537 Examples 

538 -------- 

539 >>> import pandas as pd 

540 >>> import numpy as np 

541 >>> from gwtransport.residence_time import mean 

542 >>> flow_dates = pd.date_range(start="2023-01-01", end="2023-02-10", freq="D") 

543 >>> flow_values = np.full(len(flow_dates) - 1, 100.0) # 100 m³/day 

544 >>> tau_bar = mean( 

545 ... flow=flow_values, 

546 ... tedges=flow_dates, 

547 ... cout_tedges=flow_dates, 

548 ... aquifer_pore_volumes=[400.0, 600.0], # two equally-weighted streamtubes 

549 ... ) 

550 >>> # Deep in the record: mean pore volume 500 / 100 m³/day = 5 days 

551 >>> float(np.round(tau_bar[-1], 6)) 

552 5.0 

553 """ 

554 _, threshold = _resolve_spinup(spinup) 

555 rt = full( 

556 flow=flow, 

557 tedges=tedges, 

558 cout_tedges=cout_tedges, 

559 aquifer_pore_volumes=aquifer_pore_volumes, 

560 direction=direction, 

561 retardation_factor=retardation_factor, 

562 spinup=spinup, 

563 ) 

564 

565 # Mean over the streamtubes that are valid (non-NaN) in each output bin; bins with no valid 

566 # streamtube reduce to 0/0 and are NaN. With spinup='constant' every in-record streamtube is 

567 # finite, so this is the plain mean; otherwise it renormalizes over the broken-through set and 

568 # a float covered-fraction threshold further NaNs bins where too few streamtubes have arrived. 

569 n_streamtubes = rt.shape[0] 

570 valid_count = np.isfinite(rt).sum(axis=0) 

571 with np.errstate(invalid="ignore"): 

572 mean = np.nansum(rt, axis=0) / valid_count 

573 if threshold > 0.0: 

574 mean = np.where(valid_count >= threshold * n_streamtubes, mean, np.nan) 

575 return mean 

576 

577 

578def gamma( 

579 *, 

580 flow: npt.ArrayLike, 

581 tedges: pd.DatetimeIndex | np.ndarray, 

582 cout_tedges: pd.DatetimeIndex | np.ndarray, 

583 mean: float | None = None, 

584 std: float | None = None, 

585 loc: float = 0.0, 

586 alpha: float | None = None, 

587 beta: float | None = None, 

588 direction: str = "extraction_to_infiltration", 

589 retardation_factor: float = 1.0, 

590 spinup: str | float | None = "constant", 

591 _max_tile_elements: int = 1_000_000, 

592) -> npt.NDArray[np.floating]: 

593 r""" 

594 Compute the mean residence time over output bins for a (shifted) gamma APVD. 

595 

596 The expectation over a (shifted) gamma aquifer pore-volume distribution (APVD), 

597 parameterized by either ``(mean, std, loc)`` or ``(alpha, beta, loc)``, is taken in closed 

598 form -- no pore-volume binning and no ``n_bins`` accuracy/cost knob. The bin mean is 

599 flow-weighted (uniform in cumulative volume), matching the bin-edge convention of the 

600 package, and a single per-output-bin series is returned. 

601 

602 The single-streamtube residence time is piecewise-linear in the pore volume :math:`V_p`, so 

603 its per-bin time integral :math:`G_b(V_p) = \int_{\mathrm{bin}} \tau\,dV` is piecewise- 

604 quadratic in :math:`V_p` and the covered length :math:`L_b(V_p)` piecewise-linear. The bin 

605 mean is the ratio of two closed-form integrals against the gamma density -- its zeroth, 

606 first and second partial moments (regularized incomplete gamma) -- formed once after 

607 integrating. The ``spinup`` policy sets what happens where part of the APVD lacks flow 

608 history: ``'constant'`` (default) extrapolates the boundary flow over the full distribution 

609 (the package default warm-start), while a ``float`` threshold renormalizes the mean over the 

610 covered sub-mass (``0.0`` reproduces the exact covered-sub-mass conditional mean). 

611 

612 Parameters 

613 ---------- 

614 flow : array-like 

615 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one. 

616 tedges : pandas.DatetimeIndex 

617 Time edges for the flow data, as datetime64 objects, defining the flow intervals. 

618 cout_tedges : pandas.DatetimeIndex 

619 Output time edges as datetime64 objects; ``n + 1`` edges define ``n`` output bins. 

620 mean : float, optional 

621 Mean of the gamma APVD [m³]. Must be strictly greater than ``loc``. Provide either 

622 ``(mean, std)`` or ``(alpha, beta)``. 

623 std : float, optional 

624 Standard deviation of the gamma APVD [m³]. Must be positive. 

625 loc : float, optional 

626 Location (lower bound of support) of the gamma APVD [m³]; a guaranteed minimum pore 

627 volume. Must satisfy ``0 <= loc < mean``. Default is 0.0. 

628 alpha : float, optional 

629 Shape parameter of the gamma APVD (must be > 0). 

630 beta : float, optional 

631 Scale parameter of the gamma APVD (must be > 0). 

632 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

633 Direction of the flow calculation: 

634 * 'extraction_to_infiltration': how many days ago was the extracted water infiltrated 

635 * 'infiltration_to_extraction': how many days until the infiltrated water is extracted 

636 Default is 'extraction_to_infiltration'. 

637 retardation_factor : float, optional 

638 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0. 

639 spinup : {'constant'}, None, or float in [0, 1], optional 

640 How to treat the spin-up zone, where part of the gamma APVD lacks flow history. Matches 

641 the package convention (see :mod:`gwtransport.advection`). 

642 

643 * ``'constant'`` (default): warm-start -- extrapolate the cumulative-volume-to-time map past 

644 the record at the boundary flow rates (flow held constant at its first/last value) and 

645 integrate the full distribution, so no in-record bin is ``NaN``. 

646 * ``None`` or a ``float`` in ``[0, 1]``: renormalize the mean over the covered sub-mass, 

647 emitting a bin only where the covered fraction of the distribution is at least the 

648 threshold. ``None`` and ``0.0`` both give the exact covered-sub-mass conditional mean 

649 (emit whenever any sub-mass is covered); larger values demand a larger covered fraction, 

650 and ``1.0`` requires the full distribution to be covered. 

651 

652 Output bins lying wholly outside ``tedges`` are ``NaN`` under either policy. 

653 

654 Returns 

655 ------- 

656 numpy.ndarray 

657 APVD-mean residence time [days], shape ``(n_output_bins,)``. Output bins outside the flow 

658 record are NaN; with a ``float`` ``spinup`` so are bins whose covered fraction is below the 

659 threshold. Negative or ``NaN`` ``flow`` makes the cumulative-volume map non-monotone or 

660 undefined; the whole series is returned as ``NaN`` (the function refuses rather than raising). 

661 

662 Raises 

663 ------ 

664 ValueError 

665 If ``tedges`` does not have exactly one more element than ``flow``. If ``direction`` 

666 is not ``'extraction_to_infiltration'`` or ``'infiltration_to_extraction'``. If ``spinup`` 

667 is not ``'constant'``, ``None``, or a float in ``[0, 1]``. Gamma parameter validation is 

668 delegated to :func:`gwtransport.gamma.parse_parameters`. 

669 

670 See Also 

671 -------- 

672 mean : Equally-weighted mean for a discrete set of pore volumes 

673 full : Per-pore-volume mean residence time over output bins 

674 fraction_explained_mean : Advective fraction of each output bin explained by the record 

675 gwtransport.gamma.bins : Discretize a gamma APVD into pore-volume bins 

676 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction 

677 :ref:`concept-gamma-distribution` : Two-parameter pore volume model 

678 

679 Notes 

680 ----- 

681 With the default ``spinup='constant'`` the spin-up is warm-started exactly as in 

682 :func:`mean` (constant-boundary-flow extrapolation), so the two agree everywhere. With 

683 ``spinup=0.0`` the spin-up is instead handled by exact covered-sub-mass renormalization: each 

684 output bin integrates over only the pore-volume sub-range with sufficient flow history. See the 

685 module docstring (``Spin-up period``) for the full rule. 

686 

687 Examples 

688 -------- 

689 >>> import pandas as pd 

690 >>> import numpy as np 

691 >>> from gwtransport.residence_time import gamma 

692 >>> flow_dates = pd.date_range(start="2023-01-01", end="2023-02-10", freq="D") 

693 >>> flow_values = np.full(len(flow_dates) - 1, 100.0) # 100 m³/day 

694 >>> tau_bar = gamma( 

695 ... flow=flow_values, 

696 ... tedges=flow_dates, 

697 ... cout_tedges=flow_dates, 

698 ... mean=500.0, 

699 ... std=100.0, 

700 ... direction="extraction_to_infiltration", 

701 ... ) 

702 >>> # Deep in the record the mean residence time approaches mean / flow = 5 days 

703 >>> float(np.round(tau_bar[-1], 6)) 

704 5.0 

705 """ 

706 if direction not in {"extraction_to_infiltration", "infiltration_to_extraction"}: 

707 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'" 

708 raise ValueError(msg) 

709 extrapolate, spinup_threshold = _resolve_spinup(spinup) 

710 

711 alpha, beta, loc = parse_parameters(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta) 

712 

713 flow = np.asarray(flow, dtype=float) 

714 tedges = pd.DatetimeIndex(tedges) 

715 cout_tedges = pd.DatetimeIndex(cout_tedges) 

716 n_out = len(cout_tedges) - 1 

717 

718 if len(tedges) != len(flow) + 1: 

719 msg = "tedges must have one more element than flow" 

720 raise ValueError(msg) 

721 if np.any(flow < 0) or np.any(np.isnan(flow)): 

722 return np.full(n_out, np.nan) 

723 

724 sign = -1.0 if direction == "extraction_to_infiltration" else 1.0 

725 r = retardation_factor 

726 

727 tedges_days = tedges_to_days(tedges) 

728 flow_cum = cumulative_flow_volume(flow, np.diff(tedges_days), strictly_monotone=True) 

729 v_end = flow_cum[-1] 

730 n_edges = len(flow_cum) 

731 

732 # Finite support: drop the gamma tails (mass ~1e-13, far below the discretization error this 

733 # closed form replaces). Restricting the integral to [support_lo, support_hi] is what keeps 

734 # the per-bin flow-edge band -- and the gamma CDF evaluations -- bounded. 

735 tail = 1e-13 

736 support_lo = float(loc + gamma_dist.ppf(tail, alpha, scale=beta)) 

737 support_hi = float(loc + gamma_dist.ppf(1.0 - tail, alpha, scale=beta)) 

738 

739 # phi(v) = int_0^v T(w) dw with T the inverse cumulative-volume map (piecewise-linear); phi is 

740 # piecewise-quadratic with knots at the cumulative-volume edges, so the per-bin time integral of 

741 # tau is a difference of phi at the look-back/forward limits. With spinup="constant" the map is 

742 # extended past the record at the boundary flow rates (one anchor each end, padded by the largest 

743 # reach r*support_hi) so phi extrapolates the spin-up; with a float spinup it stays clipped to 

744 # [0, v_end] (the covered sub-mass only). 

745 phi_v, phi_t, phi_knot, phi_rate = _phi_setup( 

746 flow, flow_cum, tedges_days, extrapolate=extrapolate, pad=r * support_hi 

747 ) 

748 

749 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0]) 

750 vol_out = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days, left=np.nan, right=np.nan) 

751 good_all = np.isfinite(vol_out[:-1]) & np.isfinite(vol_out[1:]) & (vol_out[1:] > vol_out[:-1]) 

752 if not np.any(good_all): 

753 return np.full(n_out, np.nan) 

754 v_lo_all = np.where(good_all, vol_out[:-1], 0.0) 

755 v_hi_all = np.where(good_all, vol_out[1:], 1.0) 

756 

757 # Fixed band of flow edges each bin's look-back/forward sweep can cross over the supported 

758 # pore volumes. band_width is the global maximum so every tile shares one column layout; a 

759 # spurious column clips to the support and merely splits a quadratic piece without changing 

760 # its integral, so the band is an exact superset. 

761 a_min = v_lo_all - r * support_hi if sign < 0 else v_lo_all + r * support_lo 

762 a_max = v_hi_all - r * support_lo if sign < 0 else v_hi_all + r * support_hi 

763 jlo_all = np.clip(np.searchsorted(flow_cum, a_min, "left") - 1, 0, n_edges - 1) 

764 jhi_all = np.clip(np.searchsorted(flow_cum, a_max, "right"), 0, n_edges - 1) 

765 band_width = int((jhi_all - jlo_all).max()) + 1 

766 band = np.arange(band_width) 

767 

768 # Tile over output bins to bound peak memory. Each bin carries ~3*band_width pieces through a 

769 # (3 nodes, 3 phi-args) stack of 9 * n_pieces elements; size the tile to that element budget. 

770 n_pieces = 3 * band_width + 3 

771 tile = max(1, _max_tile_elements // (9 * n_pieces)) 

772 

773 result = np.full(n_out, np.nan) 

774 for t0 in range(0, n_out, tile): 

775 t1 = min(t0 + tile, n_out) 

776 nt = t1 - t0 

777 good = good_all[t0:t1] 

778 v_lo = v_lo_all[t0:t1] 

779 v_hi = v_hi_all[t0:t1] 

780 cols = np.clip(jlo_all[t0:t1, None] + band[None, :], 0, n_edges - 1) 

781 fcb = flow_cum[cols] 

782 vlo = v_lo[:, None] 

783 vhi = v_hi[:, None] 

784 

785 # Direction-specific breakpoint families: the V_p values where a phi argument (a clipped 

786 # look-back/forward limit) crosses a flow edge or a validity bound. Clip to the support 

787 # and sort to form the integration pieces (zero-width pieces contribute nothing). 

788 if sign < 0: 

789 cand = np.concatenate([(vhi - fcb) / r, (vlo - fcb) / r, fcb / r, vlo / r, vhi / r], axis=1) 

790 else: 

791 cand = np.concatenate( 

792 [(fcb - vlo) / r, (fcb - vhi) / r, (v_end - fcb) / r, (v_end - vlo) / r, (v_end - vhi) / r], axis=1 

793 ) 

794 np.clip(cand, support_lo, support_hi, out=cand) 

795 cand.sort(axis=1) 

796 edges = np.concatenate([np.full((nt, 1), support_lo), cand, np.full((nt, 1), support_hi)], axis=1) 

797 lo = edges[:, :-1] 

798 hi = edges[:, 1:] 

799 mid = 0.5 * (lo + hi) 

800 half = 0.5 * (hi - lo) 

801 nodes = np.stack([lo, mid, hi], axis=0) # (3, nt, n_pieces) 

802 

803 # G(V_p) at the three quadrature nodes (piece lo/mid/hi) as a difference of phi. The constant 

804 # term phi(v_hi)/phi(v_lo) does not depend on V_p, so evaluate it once per bin and batch only 

805 # the three V_p-dependent phi arguments. With spinup="constant" the full bin is integrated 

806 # against the extrapolated phi (length is the full bin width); with a float spinup the limits 

807 # clamp to the streamtube's covered sub-interval and the covered length renormalizes. 

808 if sign < 0: 

809 a_hi = vhi[None] - r * nodes 

810 if extrapolate: 

811 v_start = np.broadcast_to(vlo[None], a_hi.shape) 

812 a_lo = vlo[None] - r * nodes 

813 length = np.broadcast_to(vhi[None] - vlo[None], a_hi.shape) 

814 else: 

815 v_start = np.maximum(vlo[None], r * nodes) 

816 a_lo = np.maximum(vlo[None] - r * nodes, 0.0) 

817 length = np.maximum(vhi[None] - v_start, 0.0) 

818 phi_const = _eval_phi(v_hi, phi_v, phi_t, phi_knot, phi_rate) 

819 phi_stack = _eval_phi(np.stack([v_start, a_hi, a_lo]), phi_v, phi_t, phi_knot, phi_rate) 

820 g = phi_const[None, :, None] - phi_stack[0] - phi_stack[1] + phi_stack[2] 

821 else: 

822 a_lo = vlo[None] + r * nodes 

823 if extrapolate: 

824 v_stop = np.broadcast_to(vhi[None], a_lo.shape) 

825 a_hi = vhi[None] + r * nodes 

826 length = np.broadcast_to(vhi[None] - vlo[None], a_lo.shape) 

827 else: 

828 v_stop = np.minimum(vhi[None], v_end - r * nodes) 

829 a_hi = np.minimum(vhi[None] + r * nodes, v_end) 

830 length = np.maximum(v_stop - vlo[None], 0.0) 

831 phi_const = _eval_phi(v_lo, phi_v, phi_t, phi_knot, phi_rate) 

832 phi_stack = _eval_phi(np.stack([a_hi, a_lo, v_stop]), phi_v, phi_t, phi_knot, phi_rate) 

833 g = phi_stack[0] - phi_stack[1] - phi_stack[2] + phi_const[None, :, None] 

834 g = np.where(length > 0, g, 0.0) 

835 length = np.where(length > 0, length, 0.0) 

836 g_lo, g_mid, g_hi = g 

837 l_lo, l_mid, l_hi = length 

838 

839 # Gamma partial moments over each piece: one CDF per shape on the piece edges, then diff. 

840 # M1/M2 follow from the shifted-gamma partial-moment identities. 

841 cdf_edges = edges - loc 

842 f0 = gamma_dist.cdf(cdf_edges, alpha, scale=beta) 

843 f1 = gamma_dist.cdf(cdf_edges, alpha + 1, scale=beta) 

844 f2 = gamma_dist.cdf(cdf_edges, alpha + 2, scale=beta) 

845 m0 = np.diff(f0, axis=1) 

846 d1 = np.diff(f1, axis=1) 

847 m1 = alpha * beta * d1 + loc * m0 

848 d2 = np.diff(f2, axis=1) 

849 m2 = alpha * (alpha + 1) * beta**2 * d2 + 2 * loc * alpha * beta * d1 + loc * loc * m0 

850 

851 # Piece-centred gamma moments mu_k = int (x - mid)^k f over each piece. They are formed by 

852 # shifting the raw partial moments (m1 - mid m0, m2 - 2 mid m1 + mid^2 m0), which for R > 1 

853 # subtracts large near-equal terms (|mid| up to support_hi, with m1/m2 carrying the matching 

854 # mid powers) and catastrophically cancels on near-degenerate pieces. They are, however, 

855 # bounded purely by the piece geometry -- |x - mid| <= half over the piece -- so clip each to 

856 # its exact range. This is not a strict no-op on well-conditioned pieces (a raw value may sit 

857 # a rounding step outside the tight bound; clamping it back is a negligible correction). It 

858 # earns its place on the near-degenerate pieces, where the cancellation noise -- which 

859 # otherwise pairs with the 1/half^2 second-difference below to manufacture a spurious 

860 # contribution -- is forced back into the physically valid band. 

861 b0 = np.maximum(m0, 0.0) 

862 mu1 = np.clip(m1 - mid * m0, -half * b0, half * b0) 

863 mu2 = np.clip(m2 - 2 * mid * m1 + mid**2 * m0, 0.0, half**2 * b0) 

864 

865 # G is quadratic per piece, L linear; reconstruct each from its three nodes (Lagrange- 

866 # exact) centred at mid and contract with the moments: int (a(x-mid)^2 + b(x-mid) + c) f 

867 # = a mu2 + b mu1 + c mu0. Zero-width pieces (duplicate breakpoints) divide by half == 0; 

868 # their masked result is discarded by np.where. 

869 safe = half > 0 

870 with np.errstate(divide="ignore", invalid="ignore"): 

871 g_a = np.where(safe, (g_lo - 2 * g_mid + g_hi) / (2 * half**2), 0.0) 

872 g_b = np.where(safe, (g_hi - g_lo) / (2 * half), 0.0) 

873 l_b = np.where(safe, (l_hi - l_lo) / (2 * half), 0.0) 

874 int_g = g_a * mu2 + g_b * mu1 + g_mid * m0 

875 int_l = l_b * mu1 + l_mid * m0 

876 

877 num = int_g.sum(axis=1) 

878 den = int_l.sum(axis=1) 

879 covered = good & (den > 0) 

880 if spinup_threshold > 0.0: 

881 # float spinup: emit only where the covered fraction of the APVD reaches the threshold. 

882 # den is the covered sub-mass; (v_hi - v_lo) * m0.sum is the fully-covered reference (equal 

883 # up to reassociation ulps when fully covered). The relative slack keeps the strictest 

884 # threshold (1.0) from rejecting fully-covered bins on that float noise. 

885 covered &= den >= (spinup_threshold - _SPINUP_GATE_RELTOL) * (v_hi - v_lo) * m0.sum(axis=1) 

886 tile_result = np.full(nt, np.nan) 

887 tile_result[covered] = num[covered] / den[covered] 

888 result[t0:t1] = tile_result 

889 

890 # Zero-throughflow output bins (Q = 0 over the bin) have a cumulative-volume window only as wide 

891 # as the strictly-monotone ulp bump, so the bin-average num/den ratio above catastrophically 

892 # cancels. There the bin-average degenerates to its well-defined zero-width-bin limit: the 

893 # pointwise gamma-mean residence time at the bin's cumulative volume (matching full). 

894 dvol = vol_out[1:] - vol_out[:-1] 

895 tol = 1e6 * np.spacing(float(np.max(np.abs(flow_cum)))) if flow_cum.size else 0.0 

896 degenerate = good_all & (dvol <= tol) 

897 if np.any(degenerate): 

898 v = vol_out[:-1][degenerate] # (k,) bin cumulative volume (constant over a zero-flow bin) 

899 # Over a zero-flow bin the volume is fixed but output time advances, so tau ramps linearly with 

900 # output time; its bin-average is the value at the bin's time midpoint, not at T(v). 

901 t_v = (0.5 * (cout_tedges_days[:-1] + cout_tedges_days[1:]))[degenerate] 

902 # Upper V_p integration bound, mirroring the main loop's spin-up handling. With 

903 # spinup="constant" the extrapolated phi map warm-starts the full support; otherwise a V_p 

904 # whose look-back/forward parcel leaves the record (e2i: v - r*V_p < 0, i2e: v + r*V_p > 

905 # v_end) is in spin-up, so integrate only the covered sub-range [support_lo, vp_hi] and let the 

906 # covered sub-mass renormalize the mean (den_pt below), keeping the parcel inside the raw phi map. 

907 if extrapolate: 

908 vp_hi = np.full(v.shape, support_hi) 

909 else: 

910 vp_hi = np.clip((v if sign < 0 else v_end - v) / r, support_lo, support_hi) 

911 # tau(v, V_p) = sign * (T(v + sign*r*V_p) - t_mid) is piecewise-linear in V_p with knots where 

912 # v + sign*r*V_p crosses a phi_v edge; integrate it against the gamma density over the sub-range. 

913 bp = np.clip(sign * (phi_v[None, :] - v[:, None]) / r, support_lo, vp_hi[:, None]) 

914 bp.sort(axis=1) 

915 edges_pt = np.concatenate([np.full((v.size, 1), support_lo), bp, vp_hi[:, None]], axis=1) 

916 lo_pt, hi_pt = edges_pt[:, :-1], edges_pt[:, 1:] 

917 nodes_pt = np.stack([lo_pt, 0.5 * (lo_pt + hi_pt), hi_pt]) # (3, k, n_pieces) 

918 a_pt = v[None, :, None] + sign * r * nodes_pt 

919 tau_lo, tau_mid, tau_hi = sign * ( 

920 np.interp(a_pt.ravel(), phi_v, phi_t).reshape(a_pt.shape) - t_v[None, :, None] 

921 ) 

922 width = np.where(hi_pt > lo_pt, hi_pt - lo_pt, 1.0) 

923 slope = np.where(hi_pt > lo_pt, (tau_hi - tau_lo) / width, 0.0) 

924 mid_pt = 0.5 * (lo_pt + hi_pt) 

925 cdf_pt = edges_pt - loc 

926 m0_pt = np.diff(gamma_dist.cdf(cdf_pt, alpha, scale=beta), axis=1) 

927 m1_pt = alpha * beta * np.diff(gamma_dist.cdf(cdf_pt, alpha + 1, scale=beta), axis=1) + loc * m0_pt 

928 num_pt = (tau_mid * m0_pt + slope * (m1_pt - mid_pt * m0_pt)).sum(axis=1) 

929 den_pt = m0_pt.sum(axis=1) # covered sub-mass over [support_lo, vp_hi] 

930 covered_pt = den_pt > 0 

931 if spinup_threshold > 0.0: 

932 # float spinup: emit only where the covered fraction reaches the threshold. den_pt is the 

933 # covered sub-mass; the full-support mass is the fully-covered reference. The same relative 

934 # slack as the main loop keeps threshold 1.0 robust to reassociation ulps in den_pt. 

935 full_mass = gamma_dist.cdf(support_hi - loc, alpha, scale=beta) - gamma_dist.cdf( 

936 support_lo - loc, alpha, scale=beta 

937 ) 

938 covered_pt &= den_pt >= (spinup_threshold - _SPINUP_GATE_RELTOL) * full_mass 

939 with np.errstate(divide="ignore", invalid="ignore"): 

940 result[degenerate] = np.where(covered_pt, num_pt / den_pt, np.nan) 

941 return result 

942 

943 

944def fraction_explained_full( 

945 *, 

946 flow: npt.ArrayLike, 

947 tedges: pd.DatetimeIndex | np.ndarray, 

948 cout_tedges: pd.DatetimeIndex | np.ndarray, 

949 aquifer_pore_volumes: npt.ArrayLike, 

950 direction: str = "extraction_to_infiltration", 

951 retardation_factor: float = 1.0, 

952) -> npt.NDArray[np.floating]: 

953 r""" 

954 Advective coverage per pore volume: the fraction of each output bin explained by the record. 

955 

956 For each streamtube (entry in ``aquifer_pore_volumes``) and each output bin 

957 ``[cout_tedges[i], cout_tedges[i + 1])`` this returns the flow-weighted fraction of the bin whose 

958 retarded **advective** parcel lies inside the supplied flow record -- the share of the bin's 

959 throughflow volume for which the look-back infiltration (``extraction_to_infiltration``) or 

960 look-forward extraction (``infiltration_to_extraction``) event is covered by ``cin``. ``1.0`` 

961 means the whole bin is explained for that pore volume, ``0.0`` that none of it is. The full 

962 ``(n_pore_volumes, n_output_bins)`` array is returned -- one row per pore volume, mirroring 

963 :func:`full`. 

964 

965 .. warning:: 

966 

967 This is a **purely advective** diagnostic: it uses only the cumulative-volume look-back 

968 ``V(t) - retardation_factor * V_p`` and ignores molecular diffusion and longitudinal 

969 dispersion. Those spread each output bin over a *range* of infiltration times whose kernel 

970 tails extend outside any finite record, so a bin that is advectively "fully explained" 

971 (``1.0``) is not fully informed once dispersion is present. For the dispersive informed 

972 fraction of an advection-dispersion model use the captured kernel mass (the column sum of the 

973 diffusion coefficient matrix), not this function. 

974 

975 Parameters 

976 ---------- 

977 flow : array-like 

978 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one. 

979 tedges : pandas.DatetimeIndex 

980 Time edges for the flow data; ``n + 1`` edges for ``n`` flow values. 

981 cout_tedges : pandas.DatetimeIndex 

982 Output time edges; ``n + 1`` edges define ``n`` output bins. 

983 aquifer_pore_volumes : float or array-like 

984 Pore volume(s) of the aquifer [m³], one per streamtube. 

985 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

986 Direction of the flow calculation. Default is 'extraction_to_infiltration'. 

987 retardation_factor : float, optional 

988 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0. 

989 

990 Returns 

991 ------- 

992 numpy.ndarray 

993 Advective coverage [dimensionless], shape ``(n_pore_volumes, n_output_bins)``, values in 

994 ``[0, 1]``. Output bins lying wholly outside ``tedges`` are ``NaN``. Negative or ``NaN`` 

995 ``flow`` makes the cumulative-volume map non-monotone or undefined; the whole array is 

996 returned as ``NaN`` (the function refuses rather than raising). 

997 

998 Raises 

999 ------ 

1000 ValueError 

1001 If ``tedges`` does not have exactly one more element than ``flow``, or if ``direction`` is not 

1002 ``'extraction_to_infiltration'`` or ``'infiltration_to_extraction'``. 

1003 

1004 See Also 

1005 -------- 

1006 fraction_explained_mean : Equal-weight mean of this over a discrete APVD 

1007 fraction_explained_gamma : Closed-form coverage for a (shifted) gamma APVD 

1008 full : Per-pore-volume mean residence time over output bins 

1009 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction 

1010 """ 

1011 if direction not in {"extraction_to_infiltration", "infiltration_to_extraction"}: 

1012 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'" 

1013 raise ValueError(msg) 

1014 

1015 aquifer_pore_volumes = np.atleast_1d(aquifer_pore_volumes) 

1016 tedges = pd.DatetimeIndex(tedges) 

1017 cout_tedges = pd.DatetimeIndex(cout_tedges) 

1018 flow = np.asarray(flow, dtype=float) 

1019 

1020 if len(tedges) != len(flow) + 1: 

1021 msg = "tedges must have one more element than flow" 

1022 raise ValueError(msg) 

1023 

1024 n_out = len(cout_tedges) - 1 

1025 # Negative or non-finite flow makes V(t) non-monotone or undefined; refuse to answer (match siblings). 

1026 if np.any(flow < 0) or np.any(np.isnan(flow)): 

1027 return np.full((len(aquifer_pore_volumes), n_out), np.nan) 

1028 

1029 tedges_days = tedges_to_days(tedges) 

1030 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0]) 

1031 flow_cum = cumulative_flow_volume(flow, np.diff(tedges_days)) 

1032 v_total = flow_cum[-1] 

1033 

1034 vol_out = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days, left=np.nan, right=np.nan) 

1035 v_lo = vol_out[:-1] 

1036 v_hi = vol_out[1:] 

1037 dvol = v_hi - v_lo 

1038 r_vp = retardation_factor * aquifer_pore_volumes 

1039 

1040 # The flow-weighted (uniform-in-volume) coverage of [v_lo, v_hi] is a clipped ramp of the 

1041 # in-record indicator: e2i needs V >= R*V_p, i2e needs V <= v_total - R*V_p. A zero-throughflow 

1042 # bin (dvol <= tol) has no volume to average over, so use the pointwise indicator at its volume. 

1043 tol = 1e6 * np.spacing(float(np.max(np.abs(flow_cum)))) if flow_cum.size else 0.0 

1044 with np.errstate(divide="ignore", invalid="ignore"): 

1045 if direction == "extraction_to_infiltration": 

1046 frac = np.clip((v_hi[None, :] - r_vp[:, None]) / dvol[None, :], 0.0, 1.0) 

1047 point = (r_vp[:, None] <= v_lo[None, :]).astype(float) 

1048 else: 

1049 frac = np.clip(((v_total - r_vp[:, None]) - v_lo[None, :]) / dvol[None, :], 0.0, 1.0) 

1050 point = (r_vp[:, None] <= v_total - v_lo[None, :]).astype(float) 

1051 out = np.where(dvol[None, :] > tol, frac, point) 

1052 out[:, ~(np.isfinite(v_lo) & np.isfinite(v_hi))] = np.nan 

1053 return out 

1054 

1055 

1056def fraction_explained_mean( 

1057 *, 

1058 flow: npt.ArrayLike, 

1059 tedges: pd.DatetimeIndex | np.ndarray, 

1060 cout_tedges: pd.DatetimeIndex | np.ndarray, 

1061 aquifer_pore_volumes: npt.ArrayLike, 

1062 direction: str = "extraction_to_infiltration", 

1063 retardation_factor: float = 1.0, 

1064) -> npt.NDArray[np.floating]: 

1065 """ 

1066 Advective coverage for a discrete APVD: equal-weight mean of :func:`fraction_explained_full`. 

1067 

1068 Collapses the pore-volume axis of :func:`fraction_explained_full` to a single per-output-bin 

1069 series by averaging over the equally-weighted streamtubes in ``aquifer_pore_volumes`` -- the 

1070 coverage analogue of :func:`mean`. ``1.0`` means every streamtube fully explains the 

1071 bin, ``0.0`` that none do. 

1072 

1073 .. warning:: 

1074 

1075 Purely advective -- see :func:`fraction_explained_full`. Molecular diffusion and longitudinal 

1076 dispersion spreading are not captured, so a value of ``1.0`` is advective coverage, not full 

1077 dispersive information. 

1078 

1079 Parameters 

1080 ---------- 

1081 flow : array-like 

1082 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one. 

1083 tedges : pandas.DatetimeIndex 

1084 Time edges for the flow data; ``n + 1`` edges for ``n`` flow values. 

1085 cout_tedges : pandas.DatetimeIndex 

1086 Output time edges; ``n + 1`` edges define ``n`` output bins. 

1087 aquifer_pore_volumes : array-like 

1088 Discrete pore volumes [m³], one per (equally-weighted) streamtube. 

1089 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

1090 Direction of the flow calculation. Default is 'extraction_to_infiltration'. 

1091 retardation_factor : float, optional 

1092 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0. 

1093 

1094 Returns 

1095 ------- 

1096 numpy.ndarray 

1097 Advective coverage [dimensionless], shape ``(n_output_bins,)``, values in ``[0, 1]``. 

1098 Output bins lying wholly outside ``tedges`` are ``NaN``. Negative or ``NaN`` ``flow`` makes 

1099 the cumulative-volume map non-monotone or undefined; the whole series is returned as ``NaN`` 

1100 (the function refuses rather than raising). 

1101 

1102 See Also 

1103 -------- 

1104 fraction_explained_full : Per-pore-volume coverage (the array this averages) 

1105 fraction_explained_gamma : Closed-form coverage for a (shifted) gamma APVD 

1106 mean : Equally-weighted mean residence time for a discrete APVD 

1107 

1108 Examples 

1109 -------- 

1110 >>> import numpy as np 

1111 >>> import pandas as pd 

1112 >>> from gwtransport.residence_time import fraction_explained_mean 

1113 >>> tedges = pd.date_range("2020-01-01", periods=11, freq="D") 

1114 >>> flow = np.full(10, 100.0) 

1115 >>> fraction_explained_mean( 

1116 ... flow=flow, 

1117 ... tedges=tedges, 

1118 ... cout_tedges=tedges, 

1119 ... aquifer_pore_volumes=[200.0, 1500.0], 

1120 ... ).tolist() 

1121 [0.0, 0.0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 

1122 """ 

1123 return fraction_explained_full( 

1124 flow=flow, 

1125 tedges=tedges, 

1126 cout_tedges=cout_tedges, 

1127 aquifer_pore_volumes=aquifer_pore_volumes, 

1128 direction=direction, 

1129 retardation_factor=retardation_factor, 

1130 ).mean(axis=0) 

1131 

1132 

1133def fraction_explained_gamma( 

1134 *, 

1135 flow: npt.ArrayLike, 

1136 tedges: pd.DatetimeIndex | np.ndarray, 

1137 cout_tedges: pd.DatetimeIndex | np.ndarray, 

1138 mean: float | None = None, 

1139 std: float | None = None, 

1140 loc: float = 0.0, 

1141 alpha: float | None = None, 

1142 beta: float | None = None, 

1143 direction: str = "extraction_to_infiltration", 

1144 retardation_factor: float = 1.0, 

1145) -> npt.NDArray[np.floating]: 

1146 r""" 

1147 Closed-form advective coverage for a (shifted) gamma APVD. 

1148 

1149 The expectation of the advective in-record indicator over a (shifted) gamma aquifer pore-volume 

1150 distribution (APVD), parameterized by either ``(mean, std, loc)`` or ``(alpha, beta, loc)``, is 

1151 taken in closed form -- the continuum analogue of :func:`fraction_explained_mean`, with no 

1152 pore-volume binning. For each output bin it returns the flow-weighted fraction of the bin whose 

1153 advective parcel lies inside the flow record. 

1154 

1155 The flow-weighted bin average :math:`\frac{1}{\Delta V}\int_{V_\mathrm{lo}}^{V_\mathrm{hi}} 

1156 F_{V_p}(\mathrm{threshold}(V))\,dV` (with :math:`\mathrm{threshold}(V) = V / R` for 

1157 ``extraction_to_infiltration`` and :math:`(V_\mathrm{end} - V) / R` for 

1158 ``infiltration_to_extraction``) is evaluated from the antiderivative of the shifted-gamma CDF, 

1159 

1160 .. math:: 

1161 

1162 \Phi(x) = \int_\mathrm{loc}^{x} F_{V_p}(s)\,ds 

1163 = y\,P(\alpha, y/\beta) - \alpha\beta\,P(\alpha + 1, y/\beta), 

1164 \qquad y = \max(x - \mathrm{loc},\, 0), 

1165 

1166 with :math:`P` the regularized lower incomplete gamma function -- two CDF evaluations per output 

1167 edge, no quadrature and no pore-volume binning. 

1168 

1169 .. warning:: 

1170 

1171 Purely advective -- see :func:`fraction_explained_full`. Molecular diffusion and longitudinal 

1172 dispersion are not captured; a value of ``1.0`` is advective coverage, not full dispersive 

1173 information. 

1174 

1175 Parameters 

1176 ---------- 

1177 flow : array-like 

1178 Flow rate of water in the aquifer [m³/day]. Length matches ``tedges`` minus one. 

1179 tedges : pandas.DatetimeIndex 

1180 Time edges for the flow data; ``n + 1`` edges for ``n`` flow values. 

1181 cout_tedges : pandas.DatetimeIndex 

1182 Output time edges; ``n + 1`` edges define ``n`` output bins. 

1183 mean : float, optional 

1184 Mean of the gamma APVD [m³]. Must be strictly greater than ``loc``. Provide either 

1185 ``(mean, std)`` or ``(alpha, beta)``. 

1186 std : float, optional 

1187 Standard deviation of the gamma APVD [m³]. Must be positive. 

1188 loc : float, optional 

1189 Location (lower bound of support) of the gamma APVD [m³]. Must satisfy ``0 <= loc < mean``. 

1190 Default is 0.0. 

1191 alpha : float, optional 

1192 Shape parameter of the gamma APVD (must be > 0). 

1193 beta : float, optional 

1194 Scale parameter of the gamma APVD (must be > 0). 

1195 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

1196 Direction of the flow calculation. Default is 'extraction_to_infiltration'. 

1197 retardation_factor : float, optional 

1198 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0. 

1199 

1200 Returns 

1201 ------- 

1202 numpy.ndarray 

1203 Advective coverage [dimensionless], shape ``(n_output_bins,)``, values in ``[0, 1]``. 

1204 Output bins lying wholly outside ``tedges`` are ``NaN``. Negative or ``NaN`` ``flow`` makes 

1205 the cumulative-volume map non-monotone or undefined; the whole series is returned as ``NaN`` 

1206 (the function refuses rather than raising). 

1207 

1208 Raises 

1209 ------ 

1210 ValueError 

1211 If ``tedges`` does not have exactly one more element than ``flow``, or if ``direction`` is 

1212 not ``'extraction_to_infiltration'`` or ``'infiltration_to_extraction'``. Gamma parameter 

1213 validation is delegated to :func:`gwtransport.gamma.parse_parameters`. 

1214 

1215 See Also 

1216 -------- 

1217 fraction_explained_mean : Discrete equal-weight APVD coverage 

1218 fraction_explained_full : Per-pore-volume coverage 

1219 gamma : Closed-form mean residence time for a (shifted) gamma APVD 

1220 :ref:`concept-gamma-distribution` : Two-parameter pore volume model 

1221 """ 

1222 if direction not in {"extraction_to_infiltration", "infiltration_to_extraction"}: 

1223 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'" 

1224 raise ValueError(msg) 

1225 alpha, beta, loc = parse_parameters(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta) 

1226 

1227 tedges = pd.DatetimeIndex(tedges) 

1228 cout_tedges = pd.DatetimeIndex(cout_tedges) 

1229 flow = np.asarray(flow, dtype=float) 

1230 

1231 if len(tedges) != len(flow) + 1: 

1232 msg = "tedges must have one more element than flow" 

1233 raise ValueError(msg) 

1234 

1235 n_out = len(cout_tedges) - 1 

1236 if np.any(flow < 0) or np.any(np.isnan(flow)): 

1237 return np.full(n_out, np.nan) 

1238 

1239 r = retardation_factor 

1240 tedges_days = tedges_to_days(tedges) 

1241 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0]) 

1242 flow_cum = cumulative_flow_volume(flow, np.diff(tedges_days)) 

1243 v_total = flow_cum[-1] 

1244 

1245 vol_out = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days, left=np.nan, right=np.nan) 

1246 v_lo = vol_out[:-1] 

1247 v_hi = vol_out[1:] 

1248 dvol = v_hi - v_lo 

1249 

1250 # threshold(V) per output edge, evaluated once on the full edge array so each shared interior 

1251 # edge's CDF is computed a single time (the per-bin lo/hi are then slices of these): 

1252 # 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) 

1253 # cdf(x) = F_Vp(x) = P(alpha, y/beta) 

1254 threshold = (vol_out if direction == "extraction_to_infiltration" else v_total - vol_out) / r 

1255 y = np.maximum(threshold - loc, 0.0) 

1256 cdf_edge = gamma_dist.cdf(y, alpha, scale=beta) 

1257 phi_edge = y * cdf_edge - alpha * beta * gamma_dist.cdf(y, alpha + 1, scale=beta) 

1258 

1259 # Flow-weighted bin average (1/dvol) int F_Vp(threshold(V)) dV = (R/dvol) [Phi(hi) - Phi(lo)]; the 

1260 # i2e threshold decreases in V so its edge order flips. A zero-throughflow bin (dvol <= tol) 

1261 # degenerates to the pointwise CDF at the bin's lower-edge volume. 

1262 tol = 1e6 * np.spacing(float(np.max(np.abs(flow_cum)))) if flow_cum.size else 0.0 

1263 with np.errstate(divide="ignore", invalid="ignore"): 

1264 if direction == "extraction_to_infiltration": 

1265 ratio = (r / dvol) * (phi_edge[1:] - phi_edge[:-1]) 

1266 else: 

1267 ratio = (r / dvol) * (phi_edge[:-1] - phi_edge[1:]) 

1268 point = cdf_edge[:-1] 

1269 out = np.where(dvol > tol, ratio, point) 

1270 out[~(np.isfinite(v_lo) & np.isfinite(v_hi))] = np.nan 

1271 return out 

1272 

1273 

1274def freundlich_retardation( 

1275 *, 

1276 concentration: npt.ArrayLike, 

1277 freundlich_k: float, 

1278 freundlich_n: float, 

1279 bulk_density: float, 

1280 porosity: float, 

1281) -> npt.NDArray[np.floating]: 

1282 """ 

1283 Compute concentration-dependent retardation factors using Freundlich isotherm. 

1284 

1285 The Freundlich isotherm relates sorbed concentration s to aqueous concentration C using the 

1286 heterogeneity-index convention (matching :class:`gwtransport.fronttracking.math.FreundlichSorption` 

1287 and :func:`gwtransport.advection.infiltration_to_extraction_nonlinear_sorption`, so a fitted 

1288 ``freundlich_n`` is portable across the package):: 

1289 

1290 s = k_f * C ^ (1 / n) 

1291 

1292 The retardation factor is computed as:: 

1293 

1294 R = 1 + (rho_b/θ) * ds/dC = 1 + (rho_b/θ) * k_f * (1/n) * C^(1/n - 1) 

1295 

1296 Parameters 

1297 ---------- 

1298 concentration : array-like 

1299 Concentration of compound in water [mass/volume]. One value per time bin, consistent 

1300 with the ``flow`` array passed to the transport function. 

1301 freundlich_k : float 

1302 Freundlich coefficient [(m³/kg)^(1/n)] (under s = k_f * C^(1/n) with s dimensionless 

1303 and C in [kg/m³]). 

1304 freundlich_n : float 

1305 Freundlich sorption exponent [dimensionless] (heterogeneity index; ``n = 1`` recovers a 

1306 linear isotherm). 

1307 bulk_density : float 

1308 Bulk density of aquifer material [mass/volume]. 

1309 porosity : float 

1310 Porosity of aquifer [dimensionless, 0-1]. 

1311 

1312 Returns 

1313 ------- 

1314 numpy.ndarray 

1315 Retardation factors for each flow interval. 

1316 Length equals len(concentration) for use as retardation_factor in the transport functions. 

1317 

1318 Raises 

1319 ------ 

1320 ValueError 

1321 If ``porosity`` is not in ``(0, 1)``, if ``bulk_density`` is not positive, if 

1322 ``freundlich_k`` is negative, or if any ``concentration`` is non-positive while 

1323 ``freundlich_n > 1`` (the retardation factor diverges as ``C -> 0``). 

1324 

1325 See Also 

1326 -------- 

1327 full : Compute residence times from flow and pore volume 

1328 gwtransport.advection.infiltration_to_extraction_nonlinear_sorption : Transport with nonlinear sorption 

1329 :ref:`concept-nonlinear-sorption` : Freundlich isotherm and concentration-dependent retardation 

1330 

1331 Examples 

1332 -------- 

1333 >>> concentration = np.array([0.1, 0.2, 0.3]) # same length as flow 

1334 >>> R = freundlich_retardation( 

1335 ... concentration=concentration, 

1336 ... freundlich_k=0.5, 

1337 ... freundlich_n=2.0, 

1338 ... bulk_density=1600, # kg/m³ 

1339 ... porosity=0.35, 

1340 ... ) 

1341 >>> # Use R as retardation_factor in the transport functions 

1342 """ 

1343 concentration = np.asarray(concentration) 

1344 

1345 if not 0 < porosity < 1: 

1346 msg = f"Porosity must be in (0, 1), got {porosity}" 

1347 raise ValueError(msg) 

1348 if bulk_density <= 0: 

1349 msg = f"Bulk density must be positive, got {bulk_density}" 

1350 raise ValueError(msg) 

1351 if freundlich_k < 0: 

1352 msg = f"Freundlich K must be non-negative, got {freundlich_k}" 

1353 raise ValueError(msg) 

1354 

1355 # For n > 1 the Freundlich retardation factor 1 + (rho_b/theta) * k_f * (1/n) * C^(1/n-1) 

1356 # diverges as C -> 0 (the exponent 1/n - 1 < 0). Silently clamping concentration would produce 

1357 # a very large but finite value that depends on an arbitrary regularization constant; instead, 

1358 # refuse the call so the user can decide how to handle non-positive concentrations. 

1359 if freundlich_n > 1.0 and np.any(concentration <= 0): 

1360 msg = "concentration must be strictly positive when freundlich_n > 1 (retardation diverges as C -> 0)" 

1361 raise ValueError(msg) 

1362 

1363 inv_n = 1.0 / freundlich_n 

1364 return 1.0 + (bulk_density / porosity) * freundlich_k * inv_n * concentration ** (inv_n - 1.0)