Coverage for src/gwtransport/diffusion.py: 98%
200 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 21:17 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 21:17 +0000
1r"""
2Analytical solutions for 1D advection-dispersion transport.
4Water infiltrates and is transported in parallel along multiple aquifer pore volumes to
5extraction. For each aquifer pore volume, transport is 1D advection with microdispersion,
6molecular diffusion, and linear sorption; the spread across aquifer pore volumes provides
7macrodispersion. Forward and backward modeling are supported. The flow is assumed orthogonal.
9The orthogonal-flow (Cartesian) geometry is what makes the Kreft-Zuber breakthrough the exact
101D solution used below.
12Key functions:
14- :func:`infiltration_to_extraction` - Main transport function combining advection,
15 microdispersion, and molecular diffusion with explicit pore volume distribution and
16 streamline lengths.
18- :func:`extraction_to_infiltration` - Inverse operation (deconvolution with dispersion).
20- :func:`gamma_infiltration_to_extraction` - Gamma-distributed pore volumes with dispersion.
21 Models aquifer heterogeneity with 2-parameter gamma distribution. Parameterizable via
22 (alpha, beta) or (mean, std). Discretizes gamma distribution into equal-probability bins.
24- :func:`gamma_extraction_to_infiltration` - Gamma-distributed pore volumes, deconvolution
25 with dispersion. Symmetric inverse of gamma_infiltration_to_extraction.
27When to choose this module vs :mod:`gwtransport.diffusion_fast`
28---------------------------------------------------------------
30This is the reference implementation: it evaluates the bin-averaged Kreft-Zuber flux
31concentration by resolution-aware composite Gauss-Legendre quadrature (splitting at
32flow-bin boundaries, with extra front-centred panels wherever a sharp breakthrough front
33is otherwise under-resolved).
34Prefer it only when the output grid is coarser than the flow detail -- it integrates the
35full within-bin flow, which the closed-form :mod:`gwtransport.diffusion_fast` approximates as
36constant per output bin. Otherwise that module computes the same physics to machine
37precision for *every* parameter regime (including ``retardation_factor != 1`` with non-zero
38molecular diffusivity, whose flux correction it also evaluates in closed form) and is
39~80-90x faster (no quadrature, no residence-time inversion). Both modules accept
40per-streamtube ``streamline_length`` / ``molecular_diffusivity`` /
41``longitudinal_dispersivity`` arrays (heterogeneous flow paths -- partially-penetrating
42wells, wedge-shaped capture zones).
44Reported outlet concentration: Kreft-Zuber (1978) flux concentration
45---------------------------------------------------------------------
47The outlet concentration reported by this module is the **flux concentration**
49 C_F(L, t) = C_R(L, t) - (D_s / v_s) * dC_R/dx \|_{x=L}
51with the solute-front (retarded-frame) velocity v_s = Q L / (R V_pore) and the
52dispersion D_s = D_m + alpha_L * v_s, so the flux coefficient is
53D_s / v_s = D_m / v_s + alpha_L = R D_m / v_fluid + alpha_L (with the fluid
54velocity v_fluid = Q L / V_pore). The resident profile C_R solves the retarded
55ADE with advection v_s and dispersion D_s, so its flux-vs-resident correction
56must use v_s — not v_fluid; pairing v_s with the moving-frame variance below is
57what conserves mass for R > 1 with D_m > 0.
59— the solute mass flux at the outlet divided by the volumetric fluid flux. This
60is what is measured when sampling the extracted fluid. The resident
61concentration ``C_R`` is Bear (1972) eq. 10.6.4, the variable-flow moving-frame
62Ogata-Banks solution
64 C_R(L, V; t_j) = 0.5 * erfc((L - xi_j(V)) / (2 * sqrt(D_t(V))))
66with the dispersion variance accumulated in the moving (Lagrangian) frame:
68 D_t(V) = sigma^2(V) / 2 = D_m * tau(V) + alpha_L * xi(V)
70where:
72- D_m is the effective molecular (or thermal) diffusivity [m²/day]
73- alpha_L is the longitudinal dispersivity [m]
74- tau(V) is the elapsed time since infiltration [day], with V the cumulative
75 extracted volume
76- xi(V) = L (V - V_j) / (R V_pore) is the distance the parcel has actually
77 travelled [m]
79The K-Z flux-correction term is what makes the column-sum invariant
80``integral Q c_out dt = integral Q c_in dt`` hold under arbitrary variable Q.
81Without it, the leading-order C_R loses O(1/Pe) per column under variable Q +
82pure D_m (issue #180).
84Implementation: the bin-averaged C_F is computed by resolution-aware composite
85Gauss-Legendre quadrature in volume space, split at flow-bin boundaries so each
86sub-interval sees a linear t(V). Within a sub-interval the erf-like front has
87width ``sqrt(4*D_t)`` (in volume units); for near-zero dispersivity this can be
88orders of magnitude below the flow-bin width, so a single fixed-order rule
89cannot resolve it. Sub-intervals whose front is under-resolved are therefore
90tiled with front-centred panels (fine near the front, flat tails outside),
91which restores the column-mass invariant to ~1e-11 for every dispersion regime;
92smooth/already-resolved sub-intervals keep the plain single 16-point rule. The
93variance is evaluated at each quadrature node from the parcel's own tau and xi
94histories — never capped at the residence time. The K-Z identity requires
95Bear's formula to satisfy the variable-coefficient ADE exactly, which holds only
96when D_t is allowed to keep growing past breakthrough.
98Macrodispersion vs microdispersion
99----------------------------------
101This module adds microdispersion (alpha_L) and molecular diffusion (D_m) on top of
102macrodispersion captured by the pore volume distribution (APVD). Both represent velocity
103heterogeneity at different scales. Microdispersion is an aquifer property; macrodispersion
104depends additionally on hydrological boundary conditions. See :ref:`concept-dispersion-scales`
105for guidance on when to use each approach and how to avoid double-counting spreading effects.
107Streamtube assumption (no cross-sectional area parameter)
108---------------------------------------------------------
110Each entry in ``aquifer_pore_volumes`` is treated as an independent 1D streamtube. There is
111no cross-sectional area parameter: the variance budget uses ``2 D_m tau`` (molecular
112diffusion in time) and ``2 alpha_L xi`` (microdispersion in travelled distance), with
113the streamline length ``L`` and the pore volume ``V_pore`` together fixing the implicit
114streamtube cross-section ``A = V_pore / L``. Callers who need distributed-area effects must
115provide multiple streamtubes (via ``aquifer_pore_volumes`` or the gamma-parameterised
116wrappers).
118References
119----------
120Bear, J. (1972). Dynamics of Fluids in Porous Media. American Elsevier
121Publishing Company. Equation 10.6.4 (variable-flow Ogata-Banks form). Provides
122the resident concentration ``C_R``.
124Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion
125equation and its solutions for different initial and boundary conditions.
126Chemical Engineering Science, 33(11), 1471-1480. Eq. 2 gives the resident-to-
127flux concentration transformation; Eq. 1 is the mass-balance identity that
128makes the column-sum invariant exact.
129"""
131import numpy as np
132import numpy.typing as npt
133import pandas as pd
134from scipy import special
136from gwtransport import gamma
137from gwtransport._time import dt_to_days, tedges_to_days
138from gwtransport._validation import (
139 _validate_no_nan,
140 _validate_non_negative_array,
141 _validate_positive_array,
142 _validate_retardation_factor,
143 _validate_scalar_or_matching_length,
144 _validate_tedges_parity,
145)
146from gwtransport.residence_time import fraction_explained_full
147from gwtransport.utils import cumulative_flow_volume, solve_inverse_transport
149# Numerical tolerance for coefficient sum to determine valid output bins
150EPSILON_COEFF_SUM = 1e-10
152# Gauss-Legendre quadrature nodes and weights for volume-space integration
153_GL_NODES, _GL_WEIGHTS = np.polynomial.legendre.leggauss(16)
155# Resolution-aware composite-quadrature template for the erf-like breakthrough front.
156# When the front width sqrt(4*D_t) (in volume units) is much smaller than a
157# (cell, flow-bin) sub-interval, plain 16-point GL cannot resolve it. Cells flagged
158# as under-resolved get panels placed at ``V_front + front_width * _FRONT_OFFSETS``
159# (clipped to the sub-interval); the two outer panels then cover the flat erf tails,
160# where 16-point GL is already exact. Panels near the front are ~1 front-width wide,
161# spanning +-6 front-widths (erf and the flux-correction Gaussian are flat to ~1e-16
162# beyond that). A cell is refined only when its front lies inside the sub-interval and
163# the sub-interval is wider than _REFINE_RATIO front-widths, so adaptivity triggers
164# only near sharp fronts (smooth regimes keep the plain single-panel cost and answer).
165_FRONT_REACH = 6.0
166_FRONT_OFFSETS = np.arange(-_FRONT_REACH, _FRONT_REACH + 0.5, 1.0)
167_REFINE_RATIO = 4.0
170def _cfrac_mean_volume(
171 *,
172 step_widths: npt.NDArray[np.floating],
173 cumulative_volume_at_cout_tedges: npt.NDArray[np.floating],
174 cumulative_volume_at_cin_tedges: npt.NDArray[np.floating],
175 tedges_days: npt.NDArray[np.floating],
176 molecular_diffusivity: float,
177 longitudinal_dispersivity: float,
178 r_vpv: float,
179 streamline_len: float,
180) -> npt.NDArray[np.floating]:
181 r"""Compute bin-averaged flux concentration at the outlet for each cell.
183 For each cell (cout-bin *i*, cin-edge *j*), computes the flow-weighted
184 average of the Kreft-Zuber (1978) **flux concentration** at the outlet:
186 .. math::
188 \text{frac}_{i,j} = \frac{1}{\Delta V_i}
189 \int_{V_i}^{V_{i+1}} C_F\!\left(L,\,V;\,t_j\right) dV
191 where :math:`C_F = C_R - (D_s / v_s) \, \partial_x C_R\big|_{x=L}` and
192 :math:`C_R` is Bear's (1972) moving-frame solution:
194 .. math::
196 C_R(L, V; t_j) &= \tfrac{1}{2}\,
197 \mathrm{erfc}\!\left( \frac{L - \xi_j(V)}{2\sqrt{D_t(V)}} \right) \\
198 \xi_j(V) &= L \cdot (V - V_j) \,/\, (R\,V_\text{pore}) \\
199 D_t(V) &= D_m\,\tau_j(V) + \alpha_L\,\xi_j(V),
200 \quad \tau_j(V) = t(V) - t_j \\
201 D_s &= D_m + \alpha_L\,v_s(t), \quad v_s(t) = Q(t)\,L\,/\,(R\,V_\text{pore}).
203 The solute-front velocity :math:`v_s` (advection speed of the retarded ADE
204 that :math:`C_R` solves), not the fluid velocity :math:`Q L / V_\text{pore}`,
205 sets the flux coefficient :math:`D_s/v_s = D_m/v_s + \alpha_L`. The added
206 flux-correction term
208 .. math::
210 \frac{D_s}{v_s(t(V))} \cdot
211 \frac{1}{\sqrt{4\pi\,D_t(V)}}\,
212 \exp\!\left( -\frac{(L - \xi_j(V))^2}{4\,D_t(V)} \right)
214 converts Bear's *resident* concentration to a *flux* concentration. This
215 makes the coefficient matrix conserve mass under the criterion
216 ``integral Q c_out dt = integral Q c_in dt`` — the relevant invariant for
217 tracer measurements taken in the extracted fluid (Kreft & Zuber, 1978,
218 Eq. 5 and Eq. 1). Without this correction, Bear's leading-order kernel
219 misses the dispersive boundary flux at the outlet and column-sum mass
220 conservation fails by O(1/Pe) under variable Q.
222 Implementation: resolution-aware composite Gauss-Legendre quadrature in
223 volume space, split at flow-bin boundaries so that within each sub-interval
224 :math:`t(V)` is linear. The erf-like front has width :math:`\sqrt{4 D_t}`
225 (in volume units); a sub-interval whose front is under-resolved by a single
226 16-point rule (front width far below the sub-interval width) is tiled with
227 front-centred panels (see ``_FRONT_OFFSETS``), while smooth/already-resolved
228 sub-intervals keep the plain single 16-point rule (bit-identical to it). No
229 "fully capped" branch: the moving-frame variance keeps growing past
230 breakthrough, and the K-Z identity requires Bear's formula to satisfy the
231 variable-coefficient ADE exactly (which it does only without capping).
233 Parameters
234 ----------
235 step_widths : ndarray, shape (n_cout_edges, n_cin_edges)
236 x-position ``x(V_cout, V_cin) = (V_cout - V_cin - r_vpv) * L / r_vpv``
237 at each (cout-edge, cin-edge). NaN for inactive cells. Equals
238 :math:`\xi - L`.
239 cumulative_volume_at_cout_tedges : ndarray, shape (n_cout_edges,)
240 Cumulative extracted volume at each cout time edge [m³].
241 cumulative_volume_at_cin_tedges : ndarray, shape (n_cin_edges,)
242 Cumulative volume at each cin (flow) time edge [m³].
243 tedges_days : ndarray, shape (n_cin_edges,)
244 Flow time edges in days.
245 molecular_diffusivity : float
246 Effective (retarded-frame) molecular diffusivity D_m [m²/day].
247 Contributes ``D_m * tau`` to the dispersion product ``D_t``.
248 longitudinal_dispersivity : float
249 Longitudinal dispersivity alpha_L [m]. Contributes ``alpha_L * xi``
250 to the dispersion product ``D_t``.
251 r_vpv : float
252 Retardation factor times pore volume = R * V_pore [m³].
253 streamline_len : float
254 Streamline length L [m].
256 Returns
257 -------
258 ndarray, shape (n_cout_bins, n_cin_edges)
259 Bin-averaged flux concentration for each cell. NaN for inactive cells.
261 References
262 ----------
263 Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion
264 equation and its solutions for different initial and boundary conditions.
265 Chemical Engineering Science, 33(11), 1471-1480.
266 """
267 n_cout_edges, n_cin_edges = step_widths.shape
268 n_cout_bins = n_cout_edges - 1
270 x_lo = step_widths[:-1]
271 x_hi = step_widths[1:]
272 dx = x_hi - x_lo
274 v_lo_arr = cumulative_volume_at_cout_tedges[:-1]
275 v_hi_arr = cumulative_volume_at_cout_tedges[1:]
277 is_valid = ~np.isnan(x_lo) & ~np.isnan(x_hi)
279 frac = np.full((n_cout_bins, n_cin_edges), np.nan)
281 # --- No dispersion: C_F = C_R = step function (no dispersive flux) ---
282 if molecular_diffusivity == 0.0 and longitudinal_dispersivity == 0.0:
283 with np.errstate(divide="ignore", invalid="ignore"):
284 cr_no_disp = 0.5 + 0.5 * (np.abs(x_hi) - np.abs(x_lo)) / dx
285 cr_no_disp = np.where(dx == 0.0, 0.5 + 0.5 * np.sign(x_lo), cr_no_disp)
286 return np.where(is_valid, cr_no_disp, frac)
288 # --- Pre-compute solute-front velocity and K-Z coefficient (D_s/v_s) per flow bin ---
289 dv_per_bin = np.diff(cumulative_volume_at_cin_tedges)
290 dt_per_bin = np.diff(tedges_days)
291 with np.errstate(divide="ignore", invalid="ignore"):
292 q_per_bin = np.where(dt_per_bin > 0, dv_per_bin / dt_per_bin, 0.0)
293 # Solute-front velocity v_s = Q L / (R V_pore) -- the advection speed of the retarded ADE
294 # that C_R actually solves. Kreft-Zuber requires the flux coefficient D_s/v_s = D_m/v_s +
295 # alpha_L to use THAT velocity (not the fluid velocity Q L / V_pore); pairing it with the
296 # moving-frame variance D_t = D_m tau + alpha_L xi is what conserves mass for R>1, D_m>0.
297 v_per_bin = q_per_bin * streamline_len / r_vpv
298 # (D_s/v_s) = D_m/v_s + alpha_L. At v_s=0 the bin has dV=0 and is skipped below; the
299 # surrounding errstate suppresses the divide warning for those lanes.
300 dl_over_v_per_bin = np.where(
301 v_per_bin > 0,
302 molecular_diffusivity / v_per_bin + longitudinal_dispersivity,
303 0.0,
304 )
306 # --- Resolution-aware composite Gauss-Legendre quadrature, split by flow bins ---
307 # The integration window per (cell, flow-bin) is the intersection of
308 # [V_lo, V_hi] (cell), [ve_lo, ve_hi] (flow bin), and [V_j, infty) (parcel
309 # entered). Within each sub-interval, t(V) is linear. Where the sub-interval is
310 # much wider than the front width sqrt(4*D_t), the erf-like front is under-resolved
311 # by a single 16-point GL rule; such sub-intervals are tiled with front-centred
312 # panels (see _FRONT_OFFSETS). Smooth sub-intervals keep the single panel and are
313 # bit-identical to the plain 16-point rule.
314 idx_i, idx_j = np.nonzero(is_valid)
315 if len(idx_i) == 0:
316 return frac
318 v_lo_cells = v_lo_arr[idx_i]
319 v_hi_cells = v_hi_arr[idx_i]
320 v_cin_cells = cumulative_volume_at_cin_tedges[idx_j]
321 t_j_cells = tedges_days[idx_j]
322 total_dv = v_hi_cells - v_lo_cells
323 valid_cells = total_dv > 0
325 # Post-injection lower bound is loop-invariant: max(V_lo, V_cin) (D2 hoist).
326 v_lo_or_cin = np.maximum(v_lo_cells, v_cin_cells)
328 integral_cf = np.zeros(len(idx_i))
330 vol_edges = cumulative_volume_at_cin_tedges
331 for k in range(len(vol_edges) - 1):
332 ve_lo, ve_hi = vol_edges[k], vol_edges[k + 1]
333 if v_per_bin[k] <= 0.0:
334 continue
335 dl_over_v_k = dl_over_v_per_bin[k]
336 dt_sub_bin = tedges_days[k + 1] - tedges_days[k]
337 dv_sub_edge = ve_hi - ve_lo
339 # Intersection of cell, flow-bin, and post-injection range
340 sub_lo = np.maximum(v_lo_or_cin, ve_lo)
341 sub_hi = np.minimum(v_hi_cells, ve_hi)
342 overlap = (sub_hi > sub_lo) & valid_cells
343 if not np.any(overlap):
344 continue
346 lo = sub_lo[overlap]
347 hi = sub_hi[overlap]
348 vcin = v_cin_cells[overlap]
349 tj = t_j_cells[overlap]
351 # Front centre (x = 0 => xi = L => V = V_cin + r_vpv) and its width in volume.
352 # front_width = sqrt(4*D_t_front) * r_vpv / L, with D_t_front = D_m*tau_front +
353 # alpha_L*L (xi = L at the front). t(V) is linear within flow bin k.
354 v_front = vcin + r_vpv
355 t_front = tedges_days[k] + (v_front - ve_lo) * (dt_sub_bin / dv_sub_edge)
356 tau_front = np.maximum(t_front - tj, 0.0)
357 dt_front = molecular_diffusivity * tau_front + longitudinal_dispersivity * streamline_len
358 front_width = np.sqrt(4.0 * dt_front) * r_vpv / streamline_len
360 # Refine only where a sharp front region intersects this sub-interval and is
361 # under-resolved by a single 16-point rule; elsewhere the integrand is flat
362 # or already resolved, so one panel is exact. The front-region test (not just
363 # "centre in this bin") also catches a front whose tail spills across a
364 # flow-bin boundary. A spuriously large extrapolated front_width far from the
365 # front fails ``underresolved`` and so cannot trigger refinement.
366 front_hits = (v_front + _FRONT_REACH * front_width > lo) & (v_front - _FRONT_REACH * front_width < hi)
367 underresolved = (hi - lo) > _REFINE_RATIO * front_width
368 if np.any(front_hits & underresolved):
369 inner = np.clip(
370 v_front[:, np.newaxis] + front_width[:, np.newaxis] * _FRONT_OFFSETS[np.newaxis, :],
371 lo[:, np.newaxis],
372 hi[:, np.newaxis],
373 )
374 edges = np.concatenate([lo[:, np.newaxis], inner, hi[:, np.newaxis]], axis=1)
375 else:
376 edges = np.stack([lo, hi], axis=1)
378 p_lo = edges[:, :-1]
379 p_hi = edges[:, 1:]
380 p_mid = 0.5 * (p_lo + p_hi)
381 p_half = 0.5 * (p_hi - p_lo)
383 # GL nodes over every panel: shape (n_cell, n_panel, n_gl)
384 v_nodes = p_mid[:, :, np.newaxis] + p_half[:, :, np.newaxis] * _GL_NODES[np.newaxis, np.newaxis, :]
386 # Geometry: x = xi - L = (V - V_j - r_vpv) * L / r_vpv (parcel position
387 # relative to outlet); xi = parcel travel distance.
388 x_nodes = (v_nodes - vcin[:, np.newaxis, np.newaxis] - r_vpv) * streamline_len / r_vpv
389 xi_nodes = x_nodes + streamline_len
391 t_nodes = tedges_days[k] + (v_nodes - ve_lo) * (dt_sub_bin / dv_sub_edge)
392 # tau >= 0 by construction (lo >= v_cin); clip for safety.
393 tau_nodes = np.maximum(t_nodes - tj[:, np.newaxis, np.newaxis], 0.0)
395 # Bear's variance accumulator (sigma^2/2) — NO capping at RT/L
396 dt_nodes = molecular_diffusivity * tau_nodes + longitudinal_dispersivity * xi_nodes
398 with np.errstate(divide="ignore", invalid="ignore"):
399 arg = x_nodes / (2.0 * np.sqrt(dt_nodes))
400 # C_R = 0.5 * (1 + erf(arg)) = 0.5 * erfc((L-xi)/(2*sqrt(Dt)))
401 erf_vals = np.where(np.isfinite(arg), special.erf(arg), np.sign(x_nodes))
402 cr_vals = 0.5 * (1.0 + erf_vals)
404 # K-Z flux correction: FC = (D_s/v_s) * (1/sqrt(4 pi D_t)) * exp(-arg^2)
405 with np.errstate(divide="ignore", invalid="ignore"):
406 gauss_vals = np.where(
407 dt_nodes > 0.0,
408 np.exp(-(arg**2)) / np.sqrt(4.0 * np.pi * dt_nodes),
409 0.0,
410 )
411 cf_vals = cr_vals + dl_over_v_k * gauss_vals
413 # Integrate: GL-weight over nodes, then sum panel contributions per cell.
414 # The weight contraction is done in 2D so a single-panel (non-refined)
415 # sub-interval is bit-identical to a plain 16-point rule.
416 cf_weighted = (cf_vals.reshape(-1, cf_vals.shape[-1]) @ _GL_WEIGHTS).reshape(cf_vals.shape[:-1])
417 integral_cf[overlap] += (p_half * cf_weighted).sum(axis=1)
419 with np.errstate(divide="ignore", invalid="ignore"):
420 frac_cells = np.where(valid_cells, integral_cf / total_dv, np.nan)
421 frac[idx_i, idx_j] = frac_cells
423 return frac
426def _diffusion_extend_tedges_flag(spinup: object) -> bool:
427 """Translate the public ``spinup`` parameter to the internal extend flag.
429 The diffusion module's existing warm-start behavior is to extend
430 ``tedges`` by 100 years on each side. The public ``spinup`` parameter
431 maps onto this binary toggle: ``"constant"`` enables the extension
432 (default; preserves legacy behavior), ``None`` disables it (cout in
433 spin-up region becomes NaN). The float fraction-threshold mode of
434 other modules is not implemented here.
436 Returns
437 -------
438 bool
439 True if tedges should be extended (warm-start), False if not.
441 Raises
442 ------
443 ValueError
444 If ``spinup`` is a string other than ``"constant"``.
445 NotImplementedError
446 If ``spinup`` is a float (fraction-threshold mode is not
447 implemented for the diffusion module).
448 """
449 if spinup is None:
450 return False
451 if isinstance(spinup, str):
452 if spinup != "constant":
453 msg = f"spinup string must be 'constant'; got {spinup!r}"
454 raise ValueError(msg)
455 return True
456 msg = (
457 "diffusion's spinup parameter only supports None or 'constant'; "
458 f"float thresholds are not yet implemented (got {spinup!r})"
459 )
460 raise NotImplementedError(msg)
463def _validate_diffusion_inputs(
464 *,
465 tedges: pd.DatetimeIndex,
466 flow: npt.NDArray[np.floating],
467 aquifer_pore_volumes: npt.NDArray[np.floating],
468 streamline_length: npt.NDArray[np.floating],
469 molecular_diffusivity: npt.NDArray[np.floating],
470 longitudinal_dispersivity: npt.NDArray[np.floating],
471 retardation_factor: float,
472 cin_values: npt.NDArray[np.floating] | None = None,
473 cout_values: npt.NDArray[np.floating] | None = None,
474 cout_tedges: pd.DatetimeIndex | None = None,
475) -> None:
476 """Validate inputs common to diffusion forward / reverse entry points.
478 Path selection via mutually-exclusive kwargs:
480 - ``cin_values`` provided => forward path. ``tedges`` parities cin and flow.
481 - ``cout_values`` + ``cout_tedges`` provided => reverse path. ``tedges`` parities
482 flow; ``cout_tedges`` parities cout.
484 Raises
485 ------
486 ValueError
487 If any check fails. The message identifies which invariant was violated.
488 """
489 n_pore_volumes = len(aquifer_pore_volumes)
491 if cin_values is not None:
492 _validate_tedges_parity(tedges, cin_values, tedges_name="tedges", values_name="cin")
493 _validate_tedges_parity(tedges, flow, tedges_name="tedges", values_name="flow")
494 elif cout_values is not None and cout_tedges is not None:
495 _validate_tedges_parity(tedges, flow, tedges_name="tedges", values_name="flow")
496 _validate_tedges_parity(cout_tedges, cout_values, tedges_name="cout_tedges", values_name="cout")
497 else:
498 msg = "must provide cin_values (forward) or both cout_values and cout_tedges (reverse)"
499 raise ValueError(msg)
500 if len(aquifer_pore_volumes) != len(streamline_length):
501 msg = "aquifer_pore_volumes and streamline_length must have the same length"
502 raise ValueError(msg)
503 _validate_scalar_or_matching_length(
504 molecular_diffusivity,
505 name="molecular_diffusivity",
506 expected_len=n_pore_volumes,
507 ref_name="aquifer_pore_volumes",
508 )
509 _validate_scalar_or_matching_length(
510 longitudinal_dispersivity,
511 name="longitudinal_dispersivity",
512 expected_len=n_pore_volumes,
513 ref_name="aquifer_pore_volumes",
514 )
515 _validate_non_negative_array(molecular_diffusivity, name="molecular_diffusivity")
516 _validate_non_negative_array(longitudinal_dispersivity, name="longitudinal_dispersivity")
517 if cin_values is not None:
518 _validate_no_nan(cin_values, name="cin")
519 elif cout_values is not None:
520 _validate_no_nan(cout_values, name="cout")
521 _validate_no_nan(flow, name="flow")
522 _validate_non_negative_array(flow, name="flow", message="flow must be non-negative (negative flow not supported)")
523 _validate_positive_array(aquifer_pore_volumes, name="aquifer_pore_volumes")
524 _validate_positive_array(streamline_length, name="streamline_length")
525 _validate_retardation_factor(retardation_factor)
528def _prepare_diffusion_arrays(
529 *,
530 flow: npt.ArrayLike,
531 aquifer_pore_volumes: npt.ArrayLike,
532 streamline_length: npt.ArrayLike,
533 molecular_diffusivity: npt.ArrayLike,
534 longitudinal_dispersivity: npt.ArrayLike,
535) -> tuple[
536 npt.NDArray[np.floating],
537 npt.NDArray[np.floating],
538 npt.NDArray[np.floating],
539 npt.NDArray[np.floating],
540 npt.NDArray[np.floating],
541]:
542 """Coerce flow / geometry / dispersion inputs to broadcasted float arrays.
544 Each per-streamtube parameter (``streamline_length``, ``molecular_diffusivity``,
545 ``longitudinal_dispersivity``) may be passed as a scalar; it is broadcast to one
546 value per pore volume. The returned arrays are read-only views when broadcast (none
547 is mutated downstream).
549 Returns
550 -------
551 tuple of ndarray
552 ``(flow, aquifer_pore_volumes, streamline_length, molecular_diffusivity,
553 longitudinal_dispersivity)`` as float arrays.
554 """
555 flow = np.asarray(flow, dtype=float)
556 aquifer_pore_volumes = np.asarray(aquifer_pore_volumes, dtype=float)
557 streamline_length = np.atleast_1d(np.asarray(streamline_length, dtype=float))
558 molecular_diffusivity = np.atleast_1d(np.asarray(molecular_diffusivity, dtype=float))
559 longitudinal_dispersivity = np.atleast_1d(np.asarray(longitudinal_dispersivity, dtype=float))
561 n_pore_volumes = len(aquifer_pore_volumes)
562 if streamline_length.size == 1:
563 streamline_length = np.broadcast_to(streamline_length, (n_pore_volumes,))
564 if molecular_diffusivity.size == 1:
565 molecular_diffusivity = np.broadcast_to(molecular_diffusivity, (n_pore_volumes,))
566 if longitudinal_dispersivity.size == 1:
567 longitudinal_dispersivity = np.broadcast_to(longitudinal_dispersivity, (n_pore_volumes,))
569 return flow, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity
572def _infiltration_to_extraction_coeff_matrix(
573 *,
574 flow: npt.NDArray[np.floating],
575 tedges: pd.DatetimeIndex,
576 cout_tedges: pd.DatetimeIndex,
577 aquifer_pore_volumes: npt.NDArray[np.floating],
578 streamline_length: npt.NDArray[np.floating],
579 molecular_diffusivity: npt.NDArray[np.floating],
580 longitudinal_dispersivity: npt.NDArray[np.floating],
581 retardation_factor: float,
582 extend_tedges: bool = True,
583) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.bool_]]:
584 """Build the forward coefficient matrix for diffusion transport.
586 Constructs the matrix W such that ``cout = W @ cin``, accounting for
587 advection, microdispersion, and molecular diffusion. NaN entries in the raw coefficient
588 matrix are replaced with zero.
590 Parameters
591 ----------
592 flow : ndarray
593 Flow rate of water [m³/day]. Already validated.
594 tedges : DatetimeIndex
595 Cin/flow time edges (not yet extended for spin-up).
596 cout_tedges : DatetimeIndex
597 Cout time edges.
598 aquifer_pore_volumes : ndarray
599 Pore volumes [m³]. Already validated.
600 streamline_length : ndarray
601 Travel distances [m]. Already validated.
602 molecular_diffusivity : ndarray
603 Effective molecular diffusivities [m²/day]. Already broadcasted.
604 See :func:`infiltration_to_extraction` for physical interpretation.
605 longitudinal_dispersivity : ndarray
606 Longitudinal dispersivities [m]. Already broadcasted.
607 retardation_factor : float
608 Retardation factor.
610 Returns
611 -------
612 coeff_matrix : ndarray
613 Filled coefficient matrix of shape (n_cout, n_cin). NaN replaced
614 with zero.
615 valid_cout_bins : ndarray
616 Boolean mask of shape (n_cout,) indicating valid output bins.
617 """
618 if extend_tedges:
619 # Extend tedges by 100 years on each side to provide warm-start cin
620 # and post-data flow for cout bins near the boundaries. Equivalent to
621 # the public ``spinup="constant"`` policy in other modules.
622 tedges = pd.DatetimeIndex([
623 tedges[0] - pd.Timedelta("36500D"),
624 *list(tedges[1:-1]),
625 tedges[-1] + pd.Timedelta("36500D"),
626 ])
628 # Compute the cumulative flow at tedges
629 cumulative_volume_at_cin_tedges = cumulative_flow_volume(flow, dt_to_days(tedges)) # m³
631 # Compute the cumulative flow at cout_tedges. Both edge arrays are first reduced to a shared
632 # day axis: np.interp coerces each datetime64 operand to int64 in its own resolution, so a
633 # cout_tedges / tedges unit mismatch (e.g. ns vs us) would send every query out of range and
634 # silently return all-NaN.
635 tedges_days_arr = tedges_to_days(tedges)
636 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0])
637 cumulative_volume_at_cout_tedges = np.interp(
638 cout_tedges_days, tedges_days_arr, cumulative_volume_at_cin_tedges
639 ).astype(float)
641 # Output bin valid where every streamtube's advective look-back is in-record across the whole
642 # bin -- i.e. advective coverage == 1 for all pore volumes (NaN outside the record -> invalid).
643 # This is the advective validity gate only; the dispersive informedness is the captured kernel
644 # mass (total_coeff) applied downstream.
645 valid_cout_bins = np.all(
646 fraction_explained_full(
647 flow=flow,
648 tedges=tedges,
649 cout_tedges=cout_tedges,
650 aquifer_pore_volumes=aquifer_pore_volumes,
651 retardation_factor=retardation_factor,
652 direction="extraction_to_infiltration",
653 )
654 >= 1.0,
655 axis=0,
656 )
658 # Initialize coefficient matrix accumulator
659 n_cout_bins = len(cout_tedges) - 1
660 n_cin_bins = len(flow)
661 accumulated_coeff = np.zeros((n_cout_bins, n_cin_bins))
663 # Determine when infiltration has occurred: cout_tedge must be >= tedge (infiltration time)
664 isactive = cout_tedges.to_numpy()[:, None] >= tedges.to_numpy()[None, :]
666 # Loop over each pore volume
667 for i_pv in range(len(aquifer_pore_volumes)):
668 r_vpv = retardation_factor * aquifer_pore_volumes[i_pv]
670 delta_volume = cumulative_volume_at_cout_tedges[:, None] - cumulative_volume_at_cin_tedges[None, :] - r_vpv
671 delta_volume[~isactive] = np.nan
673 step_widths = delta_volume / r_vpv * streamline_length[i_pv]
675 frac = _cfrac_mean_volume(
676 step_widths=step_widths,
677 cumulative_volume_at_cout_tedges=cumulative_volume_at_cout_tedges,
678 cumulative_volume_at_cin_tedges=cumulative_volume_at_cin_tedges,
679 tedges_days=tedges_days_arr,
680 molecular_diffusivity=float(molecular_diffusivity[i_pv]),
681 longitudinal_dispersivity=float(longitudinal_dispersivity[i_pv]),
682 r_vpv=r_vpv,
683 streamline_len=streamline_length[i_pv],
684 )
686 frac_start = frac[:, :-1]
687 frac_end = frac[:, 1:]
688 frac_end_filled = np.where(np.isnan(frac_end) & ~np.isnan(frac_start), 0.0, frac_end)
689 coeff = frac_start - frac_end_filled
691 accumulated_coeff += coeff
693 coeff_matrix_filled = np.nan_to_num(accumulated_coeff / len(aquifer_pore_volumes), nan=0.0)
695 return coeff_matrix_filled, valid_cout_bins
698def infiltration_to_extraction(
699 *,
700 cin: npt.ArrayLike,
701 flow: npt.ArrayLike,
702 tedges: pd.DatetimeIndex,
703 cout_tedges: pd.DatetimeIndex,
704 aquifer_pore_volumes: npt.ArrayLike,
705 streamline_length: npt.ArrayLike,
706 molecular_diffusivity: npt.ArrayLike,
707 longitudinal_dispersivity: npt.ArrayLike,
708 retardation_factor: float = 1.0,
709 spinup: str | None = "constant",
710) -> npt.NDArray[np.floating]:
711 """
712 Compute extracted concentration with advection, microdispersion, and molecular diffusion.
714 This function models 1D solute transport through an aquifer system along orthogonal
715 (Cartesian) flow paths. Each aquifer pore volume is an independent streamline carrying
716 advection with microdispersion (alpha_L) and molecular diffusion (D_m); the spread across
717 the pore volume distribution provides macrodispersion. Linear sorption enters via the
718 retardation factor.
720 The physical model assumes:
722 1. Water infiltrates with concentration cin at time t_in
723 2. Water travels distance L through aquifer with residence time tau = V_pore / Q
724 3. During transport, microdispersion and molecular diffusion spread each streamline,
725 while the spread across pore volumes provides macrodispersion
726 4. At extraction, the concentration is a dispersed breakthrough curve
728 The reported extracted concentration is the Kreft-Zuber (1978) **flux
729 concentration** at the outlet, defined as the solute mass flux divided
730 by the volumetric fluid flux. This is what is measured when sampling the
731 outflowing fluid. Compared to Bear's leading-order resident concentration,
732 it includes the dispersive boundary flux ``-D_s * dC_R/dx`` at
733 ``x = L`` (with the solute-front dispersion ``D_s = D_m + alpha_L * v_s``
734 and velocity ``v_s = Q L / (R V_pore)``), which is what makes the column-sum
735 invariant ``integral Q c_out dt = integral Q c_in dt`` hold exactly under
736 variable flow.
738 Microdispersion and molecular diffusion enter as the moving-frame variance
740 sigma^2(V) = 2 * D_m * tau(V) + 2 * alpha_L * xi(V),
742 where ``tau(V)`` is the elapsed time since infiltration and ``xi(V)`` is
743 the distance the parcel has actually travelled. Evaluating sigma^2 at each
744 quadrature node — and avoiding any artificial capping past breakthrough —
745 keeps Bear's formula an exact solution of the variable-coefficient ADE,
746 which the Kreft-Zuber identity relies on.
748 Parameters
749 ----------
750 cin : array-like
751 Concentration of the compound in infiltrating water [concentration units].
752 Length must match the number of time bins defined by tedges. The model assumes
753 this value is constant over each interval ``[tedges[i], tedges[i+1])``.
754 flow : array-like
755 Flow rate of water in the aquifer [m³/day].
756 Length must match cin and the number of time bins defined by tedges. The model
757 assumes this value is constant over each interval ``[tedges[i], tedges[i+1])``.
758 tedges : pandas.DatetimeIndex
759 Time edges defining bins for both cin and flow data. Has length of
760 len(cin) + 1.
761 cout_tedges : pandas.DatetimeIndex
762 Time edges for output data bins. Has length of desired output + 1.
763 The output concentration is averaged over each bin.
764 aquifer_pore_volumes : array-like
765 Array of aquifer pore volumes [m³] representing the distribution
766 of flow paths. Each pore volume determines the residence time for
767 that flow path: tau = V_pore / Q.
768 streamline_length : array-like
769 Array of travel distances [m] corresponding to each pore volume.
770 Must have the same length as aquifer_pore_volumes.
771 molecular_diffusivity : float or array-like
772 Effective (retarded-frame) molecular diffusivity [m²/day]. Can be a
773 scalar (same for all pore volumes) or an array with the same length as
774 aquifer_pore_volumes. Must be non-negative. For solute transport, this is
775 the molecular diffusion coefficient D_m [m²/day] — typically ~1e-5 m²/day,
776 negligible compared to microdispersion. For heat transport, pass the
777 thermal diffusivity D_th = lambda / (rho*c)_eff [m²/day], typically
778 0.01-0.1 m²/day.
780 Internally, this contributes ``2 * molecular_diffusivity * tau`` to the
781 variance, where ``tau`` is the elapsed time in days (no extra factor of
782 R). The retardation factor instead enters the flux coefficient
783 ``D_s/v_s = R D_m / v_fluid + alpha_L`` through the solute-front velocity
784 ``v_s = Q L / (R V_pore)``. For heat transport, the thermal diffusivity
785 already represents the effective diffusivity D_eff in the porous matrix;
786 for solutes the contribution is typically negligible.
787 longitudinal_dispersivity : float or array-like
788 Longitudinal dispersivity [m]. Can be a scalar (same for all pore
789 volumes) or an array with the same length as aquifer_pore_volumes.
790 Must be non-negative. Represents microdispersion from pore-scale velocity variations.
791 Set to 0 for pure molecular diffusion.
792 retardation_factor : float, optional
793 Retardation factor of the compound in the aquifer (default 1.0).
794 Values > 1.0 indicate slower transport due to sorption.
795 spinup : {'constant'} or None, optional
796 Spin-up policy (default ``'constant'``). ``'constant'`` extends tedges by
797 100 years on each side so that output bins near the boundary are always
798 informed. ``None`` disables the extension; output bins without sufficient
799 upstream data become NaN. Float fraction-threshold mode is not implemented
800 and raises ``NotImplementedError``.
802 Returns
803 -------
804 numpy.ndarray
805 Bin-averaged concentration in the extracted water. Same units as cin.
806 Length equals len(cout_tedges) - 1. NaN values indicate time periods
807 with no valid contributions from the infiltration data.
809 Raises
810 ------
811 ValueError
812 If input dimensions are inconsistent, if diffusivity is negative,
813 or if aquifer_pore_volumes and streamline_length have different lengths.
815 See Also
816 --------
817 extraction_to_infiltration : Inverse operation (deconvolution)
818 gwtransport.advection.infiltration_to_extraction : Pure advection (no dispersion)
819 gwtransport.diffusion_fast.infiltration_to_extraction : Fast closed-form equivalent
820 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion
822 Notes
823 -----
824 The algorithm constructs a coefficient matrix W where cout = W @ cin:
826 1. For each pore volume, build a cell grid in cumulative volume space:
828 - cells span ``(V_cout[i], V_cout[i+1]) x V_cin[j]`` for each
829 (cout-bin i, cin-edge j)
830 - delta_volume = V_cout - V_cin - r_vpv encodes the parcel's offset
831 from the outlet at each (cout-edge, cin-edge)
833 2. For each cell, compute the bin-averaged Kreft-Zuber flux concentration
834 ``frac[i, j] = (1/dV_i) * integral C_F(L, V; t_j) dV`` by resolution-aware
835 composite Gauss-Legendre quadrature in volume space, split at flow-bin
836 boundaries so that ``t(V)`` is linear within each sub-interval. Where the
837 erf-like front (width ``sqrt(4*D_t)`` in volume units) is under-resolved by
838 a single 16-point rule -- as for near-zero dispersivity -- the sub-interval
839 is tiled with front-centred panels; smooth sub-intervals keep the single
840 rule. The moving-frame variance ``D_t = D_m*tau + alpha_L*xi`` is evaluated
841 at each quadrature node (never capped at the residence time).
843 3. Coefficient for bin: ``coeff[i,j] = frac[i, j] - frac[i, j+1]``. This
844 is the contribution of cin[j] to cout[i] in the W matrix.
846 4. Average coefficients across all pore volumes.
848 The K-Z flux-correction term in C_F = C_R - (D_s/v_s) * dC_R/dx (solute-front
849 velocity v_s = Q L / (R V_pore), dispersion D_s = D_m + alpha_L * v_s) is what
850 makes the column-sum invariant exact under variable Q; see the module
851 docstring for the derivation.
853 Examples
854 --------
855 Basic usage with constant flow:
857 >>> import pandas as pd
858 >>> import numpy as np
859 >>> from gwtransport.diffusion import infiltration_to_extraction
860 >>>
861 >>> # Create time edges
862 >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
863 >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
864 >>>
865 >>> # Input concentration (step function) and constant flow
866 >>> cin = np.zeros(len(tedges) - 1)
867 >>> cin[5:10] = 1.0 # Pulse of concentration
868 >>> flow = np.ones(len(tedges) - 1) * 100.0 # 100 m³/day
869 >>>
870 >>> # Single pore volume of 500 m³, travel distance 100 m
871 >>> aquifer_pore_volumes = np.array([500.0])
872 >>> streamline_length = np.array([100.0])
873 >>>
874 >>> # Compute with dispersion (molecular diffusion + dispersivity)
875 >>> # Scalar values broadcast to all pore volumes
876 >>> cout = infiltration_to_extraction(
877 ... cin=cin,
878 ... flow=flow,
879 ... tedges=tedges,
880 ... cout_tedges=cout_tedges,
881 ... aquifer_pore_volumes=aquifer_pore_volumes,
882 ... streamline_length=streamline_length,
883 ... molecular_diffusivity=1e-4, # m²/day, same for all pore volumes
884 ... longitudinal_dispersivity=1.0, # m, same for all pore volumes
885 ... )
887 With multiple pore volumes (heterogeneous aquifer):
889 >>> # Distribution of pore volumes and corresponding travel distances
890 >>> aquifer_pore_volumes = np.array([400.0, 500.0, 600.0])
891 >>> streamline_length = np.array([80.0, 100.0, 120.0])
892 >>>
893 >>> # Scalar diffusion parameters broadcast to all pore volumes
894 >>> cout = infiltration_to_extraction(
895 ... cin=cin,
896 ... flow=flow,
897 ... tedges=tedges,
898 ... cout_tedges=cout_tedges,
899 ... aquifer_pore_volumes=aquifer_pore_volumes,
900 ... streamline_length=streamline_length,
901 ... molecular_diffusivity=1e-4, # m²/day
902 ... longitudinal_dispersivity=1.0, # m
903 ... )
904 """
905 cout_tedges = pd.DatetimeIndex(cout_tedges)
906 tedges = pd.DatetimeIndex(tedges)
908 cin = np.asarray(cin, dtype=float)
909 flow, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity = (
910 _prepare_diffusion_arrays(
911 flow=flow,
912 aquifer_pore_volumes=aquifer_pore_volumes,
913 streamline_length=streamline_length,
914 molecular_diffusivity=molecular_diffusivity,
915 longitudinal_dispersivity=longitudinal_dispersivity,
916 )
917 )
919 _validate_diffusion_inputs(
920 tedges=tedges,
921 flow=flow,
922 aquifer_pore_volumes=aquifer_pore_volumes,
923 streamline_length=streamline_length,
924 molecular_diffusivity=molecular_diffusivity,
925 longitudinal_dispersivity=longitudinal_dispersivity,
926 retardation_factor=retardation_factor,
927 cin_values=cin,
928 )
930 extend_tedges = _diffusion_extend_tedges_flag(spinup)
931 coeff_matrix, valid_cout_bins = _infiltration_to_extraction_coeff_matrix(
932 flow=flow,
933 tedges=tedges,
934 cout_tedges=cout_tedges,
935 aquifer_pore_volumes=aquifer_pore_volumes,
936 streamline_length=streamline_length,
937 molecular_diffusivity=molecular_diffusivity,
938 longitudinal_dispersivity=longitudinal_dispersivity,
939 retardation_factor=retardation_factor,
940 extend_tedges=extend_tedges,
941 )
943 cout = coeff_matrix @ cin
945 # Output bins are invalid where the coefficient sum is near zero (no cin has broken
946 # through yet) or the bin extends beyond the input data range (valid_cout_bins).
947 total_coeff = np.sum(coeff_matrix, axis=1)
948 no_valid_contribution = (total_coeff < EPSILON_COEFF_SUM) | ~valid_cout_bins
949 cout[no_valid_contribution] = np.nan
951 return cout
954def extraction_to_infiltration(
955 *,
956 cout: npt.ArrayLike,
957 flow: npt.ArrayLike,
958 tedges: pd.DatetimeIndex,
959 cout_tedges: pd.DatetimeIndex,
960 aquifer_pore_volumes: npt.ArrayLike,
961 streamline_length: npt.ArrayLike,
962 molecular_diffusivity: npt.ArrayLike,
963 longitudinal_dispersivity: npt.ArrayLike,
964 retardation_factor: float = 1.0,
965 regularization_strength: float = 1e-10,
966 spinup: str | None = "constant",
967) -> npt.NDArray[np.floating]:
968 """
969 Compute infiltration concentration from extracted water (deconvolution with dispersion).
971 Inverts the forward transport model by building the forward coefficient
972 matrix ``W_forward`` from :func:`infiltration_to_extraction` and solving
973 ``W_forward @ cin = cout`` via Tikhonov regularization. Well-determined
974 modes are dominated by the data; poorly-determined modes are pulled
975 toward the physically motivated target (transpose-and-normalize of the
976 forward matrix).
978 Parameters
979 ----------
980 cout : array-like
981 Concentration of the compound in extracted water [concentration units].
982 Length must match the number of time bins defined by cout_tedges.
983 flow : array-like
984 Flow rate of water in the aquifer [m³/day].
985 Length must match the number of time bins defined by tedges.
986 tedges : pandas.DatetimeIndex
987 Time edges defining bins for cin (output) and flow data.
988 Has length of len(flow) + 1. Output cin has length len(tedges) - 1.
989 cout_tedges : pandas.DatetimeIndex
990 Time edges for cout data bins. Has length of len(cout) + 1.
991 Can have different time alignment and resolution than tedges.
992 aquifer_pore_volumes : array-like
993 Array of aquifer pore volumes [m³] representing the distribution
994 of flow paths. Each pore volume determines the residence time for
995 that flow path: tau = V_pore / Q.
996 streamline_length : array-like
997 Array of travel distances [m] corresponding to each pore volume.
998 Must have the same length as aquifer_pore_volumes.
999 molecular_diffusivity : float or array-like
1000 Effective molecular diffusivity [m²/day]. Can be a scalar (same for all
1001 pore volumes) or an array with the same length as aquifer_pore_volumes.
1002 Must be non-negative. See :func:`infiltration_to_extraction` for
1003 details on the physical interpretation and the interaction with
1004 retardation_factor.
1005 longitudinal_dispersivity : float or array-like
1006 Longitudinal dispersivity [m]. Can be a scalar (same for all pore
1007 volumes) or an array with the same length as aquifer_pore_volumes.
1008 Must be non-negative.
1009 retardation_factor : float, optional
1010 Retardation factor of the compound in the aquifer (default 1.0).
1011 Values > 1.0 indicate slower transport due to sorption.
1012 regularization_strength : float, optional
1013 Tikhonov regularization parameter λ. See
1014 :func:`gwtransport.advection.extraction_to_infiltration` for details.
1015 Default is 1e-10.
1016 spinup : {'constant'} or None, optional
1017 Spin-up policy (default ``'constant'``). ``'constant'`` extends tedges by
1018 100 years on each side so that output bins near the boundary are always
1019 informed. ``None`` disables the extension; output bins without sufficient
1020 upstream data become NaN. Float fraction-threshold mode is not implemented
1021 and raises ``NotImplementedError``.
1023 Returns
1024 -------
1025 numpy.ndarray
1026 Bin-averaged concentration in the infiltrating water. Same units as cout.
1027 Length equals len(tedges) - 1. NaN values indicate time periods
1028 with no valid contributions from the extraction data.
1030 Raises
1031 ------
1032 ValueError
1033 If input dimensions are inconsistent, if diffusivity is negative,
1034 or if aquifer_pore_volumes and streamline_length have different lengths.
1036 See Also
1037 --------
1038 infiltration_to_extraction : Forward operation (convolution)
1039 gwtransport.advection.extraction_to_infiltration : Pure advection (no dispersion)
1040 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion
1042 Notes
1043 -----
1044 The algorithm builds the forward coefficient matrix ``W_forward`` (same as
1045 used by :func:`infiltration_to_extraction`) and solves ``W_forward @ cin = cout``
1046 using :func:`gwtransport.utils.solve_tikhonov`. This ensures mathematical
1047 consistency between forward and inverse operations.
1049 NaN values in ``cout`` are rejected. The Tikhonov solver here does not
1050 mask NaN rows, so any NaN in ``cout`` would poison the solution. This
1051 differs from :func:`gwtransport.deposition.extraction_to_deposition`,
1052 whose regularized solver excludes NaN ``cout`` rows by construction.
1054 Examples
1055 --------
1056 Basic usage with constant flow:
1058 >>> import pandas as pd
1059 >>> import numpy as np
1060 >>> from gwtransport.diffusion import extraction_to_infiltration
1061 >>>
1062 >>> # Create time edges: tedges for cin/flow, cout_tedges for cout
1063 >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
1064 >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
1065 >>>
1066 >>> # Extracted concentration and constant flow
1067 >>> cout = np.zeros(len(cout_tedges) - 1)
1068 >>> cout[5:10] = 1.0 # Observed pulse at extraction
1069 >>> flow = np.ones(len(tedges) - 1) * 100.0 # 100 m³/day
1070 >>>
1071 >>> # Single pore volume of 500 m³, travel distance 100 m
1072 >>> aquifer_pore_volumes = np.array([500.0])
1073 >>> streamline_length = np.array([100.0])
1074 >>>
1075 >>> # Reconstruct infiltration concentration
1076 >>> cin = extraction_to_infiltration(
1077 ... cout=cout,
1078 ... flow=flow,
1079 ... tedges=tedges,
1080 ... cout_tedges=cout_tedges,
1081 ... aquifer_pore_volumes=aquifer_pore_volumes,
1082 ... streamline_length=streamline_length,
1083 ... molecular_diffusivity=1e-4,
1084 ... longitudinal_dispersivity=1.0,
1085 ... )
1086 """
1087 tedges = pd.DatetimeIndex(tedges)
1088 cout_tedges = pd.DatetimeIndex(cout_tedges)
1090 cout = np.asarray(cout, dtype=float)
1091 flow, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity = (
1092 _prepare_diffusion_arrays(
1093 flow=flow,
1094 aquifer_pore_volumes=aquifer_pore_volumes,
1095 streamline_length=streamline_length,
1096 molecular_diffusivity=molecular_diffusivity,
1097 longitudinal_dispersivity=longitudinal_dispersivity,
1098 )
1099 )
1101 _validate_diffusion_inputs(
1102 tedges=tedges,
1103 flow=flow,
1104 aquifer_pore_volumes=aquifer_pore_volumes,
1105 streamline_length=streamline_length,
1106 molecular_diffusivity=molecular_diffusivity,
1107 longitudinal_dispersivity=longitudinal_dispersivity,
1108 retardation_factor=retardation_factor,
1109 cout_values=cout,
1110 cout_tedges=cout_tedges,
1111 )
1113 n_cin = len(tedges) - 1
1115 # Build forward weight matrix: W_forward @ cin = cout
1116 extend_tedges = _diffusion_extend_tedges_flag(spinup)
1117 w_forward, valid_cout_bins = _infiltration_to_extraction_coeff_matrix(
1118 flow=flow,
1119 tedges=tedges,
1120 cout_tedges=cout_tedges,
1121 aquifer_pore_volumes=aquifer_pore_volumes,
1122 streamline_length=streamline_length,
1123 molecular_diffusivity=molecular_diffusivity,
1124 longitudinal_dispersivity=longitudinal_dispersivity,
1125 retardation_factor=retardation_factor,
1126 extend_tedges=extend_tedges,
1127 )
1129 return solve_inverse_transport(
1130 w_forward=w_forward,
1131 observed=cout,
1132 n_output=n_cin,
1133 regularization_strength=regularization_strength,
1134 valid_rows=valid_cout_bins,
1135 )
1138def gamma_infiltration_to_extraction(
1139 *,
1140 cin: npt.ArrayLike,
1141 flow: npt.ArrayLike,
1142 tedges: pd.DatetimeIndex,
1143 cout_tedges: pd.DatetimeIndex,
1144 mean: float | None = None,
1145 std: float | None = None,
1146 loc: float = 0.0,
1147 alpha: float | None = None,
1148 beta: float | None = None,
1149 n_bins: int = 100,
1150 streamline_length: float,
1151 molecular_diffusivity: float,
1152 longitudinal_dispersivity: float,
1153 retardation_factor: float = 1.0,
1154 spinup: str | None = "constant",
1155) -> npt.NDArray[np.floating]:
1156 """
1157 Compute extracted concentration with advection and dispersion for gamma-distributed pore volumes.
1159 Combines advection with microdispersion and molecular diffusion along each streamline
1160 (gamma-distributed pore volumes, whose spread provides macrodispersion). This is a
1161 convenience wrapper around :func:`infiltration_to_extraction` that parameterizes
1162 the aquifer pore volume distribution as a (shifted) gamma distribution.
1164 Provide either (mean, std) or (alpha, beta); ``loc`` is optional and defaults to 0.
1166 Parameters
1167 ----------
1168 cin : array-like
1169 Concentration of the compound in infiltrating water.
1170 flow : array-like
1171 Flow rate of water in the aquifer [m³/day].
1172 tedges : pandas.DatetimeIndex
1173 Time edges for cin and flow data. Has length len(cin) + 1.
1174 cout_tedges : pandas.DatetimeIndex
1175 Time edges for output data bins. Has length of desired output + 1.
1176 mean : float, optional
1177 Mean of the gamma distribution of the aquifer pore volume. Must be strictly
1178 greater than ``loc``.
1179 std : float, optional
1180 Standard deviation of the gamma distribution of the aquifer pore volume
1181 (invariant under the ``loc`` shift).
1182 loc : float, optional
1183 Location (minimum pore volume) of the gamma distribution. Must satisfy
1184 ``0 <= loc < mean``. Default is ``0.0``.
1185 alpha : float, optional
1186 Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).
1187 beta : float, optional
1188 Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).
1189 n_bins : int, optional
1190 Number of bins to discretize the gamma distribution. Default is 100.
1191 streamline_length : float
1192 Travel distance through the aquifer [m]. Applied uniformly to all
1193 gamma-discretized pore volumes.
1194 molecular_diffusivity : float
1195 Effective molecular diffusivity [m²/day]. Must be non-negative.
1196 See :func:`infiltration_to_extraction` for details on the interaction
1197 with retardation_factor.
1198 longitudinal_dispersivity : float
1199 Longitudinal dispersivity [m]. Must be non-negative.
1200 retardation_factor : float, optional
1201 Retardation factor (default 1.0). Values > 1.0 indicate slower transport.
1202 spinup : {'constant'} or None, optional
1203 Spin-up policy (default ``'constant'``). ``'constant'`` extends tedges by
1204 100 years on each side so that output bins near the boundary are always
1205 informed. ``None`` disables the extension; output bins without sufficient
1206 upstream data become NaN. Float fraction-threshold mode is not implemented
1207 and raises ``NotImplementedError``.
1209 Returns
1210 -------
1211 numpy.ndarray
1212 Bin-averaged concentration in the extracted water. Length equals
1213 len(cout_tedges) - 1. NaN values indicate time periods with no valid
1214 contributions from the infiltration data.
1216 See Also
1217 --------
1218 infiltration_to_extraction : Transport with explicit pore volume distribution
1219 gamma_extraction_to_infiltration : Reverse operation (deconvolution)
1220 gwtransport.gamma.bins : Create gamma distribution bins
1221 gwtransport.advection.gamma_infiltration_to_extraction : Pure advection (no dispersion)
1222 :ref:`concept-gamma-distribution` : Two-parameter pore volume model
1223 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion
1225 Notes
1226 -----
1227 The APVD is only time-invariant under the steady-streamlines assumption
1228 (see :ref:`assumption-steady-streamlines`).
1230 The spreading from the gamma-distributed pore volumes represents macrodispersion
1231 (aquifer-scale heterogeneity). When ``std`` comes from calibration on measurements,
1232 it absorbs all mixing: macrodispersion, microdispersion, and an average molecular
1233 diffusion contribution. When ``std`` comes from streamline analysis, it represents
1234 macrodispersion only; microdispersion and molecular diffusion can be added via the
1235 dispersion parameters.
1236 See :ref:`concept-dispersion-scales` for guidance on when to add microdispersion.
1238 Examples
1239 --------
1240 >>> import pandas as pd
1241 >>> import numpy as np
1242 >>> from gwtransport.diffusion import gamma_infiltration_to_extraction
1243 >>>
1244 >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
1245 >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
1246 >>> cin = np.zeros(len(tedges) - 1)
1247 >>> cin[5:10] = 1.0
1248 >>> flow = np.ones(len(tedges) - 1) * 100.0
1249 >>>
1250 >>> cout = gamma_infiltration_to_extraction(
1251 ... cin=cin,
1252 ... flow=flow,
1253 ... tedges=tedges,
1254 ... cout_tedges=cout_tedges,
1255 ... mean=500.0,
1256 ... std=100.0,
1257 ... n_bins=5,
1258 ... streamline_length=100.0,
1259 ... molecular_diffusivity=1e-4,
1260 ... longitudinal_dispersivity=1.0,
1261 ... )
1262 """
1263 bins = gamma.bins(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta, n_bins=n_bins)
1264 return infiltration_to_extraction(
1265 cin=cin,
1266 flow=flow,
1267 tedges=tedges,
1268 cout_tedges=cout_tedges,
1269 aquifer_pore_volumes=bins["expected_values"],
1270 streamline_length=streamline_length,
1271 molecular_diffusivity=molecular_diffusivity,
1272 longitudinal_dispersivity=longitudinal_dispersivity,
1273 retardation_factor=retardation_factor,
1274 spinup=spinup,
1275 )
1278def gamma_extraction_to_infiltration(
1279 *,
1280 cout: npt.ArrayLike,
1281 flow: npt.ArrayLike,
1282 tedges: pd.DatetimeIndex,
1283 cout_tedges: pd.DatetimeIndex,
1284 mean: float | None = None,
1285 std: float | None = None,
1286 loc: float = 0.0,
1287 alpha: float | None = None,
1288 beta: float | None = None,
1289 n_bins: int = 100,
1290 streamline_length: float,
1291 molecular_diffusivity: float,
1292 longitudinal_dispersivity: float,
1293 retardation_factor: float = 1.0,
1294 regularization_strength: float = 1e-10,
1295 spinup: str | None = "constant",
1296) -> npt.NDArray[np.floating]:
1297 """
1298 Compute infiltration concentration from extracted water for gamma-distributed pore volumes.
1300 Inverts the forward transport model (advection + dispersion with gamma-distributed
1301 pore volumes) via Tikhonov regularization. This is a convenience wrapper around
1302 :func:`extraction_to_infiltration` that parameterizes the aquifer pore volume
1303 distribution as a (shifted) gamma distribution.
1305 Provide either (mean, std) or (alpha, beta); ``loc`` is optional and defaults to 0.
1307 Parameters
1308 ----------
1309 cout : array-like
1310 Concentration of the compound in extracted water.
1311 flow : array-like
1312 Flow rate of water in the aquifer [m³/day].
1313 tedges : pandas.DatetimeIndex
1314 Time edges for cin (output) and flow data. Has length of len(flow) + 1.
1315 cout_tedges : pandas.DatetimeIndex
1316 Time edges for cout data bins. Has length of len(cout) + 1.
1317 mean : float, optional
1318 Mean of the gamma distribution of the aquifer pore volume. Must be strictly
1319 greater than ``loc``.
1320 std : float, optional
1321 Standard deviation of the gamma distribution of the aquifer pore volume
1322 (invariant under the ``loc`` shift).
1323 loc : float, optional
1324 Location (minimum pore volume) of the gamma distribution. Must satisfy
1325 ``0 <= loc < mean``. Default is ``0.0``.
1326 alpha : float, optional
1327 Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).
1328 beta : float, optional
1329 Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).
1330 n_bins : int, optional
1331 Number of bins to discretize the gamma distribution. Default is 100.
1332 streamline_length : float
1333 Travel distance through the aquifer [m]. Applied uniformly to all
1334 gamma-discretized pore volumes.
1335 molecular_diffusivity : float
1336 Effective molecular diffusivity [m²/day]. Must be non-negative.
1337 See :func:`infiltration_to_extraction` for details on the interaction
1338 with retardation_factor.
1339 longitudinal_dispersivity : float
1340 Longitudinal dispersivity [m]. Must be non-negative.
1341 retardation_factor : float, optional
1342 Retardation factor (default 1.0). Values > 1.0 indicate slower transport.
1343 regularization_strength : float, optional
1344 Tikhonov regularization parameter. Default is 1e-10.
1345 spinup : {'constant'} or None, optional
1346 Spin-up policy (default ``'constant'``). ``'constant'`` extends tedges by
1347 100 years on each side so that output bins near the boundary are always
1348 informed. ``None`` disables the extension; output bins without sufficient
1349 upstream data become NaN. Float fraction-threshold mode is not implemented
1350 and raises ``NotImplementedError``.
1352 Returns
1353 -------
1354 numpy.ndarray
1355 Bin-averaged concentration in the infiltrating water. Length equals
1356 len(tedges) - 1. NaN values indicate time periods with no valid
1357 contributions from the extraction data.
1359 See Also
1360 --------
1361 extraction_to_infiltration : Deconvolution with explicit pore volume distribution
1362 gamma_infiltration_to_extraction : Forward operation (convolution)
1363 gwtransport.gamma.bins : Create gamma distribution bins
1364 gwtransport.advection.gamma_extraction_to_infiltration : Pure advection (no dispersion)
1365 :ref:`concept-gamma-distribution` : Two-parameter pore volume model
1366 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion
1368 Notes
1369 -----
1370 The APVD is only time-invariant under the steady-streamlines assumption
1371 (see :ref:`assumption-steady-streamlines`).
1373 The spreading from the gamma-distributed pore volumes represents macrodispersion
1374 (aquifer-scale heterogeneity). When ``std`` comes from calibration on measurements,
1375 it absorbs all mixing: macrodispersion, microdispersion, and an average molecular
1376 diffusion contribution. When ``std`` comes from streamline analysis, it represents
1377 macrodispersion only; microdispersion and molecular diffusion can be added via the
1378 dispersion parameters.
1379 See :ref:`concept-dispersion-scales` for guidance on when to add microdispersion.
1381 Examples
1382 --------
1383 >>> import pandas as pd
1384 >>> import numpy as np
1385 >>> from gwtransport.diffusion import gamma_extraction_to_infiltration
1386 >>>
1387 >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
1388 >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
1389 >>> cout = np.zeros(len(cout_tedges) - 1)
1390 >>> cout[5:10] = 1.0
1391 >>> flow = np.ones(len(tedges) - 1) * 100.0
1392 >>>
1393 >>> cin = gamma_extraction_to_infiltration(
1394 ... cout=cout,
1395 ... flow=flow,
1396 ... tedges=tedges,
1397 ... cout_tedges=cout_tedges,
1398 ... mean=500.0,
1399 ... std=100.0,
1400 ... n_bins=5,
1401 ... streamline_length=100.0,
1402 ... molecular_diffusivity=1e-4,
1403 ... longitudinal_dispersivity=1.0,
1404 ... )
1405 """
1406 bins = gamma.bins(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta, n_bins=n_bins)
1407 return extraction_to_infiltration(
1408 cout=cout,
1409 flow=flow,
1410 tedges=tedges,
1411 cout_tedges=cout_tedges,
1412 aquifer_pore_volumes=bins["expected_values"],
1413 streamline_length=streamline_length,
1414 molecular_diffusivity=molecular_diffusivity,
1415 longitudinal_dispersivity=longitudinal_dispersivity,
1416 retardation_factor=retardation_factor,
1417 regularization_strength=regularization_strength,
1418 spinup=spinup,
1419 )