Coverage for src/gwtransport/diffusion.py: 0%
173 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 20:54 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 20:54 +0000
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.
12Choosing among the three diffusion modules
13------------------------------------------
15This is the reference implementation: it evaluates the bin-averaged Kreft-Zuber flux
16concentration by resolution-aware composite Gauss-Legendre quadrature (splitting at
17flow-bin boundaries, with extra front-centred panels wherever a sharp breakthrough front
18is otherwise under-resolved).
19Prefer it only when the output grid is coarser than the flow detail -- it integrates the
20full within-bin flow, which the closed-form :mod:`gwtransport.diffusion_fast` approximates as
21constant per output bin. Otherwise that module computes the same physics to machine
22precision for *every* parameter regime (including ``retardation_factor != 1`` with non-zero
23molecular diffusivity, whose flux correction it also evaluates in closed form) and is
24~80-90x faster (no quadrature, no residence-time inversion). Both modules accept
25per-streamtube ``streamline_length`` / ``molecular_diffusivity`` /
26``longitudinal_dispersivity`` arrays (heterogeneous flow paths -- partially-penetrating
27wells, wedge-shaped capture zones).
29:mod:`gwtransport.diffusion_fast_fast` is the third rung and is approximate by design: it folds
30molecular diffusion into an effective dispersivity ``alpha_eff = alpha_L + D_m * R * V_pore /
31(L * q_mean)`` per streamtube, so the whole streamtube bundle collapses to one banded breakthrough
32on the native cumulative-volume grid -- the fastest of the three. At constant flow it reproduces
33:mod:`gwtransport.diffusion_fast` to ~1e-6 for smooth inputs and ~1e-4 for sharp ones, and it loses
34accuracy in the molecular-diffusion-dominated corner (``alpha_L`` ~ 0) under strongly variable flow,
35where the frozen record-mean flow leaves a commutator residual. This module is the ground truth both
36fast modules are validated against.
38Reported outlet concentration: Kreft-Zuber (1978) flux concentration
39---------------------------------------------------------------------
41The outlet concentration reported by this module is the **flux concentration**
43 C_F(L, t) = C_R(L, t) - (D_s / v_s) * dC_R/dx \|_{x=L}
45with the solute-front (retarded-frame) velocity v_s = Q L / (R V_pore) and the
46dispersion D_s = D_m + alpha_L * v_s, so the flux coefficient is
47D_s / v_s = D_m / v_s + alpha_L = R D_m / v_fluid + alpha_L (with the fluid
48velocity v_fluid = Q L / V_pore). The resident profile C_R solves the retarded
49ADE with advection v_s and dispersion D_s, so its flux-vs-resident correction
50must use v_s — not v_fluid; pairing v_s with the moving-frame variance below is
51what conserves mass for R > 1 with D_m > 0.
53— the solute mass flux at the outlet divided by the volumetric fluid flux. This
54is what is measured when sampling the extracted fluid. The resident
55concentration ``C_R`` is Bear (1972) eq. 10.6.4, the variable-flow moving-frame
56Ogata-Banks solution
58 C_R(L, V; t_j) = 0.5 * erfc((L - xi_j(V)) / (2 * sqrt(D_t(V))))
60with the dispersion variance accumulated in the moving (Lagrangian) frame:
62 D_t(V) = sigma^2(V) / 2 = D_m * tau(V) + alpha_L * xi(V)
64where:
66- D_m is the effective molecular (or thermal) diffusivity [m²/day]
67- alpha_L is the longitudinal dispersivity [m]
68- tau(V) is the elapsed time since infiltration [day], with V the cumulative
69 extracted volume
70- xi(V) = L (V - V_j) / (R V_pore) is the distance the parcel has actually
71 travelled [m]
73The K-Z flux-correction term is what makes the column-sum invariant
74``integral Q c_out dt = integral Q c_in dt`` hold under arbitrary variable Q.
75Without it, the leading-order C_R loses O(1/Pe) per column under variable Q +
76pure D_m.
78Implementation: the bin-averaged C_F is computed by resolution-aware composite
79Gauss-Legendre quadrature in volume space, split at flow-bin boundaries so each
80sub-interval sees a linear t(V). Within a sub-interval the erf-like front has
81width ``sqrt(4*D_t)`` (in volume units); for near-zero dispersivity this can be
82orders of magnitude below the flow-bin width, so a single fixed-order rule
83cannot resolve it. Sub-intervals whose front is under-resolved are therefore
84tiled with front-centred panels (fine near the front, flat tails outside),
85which restores the column-mass invariant to ~1e-11 for every dispersion regime;
86smooth/already-resolved sub-intervals keep the plain single 16-point rule. The
87variance is evaluated at each quadrature node from the parcel's own tau and xi
88histories — never capped at the residence time. The K-Z identity requires
89Bear's formula to satisfy the variable-coefficient ADE exactly, which holds only
90when D_t is allowed to keep growing past breakthrough.
92Macrodispersion vs microdispersion
93----------------------------------
95This module adds microdispersion (alpha_L) and molecular diffusion (D_m) on top of
96macrodispersion captured by the pore volume distribution (APVD). Both represent velocity
97heterogeneity at different scales. Microdispersion is an aquifer property; macrodispersion
98depends additionally on hydrological boundary conditions. See :ref:`concept-dispersion-scales`
99for guidance on when to use each approach and how to avoid double-counting spreading effects.
101Streamtube assumption (no cross-sectional area parameter)
102---------------------------------------------------------
104Each entry in ``aquifer_pore_volumes`` is treated as an independent 1D streamtube. There is
105no cross-sectional area parameter: the variance budget uses ``2 D_m tau`` (molecular
106diffusion in time) and ``2 alpha_L xi`` (microdispersion in travelled distance), with
107the streamline length ``L`` and the pore volume ``V_pore`` together fixing the implicit
108streamtube cross-section ``A = V_pore / L``. Callers who need distributed-area effects must
109provide multiple streamtubes (via ``aquifer_pore_volumes`` or the gamma-parameterised
110wrappers).
112Available functions:
114- :func:`infiltration_to_extraction` - Forward transport from an explicit ``aquifer_pore_volumes``
115 distribution: returns the bin-averaged Kreft-Zuber flux concentration on ``cout_tedges``, integrated
116 over the full ``tedges``-resolution flow within each output bin and averaged with equal weight over
117 the streamtubes. ``streamline_length``, ``molecular_diffusivity`` and ``longitudinal_dispersivity``
118 are either scalars shared by all streamtubes or one value per pore volume. Output bins that no
119 infiltration has reached, or whose look-back leaves the record, are NaN.
121- :func:`extraction_to_infiltration` - Reverse direction: builds the same forward coefficient matrix
122 and solves ``W @ cin = cout`` by Tikhonov regularization, returning the bin-averaged infiltration
123 concentration on ``tedges``. NaN entries in ``cout`` mark measurement gaps; their rows are dropped
124 from the solve and cin bins that nothing else constrains are returned as NaN.
126- :func:`gamma_infiltration_to_extraction` - :func:`infiltration_to_extraction` with the pore volume
127 distribution given as a (shifted) gamma -- either (mean, std) or (alpha, beta), plus ``loc`` --
128 discretized into ``n_bins`` equal-probability streamtubes that share one ``streamline_length`` and
129 one pair of dispersion parameters.
131- :func:`gamma_extraction_to_infiltration` - :func:`extraction_to_infiltration` with the same gamma
132 parameterization of the pore volume distribution: reconstructs ``cin`` on ``tedges`` from ``cout``.
134References
135----------
136Bear, J. (1972). Dynamics of Fluids in Porous Media. American Elsevier
137Publishing Company. Equation 10.6.4 (variable-flow Ogata-Banks form). Provides
138the resident concentration ``C_R``.
140Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion
141equation and its solutions for different initial and boundary conditions.
142Chemical Engineering Science, 33(11), 1471-1480. Eq. 2 gives the resident-to-
143flux concentration transformation; Eq. 1 is the mass-balance identity that
144makes the column-sum invariant exact.
145"""
147import numpy as np
148import numpy.typing as npt
149import pandas as pd
150from scipy import special
152from gwtransport import gamma
153from gwtransport._diffusion_shared import _broadcast_to_pore_volumes, _extend_tedges_flag
154from gwtransport._time import dt_to_days, tedges_to_days
155from gwtransport._validation import (
156 _validate_no_nan,
157 _validate_non_negative_array,
158 _validate_positive_array,
159 _validate_retardation_factor,
160 _validate_scalar_or_matching_length,
161 _validate_tedges_parity,
162)
163from gwtransport.residence_time import fraction_explained_full
164from gwtransport.utils import cumulative_flow_volume, solve_inverse_transport
166# Numerical tolerance for coefficient sum to determine valid output bins
167_EPSILON_COEFF_SUM = 1e-10
169# Gauss-Legendre quadrature nodes and weights for volume-space integration
170_GL_NODES, _GL_WEIGHTS = np.polynomial.legendre.leggauss(16)
172# Resolution-aware composite-quadrature template for the erf-like breakthrough front.
173# When the front width sqrt(4*D_t) (in volume units) is much smaller than a
174# (cell, flow-bin) sub-interval, plain 16-point GL cannot resolve it. Cells flagged
175# as under-resolved get panels placed at ``V_front + front_width * _FRONT_OFFSETS``
176# (clipped to the sub-interval); the two outer panels then cover the flat erf tails,
177# where 16-point GL is already exact. Panels near the front are ~1 front-width wide,
178# spanning +-6 front-widths (erf and the flux-correction Gaussian are flat to ~1e-16
179# beyond that). A cell is refined only when its front lies inside the sub-interval and
180# the sub-interval is wider than _REFINE_RATIO front-widths, so adaptivity triggers
181# only near sharp fronts (smooth regimes keep the plain single-panel cost and answer).
182_FRONT_REACH = 6.0
183_FRONT_OFFSETS = np.arange(-_FRONT_REACH, _FRONT_REACH + 0.5, 1.0)
184_REFINE_RATIO = 4.0
187def _cfrac_mean_volume(
188 *,
189 step_widths: npt.NDArray[np.floating],
190 cumulative_volume_at_cout_tedges: npt.NDArray[np.floating],
191 cumulative_volume_at_cin_tedges: npt.NDArray[np.floating],
192 tedges_days: npt.NDArray[np.floating],
193 molecular_diffusivity: float,
194 longitudinal_dispersivity: float,
195 r_vpv: float,
196 streamline_len: float,
197) -> npt.NDArray[np.floating]:
198 r"""Compute bin-averaged flux concentration at the outlet for each cell.
200 For each cell (cout-bin *i*, cin-edge *j*), computes the flow-weighted
201 average of the Kreft-Zuber (1978) **flux concentration** at the outlet:
203 .. math::
205 \text{frac}_{i,j} = \frac{1}{\Delta V_i}
206 \int_{V_i}^{V_{i+1}} C_F\!\left(L,\,V;\,t_j\right) dV
208 with :math:`C_F = C_R - (D_s / v_s) \, \partial_x C_R\big|_{x=L}`, Bear's
209 resident concentration :math:`C_R`, the moving-frame variance
210 :math:`D_t = D_m \tau + \alpha_L \xi` and the solute-front flux coefficient
211 :math:`D_s/v_s`, all as derived in the module docstring. The correction term
212 added to :math:`C_R` at each quadrature node is
214 .. math::
216 \frac{D_s}{v_s(t(V))} \cdot
217 \frac{1}{\sqrt{4\pi\,D_t(V)}}\,
218 \exp\!\left( -\frac{(L - \xi_j(V))^2}{4\,D_t(V)} \right).
220 Implementation: resolution-aware composite Gauss-Legendre quadrature in
221 volume space, split at flow-bin boundaries so that within each sub-interval
222 :math:`t(V)` is linear. The erf-like front has width :math:`\sqrt{4 D_t}`
223 (in volume units); a sub-interval whose front is under-resolved by a single
224 16-point rule (front width far below the sub-interval width) is tiled with
225 front-centred panels (see ``_FRONT_OFFSETS``), while smooth/already-resolved
226 sub-intervals keep the plain single 16-point rule (bit-identical to it *per
227 sub-interval*; a cell split at flow-bin boundaries still integrates more
228 accurately than one un-split rule over the whole cell). The moving-frame
229 variance keeps growing past breakthrough and is never capped: the K-Z
230 identity holds only while Bear's formula satisfies the variable-coefficient
231 ADE exactly.
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.
260 """
261 n_cout_edges, n_cin_edges = step_widths.shape
262 n_cout_bins = n_cout_edges - 1
264 x_lo = step_widths[:-1]
265 x_hi = step_widths[1:]
266 dx = x_hi - x_lo
268 v_lo_arr = cumulative_volume_at_cout_tedges[:-1]
269 v_hi_arr = cumulative_volume_at_cout_tedges[1:]
271 is_valid = ~np.isnan(x_lo) & ~np.isnan(x_hi)
273 frac = np.full((n_cout_bins, n_cin_edges), np.nan)
275 # --- No dispersion: C_F = C_R = step function (no dispersive flux) ---
276 if molecular_diffusivity == 0.0 and longitudinal_dispersivity == 0.0:
277 with np.errstate(divide="ignore", invalid="ignore"):
278 cr_no_disp = 0.5 + 0.5 * (np.abs(x_hi) - np.abs(x_lo)) / dx
279 cr_no_disp = np.where(dx == 0.0, 0.5 + 0.5 * np.sign(x_lo), cr_no_disp)
280 return np.where(is_valid, cr_no_disp, frac)
282 # --- Pre-compute solute-front velocity and K-Z coefficient (D_s/v_s) per flow bin ---
283 dv_per_bin = np.diff(cumulative_volume_at_cin_tedges)
284 dt_per_bin = np.diff(tedges_days)
285 with np.errstate(divide="ignore", invalid="ignore"):
286 q_per_bin = np.where(dt_per_bin > 0, dv_per_bin / dt_per_bin, 0.0)
287 # Solute-front velocity v_s = Q L / (R V_pore), the advection speed of the retarded ADE
288 # that C_R solves -- not the fluid velocity Q L / V_pore.
289 v_per_bin = q_per_bin * streamline_len / r_vpv
290 # (D_s/v_s) = D_m/v_s + alpha_L. At v_s=0 the bin has dV=0 and is skipped below; the
291 # surrounding errstate suppresses the divide warning for those lanes.
292 dl_over_v_per_bin = np.where(
293 v_per_bin > 0,
294 molecular_diffusivity / v_per_bin + longitudinal_dispersivity,
295 0.0,
296 )
298 # --- Resolution-aware composite Gauss-Legendre quadrature, split by flow bins ---
299 # The integration window per (cell, flow-bin) is the intersection of
300 # [V_lo, V_hi] (cell), [ve_lo, ve_hi] (flow bin), and [V_j, infty) (parcel
301 # entered). Within each sub-interval, t(V) is linear. Where the sub-interval is
302 # much wider than the front width sqrt(4*D_t), the erf-like front is under-resolved
303 # by a single 16-point GL rule; such sub-intervals are tiled with front-centred
304 # panels (see _FRONT_OFFSETS). Smooth sub-intervals keep the single panel and are
305 # bit-identical to the plain 16-point rule.
306 idx_i, idx_j = np.nonzero(is_valid)
307 if len(idx_i) == 0:
308 return frac
310 v_lo_cells = v_lo_arr[idx_i]
311 v_hi_cells = v_hi_arr[idx_i]
312 v_cin_cells = cumulative_volume_at_cin_tedges[idx_j]
313 t_j_cells = tedges_days[idx_j]
314 total_dv = v_hi_cells - v_lo_cells
315 valid_cells = total_dv > 0
317 # Post-injection lower bound, loop-invariant: max(V_lo, V_cin).
318 v_lo_or_cin = np.maximum(v_lo_cells, v_cin_cells)
320 integral_cf = np.zeros(len(idx_i))
322 vol_edges = cumulative_volume_at_cin_tedges
323 for k in range(len(vol_edges) - 1):
324 ve_lo, ve_hi = vol_edges[k], vol_edges[k + 1]
325 if v_per_bin[k] <= 0.0:
326 continue
327 dl_over_v_k = dl_over_v_per_bin[k]
328 dt_sub_bin = tedges_days[k + 1] - tedges_days[k]
329 dv_sub_edge = ve_hi - ve_lo
331 # Intersection of cell, flow-bin, and post-injection range
332 sub_lo = np.maximum(v_lo_or_cin, ve_lo)
333 sub_hi = np.minimum(v_hi_cells, ve_hi)
334 overlap = (sub_hi > sub_lo) & valid_cells
335 if not np.any(overlap):
336 continue
338 lo = sub_lo[overlap]
339 hi = sub_hi[overlap]
340 vcin = v_cin_cells[overlap]
341 tj = t_j_cells[overlap]
343 # Front centre (x = 0 => xi = L => V = V_cin + r_vpv) and its width in volume.
344 # front_width = sqrt(4*D_t_front) * r_vpv / L, with D_t_front = D_m*tau_front +
345 # alpha_L*L (xi = L at the front). t(V) is linear within flow bin k.
346 v_front = vcin + r_vpv
347 t_front = tedges_days[k] + (v_front - ve_lo) * (dt_sub_bin / dv_sub_edge)
348 tau_front = np.maximum(t_front - tj, 0.0)
349 dt_front = molecular_diffusivity * tau_front + longitudinal_dispersivity * streamline_len
350 front_width = np.sqrt(4.0 * dt_front) * r_vpv / streamline_len
352 # Refine only where a sharp front region intersects this sub-interval and is
353 # under-resolved by a single 16-point rule; elsewhere the integrand is flat
354 # or already resolved, so one panel is exact. The front-region test (not just
355 # "centre in this bin") also catches a front whose tail spills across a
356 # flow-bin boundary. A spuriously large extrapolated front_width far from the
357 # front fails ``underresolved`` and so cannot trigger refinement.
358 front_hits = (v_front + _FRONT_REACH * front_width > lo) & (v_front - _FRONT_REACH * front_width < hi)
359 underresolved = (hi - lo) > _REFINE_RATIO * front_width
360 if np.any(front_hits & underresolved):
361 inner = np.clip(
362 v_front[:, np.newaxis] + front_width[:, np.newaxis] * _FRONT_OFFSETS[np.newaxis, :],
363 lo[:, np.newaxis],
364 hi[:, np.newaxis],
365 )
366 edges = np.concatenate([lo[:, np.newaxis], inner, hi[:, np.newaxis]], axis=1)
367 else:
368 edges = np.stack([lo, hi], axis=1)
370 p_lo = edges[:, :-1]
371 p_hi = edges[:, 1:]
372 p_mid = 0.5 * (p_lo + p_hi)
373 p_half = 0.5 * (p_hi - p_lo)
375 # GL nodes over every panel: shape (n_cell, n_panel, n_gl)
376 v_nodes = p_mid[:, :, np.newaxis] + p_half[:, :, np.newaxis] * _GL_NODES[np.newaxis, np.newaxis, :]
378 # Geometry: x = xi - L = (V - V_j - r_vpv) * L / r_vpv (parcel position
379 # relative to outlet); xi = parcel travel distance.
380 x_nodes = (v_nodes - vcin[:, np.newaxis, np.newaxis] - r_vpv) * streamline_len / r_vpv
381 xi_nodes = x_nodes + streamline_len
383 t_nodes = tedges_days[k] + (v_nodes - ve_lo) * (dt_sub_bin / dv_sub_edge)
384 # tau >= 0 by construction (lo >= v_cin); clip for safety.
385 tau_nodes = np.maximum(t_nodes - tj[:, np.newaxis, np.newaxis], 0.0)
387 # Bear's variance accumulator (sigma^2/2) — NO capping at RT/L
388 dt_nodes = molecular_diffusivity * tau_nodes + longitudinal_dispersivity * xi_nodes
390 with np.errstate(divide="ignore", invalid="ignore"):
391 arg = x_nodes / (2.0 * np.sqrt(dt_nodes))
392 # C_R = 0.5 * (1 + erf(arg)) = 0.5 * erfc((L-xi)/(2*sqrt(Dt)))
393 erf_vals = np.where(np.isfinite(arg), special.erf(arg), np.sign(x_nodes))
394 cr_vals = 0.5 * (1.0 + erf_vals)
396 # K-Z flux correction: FC = (D_s/v_s) * (1/sqrt(4 pi D_t)) * exp(-arg^2)
397 with np.errstate(divide="ignore", invalid="ignore"):
398 gauss_vals = np.where(
399 dt_nodes > 0.0,
400 np.exp(-(arg**2)) / np.sqrt(4.0 * np.pi * dt_nodes),
401 0.0,
402 )
403 cf_vals = cr_vals + dl_over_v_k * gauss_vals
405 # Integrate: GL-weight over nodes, then sum panel contributions per cell.
406 # The weight contraction is done in 2D so a single-panel (non-refined)
407 # sub-interval is bit-identical to a plain 16-point rule.
408 cf_weighted = (cf_vals.reshape(-1, cf_vals.shape[-1]) @ _GL_WEIGHTS).reshape(cf_vals.shape[:-1])
409 integral_cf[overlap] += (p_half * cf_weighted).sum(axis=1)
411 with np.errstate(divide="ignore", invalid="ignore"):
412 frac_cells = np.where(valid_cells, integral_cf / total_dv, np.nan)
413 frac[idx_i, idx_j] = frac_cells
415 return frac
418def _validate_diffusion_inputs(
419 *,
420 tedges: pd.DatetimeIndex,
421 flow: npt.NDArray[np.floating],
422 aquifer_pore_volumes: npt.NDArray[np.floating],
423 streamline_length: npt.NDArray[np.floating],
424 molecular_diffusivity: npt.NDArray[np.floating],
425 longitudinal_dispersivity: npt.NDArray[np.floating],
426 retardation_factor: float,
427 cin_values: npt.NDArray[np.floating] | None = None,
428 cout_values: npt.NDArray[np.floating] | None = None,
429 cout_tedges: pd.DatetimeIndex | None = None,
430) -> None:
431 """Validate inputs common to diffusion forward / reverse entry points.
433 The caller supplies the kwargs of its own direction; the parity checks that apply to
434 that direction then run:
436 - ``cin_values`` (forward): ``tedges`` parities cin and flow.
437 - ``cout_values`` + ``cout_tedges`` (reverse): ``tedges`` parities flow, ``cout_tedges``
438 parities cout.
440 The physical-parameter checks below run either way.
442 Raises
443 ------
444 ValueError
445 If any check fails. The message identifies which invariant was violated.
446 """
447 n_pore_volumes = len(aquifer_pore_volumes)
449 if cin_values is not None:
450 _validate_tedges_parity(tedges, cin_values, tedges_name="tedges", values_name="cin")
451 _validate_tedges_parity(tedges, flow, tedges_name="tedges", values_name="flow")
452 elif cout_values is not None and cout_tedges is not None:
453 _validate_tedges_parity(tedges, flow, tedges_name="tedges", values_name="flow")
454 _validate_tedges_parity(cout_tedges, cout_values, tedges_name="cout_tedges", values_name="cout")
455 if len(aquifer_pore_volumes) != len(streamline_length):
456 msg = "aquifer_pore_volumes and streamline_length must have the same length"
457 raise ValueError(msg)
458 _validate_scalar_or_matching_length(
459 molecular_diffusivity,
460 name="molecular_diffusivity",
461 expected_len=n_pore_volumes,
462 ref_name="aquifer_pore_volumes",
463 )
464 _validate_scalar_or_matching_length(
465 longitudinal_dispersivity,
466 name="longitudinal_dispersivity",
467 expected_len=n_pore_volumes,
468 ref_name="aquifer_pore_volumes",
469 )
470 _validate_non_negative_array(molecular_diffusivity, name="molecular_diffusivity")
471 _validate_non_negative_array(longitudinal_dispersivity, name="longitudinal_dispersivity")
472 # Reverse cout may contain NaN (measurement gaps); gapped rows are excluded from the solve.
473 if cin_values is not None:
474 _validate_no_nan(cin_values, name="cin")
475 _validate_no_nan(flow, name="flow")
476 _validate_non_negative_array(flow, name="flow", message="flow must be non-negative (negative flow not supported)")
477 _validate_positive_array(aquifer_pore_volumes, name="aquifer_pore_volumes")
478 _validate_positive_array(streamline_length, name="streamline_length")
479 _validate_retardation_factor(retardation_factor)
482def _prepare_diffusion_arrays(
483 *,
484 flow: npt.ArrayLike,
485 aquifer_pore_volumes: npt.ArrayLike,
486 streamline_length: npt.ArrayLike,
487 molecular_diffusivity: npt.ArrayLike,
488 longitudinal_dispersivity: npt.ArrayLike,
489) -> tuple[
490 npt.NDArray[np.floating],
491 npt.NDArray[np.floating],
492 npt.NDArray[np.floating],
493 npt.NDArray[np.floating],
494 npt.NDArray[np.floating],
495]:
496 """Coerce flow / geometry / dispersion inputs to broadcasted float arrays.
498 Each per-streamtube parameter (``streamline_length``, ``molecular_diffusivity``,
499 ``longitudinal_dispersivity``) may be passed as a scalar; it is broadcast to one
500 value per pore volume. The returned arrays are read-only views when broadcast (none
501 is mutated downstream).
503 Returns
504 -------
505 tuple of ndarray
506 ``(flow, aquifer_pore_volumes, streamline_length, molecular_diffusivity,
507 longitudinal_dispersivity)`` as float arrays.
508 """
509 flow = np.asarray(flow, dtype=float)
510 aquifer_pore_volumes = np.asarray(aquifer_pore_volumes, dtype=float)
511 n_pore_volumes = len(aquifer_pore_volumes)
512 return (
513 flow,
514 aquifer_pore_volumes,
515 _broadcast_to_pore_volumes(streamline_length, n_pore_volumes),
516 _broadcast_to_pore_volumes(molecular_diffusivity, n_pore_volumes),
517 _broadcast_to_pore_volumes(longitudinal_dispersivity, n_pore_volumes),
518 )
521def _infiltration_to_extraction_coeff_matrix(
522 *,
523 flow: npt.NDArray[np.floating],
524 tedges: pd.DatetimeIndex,
525 cout_tedges: pd.DatetimeIndex,
526 aquifer_pore_volumes: npt.NDArray[np.floating],
527 streamline_length: npt.NDArray[np.floating],
528 molecular_diffusivity: npt.NDArray[np.floating],
529 longitudinal_dispersivity: npt.NDArray[np.floating],
530 retardation_factor: float,
531 extend_tedges: bool = True,
532) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.bool_]]:
533 """Build the forward coefficient matrix for diffusion transport.
535 Constructs the matrix W such that ``cout = W @ cin``, accounting for
536 advection, microdispersion, and molecular diffusion. NaN entries in the raw coefficient
537 matrix are replaced with zero.
539 Parameters
540 ----------
541 flow : ndarray
542 Flow rate of water [m³/day]. Already validated.
543 tedges : DatetimeIndex
544 Cin/flow time edges (not yet extended for spin-up).
545 cout_tedges : DatetimeIndex
546 Cout time edges.
547 aquifer_pore_volumes : ndarray
548 Pore volumes [m³]. Already validated.
549 streamline_length : ndarray
550 Travel distances [m]. Already validated.
551 molecular_diffusivity : ndarray
552 Effective molecular diffusivities [m²/day]. Already broadcasted.
553 See :func:`infiltration_to_extraction` for physical interpretation.
554 longitudinal_dispersivity : ndarray
555 Longitudinal dispersivities [m]. Already broadcasted.
556 retardation_factor : float
557 Retardation factor.
559 Returns
560 -------
561 coeff_matrix : ndarray
562 Filled coefficient matrix of shape (n_cout, n_cin). NaN replaced
563 with zero.
564 valid_cout_bins : ndarray
565 Boolean mask of shape (n_cout,) indicating valid output bins.
566 """
567 if extend_tedges:
568 # Extend tedges by 100 years on each side to provide warm-start cin
569 # and post-data flow for cout bins near the boundaries. Equivalent to
570 # the public ``spinup="constant"`` policy in other modules.
571 tedges = pd.DatetimeIndex([
572 tedges[0] - pd.Timedelta("36500D"),
573 *list(tedges[1:-1]),
574 tedges[-1] + pd.Timedelta("36500D"),
575 ])
577 # Compute the cumulative flow at tedges
578 cumulative_volume_at_cin_tedges = cumulative_flow_volume(flow, dt_to_days(tedges)) # m³
580 # Compute the cumulative flow at cout_tedges. Both edge arrays are first reduced to a shared
581 # day axis: np.interp coerces each datetime64 operand to int64 in its own resolution, so a
582 # cout_tedges / tedges unit mismatch (e.g. ns vs us) would send every query out of range and
583 # silently return all-NaN.
584 tedges_days_arr = tedges_to_days(tedges)
585 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0])
586 cumulative_volume_at_cout_tedges = np.interp(
587 cout_tedges_days, tedges_days_arr, cumulative_volume_at_cin_tedges
588 ).astype(float)
590 # Output bin valid where every streamtube's advective look-back is in-record across the whole
591 # bin -- i.e. advective coverage == 1 for all pore volumes (NaN outside the record -> invalid).
592 # This is the advective validity gate only; the dispersive informedness is the captured kernel
593 # mass (total_coeff) applied downstream.
594 valid_cout_bins = np.all(
595 fraction_explained_full(
596 flow=flow,
597 tedges=tedges,
598 cout_tedges=cout_tedges,
599 aquifer_pore_volumes=aquifer_pore_volumes,
600 retardation_factor=retardation_factor,
601 direction="extraction_to_infiltration",
602 )
603 >= 1.0,
604 axis=0,
605 )
607 # Initialize coefficient matrix accumulator
608 n_cout_bins = len(cout_tedges) - 1
609 n_cin_bins = len(flow)
610 accumulated_coeff = np.zeros((n_cout_bins, n_cin_bins))
612 # Loop over each pore volume
613 for i_pv in range(len(aquifer_pore_volumes)):
614 r_vpv = retardation_factor * aquifer_pore_volumes[i_pv]
616 delta_volume = cumulative_volume_at_cout_tedges[:, None] - cumulative_volume_at_cin_tedges[None, :] - r_vpv
618 step_widths = delta_volume / r_vpv * streamline_length[i_pv]
620 frac = _cfrac_mean_volume(
621 step_widths=step_widths,
622 cumulative_volume_at_cout_tedges=cumulative_volume_at_cout_tedges,
623 cumulative_volume_at_cin_tedges=cumulative_volume_at_cin_tedges,
624 tedges_days=tedges_days_arr,
625 molecular_diffusivity=float(molecular_diffusivity[i_pv]),
626 longitudinal_dispersivity=float(longitudinal_dispersivity[i_pv]),
627 r_vpv=r_vpv,
628 streamline_len=streamline_length[i_pv],
629 )
631 accumulated_coeff += frac[:, :-1] - frac[:, 1:]
633 coeff_matrix_filled = np.nan_to_num(accumulated_coeff / len(aquifer_pore_volumes), nan=0.0)
635 return coeff_matrix_filled, valid_cout_bins
638def infiltration_to_extraction(
639 *,
640 cin: npt.ArrayLike,
641 flow: npt.ArrayLike,
642 tedges: pd.DatetimeIndex,
643 cout_tedges: pd.DatetimeIndex,
644 aquifer_pore_volumes: npt.ArrayLike,
645 streamline_length: npt.ArrayLike,
646 molecular_diffusivity: npt.ArrayLike,
647 longitudinal_dispersivity: npt.ArrayLike,
648 retardation_factor: float = 1.0,
649 spinup: str | None = "constant",
650) -> npt.NDArray[np.floating]:
651 """
652 Compute extracted concentration with advection, microdispersion, and molecular diffusion.
654 This function models 1D solute transport through an aquifer system along orthogonal
655 (Cartesian) flow paths. Each aquifer pore volume is an independent streamline carrying
656 advection with microdispersion (alpha_L) and molecular diffusion (D_m); the spread across
657 the pore volume distribution provides macrodispersion. Linear sorption enters via the
658 retardation factor.
660 The physical model assumes:
662 1. Water infiltrates with concentration cin at time t_in
663 2. Water travels distance L through aquifer with residence time tau = V_pore / Q
664 3. During transport, microdispersion and molecular diffusion spread each streamline,
665 while the spread across pore volumes provides macrodispersion
666 4. At extraction, the concentration is a dispersed breakthrough curve
668 The reported extracted concentration is the Kreft-Zuber (1978) **flux
669 concentration** at the outlet -- the solute mass flux divided by the
670 volumetric fluid flux, which is what is measured when sampling the
671 outflowing fluid. Microdispersion and molecular diffusion enter as the
672 moving-frame variance ``sigma^2(V) = 2*D_m*tau(V) + 2*alpha_L*xi(V)``, with
673 ``tau(V)`` the elapsed time since infiltration and ``xi(V)`` the distance the
674 parcel has actually travelled. See the module docstring for the derivation
675 and for why the flux form is what makes the column-sum invariant
676 ``integral Q c_out dt = integral Q c_in dt`` hold exactly under variable flow.
678 Parameters
679 ----------
680 cin : array-like
681 Concentration of the compound in infiltrating water [concentration units].
682 Length must match the number of time bins defined by tedges. The model assumes
683 this value is constant over each interval ``[tedges[i], tedges[i+1])``.
684 flow : array-like
685 Flow rate of water in the aquifer [m³/day].
686 Length must match cin and the number of time bins defined by tedges. The model
687 assumes this value is constant over each interval ``[tedges[i], tedges[i+1])``.
688 tedges : pandas.DatetimeIndex
689 Time edges defining bins for both cin and flow data. Has length of
690 len(cin) + 1.
691 cout_tedges : pandas.DatetimeIndex
692 Time edges for output data bins. Has length of desired output + 1.
693 The output concentration is averaged over each bin.
694 aquifer_pore_volumes : array-like
695 Array of aquifer pore volumes [m³] representing the distribution
696 of flow paths. Each pore volume determines the residence time for
697 that flow path: tau = V_pore / Q.
698 streamline_length : array-like
699 Array of travel distances [m] corresponding to each pore volume.
700 Must have the same length as aquifer_pore_volumes.
701 molecular_diffusivity : float or array-like
702 Effective (retarded-frame) molecular diffusivity [m²/day]. Can be a
703 scalar (same for all pore volumes) or an array with the same length as
704 aquifer_pore_volumes. Must be non-negative. For solute transport, this is
705 the molecular diffusion coefficient D_m [m²/day] — typically ~1e-5 m²/day,
706 negligible compared to microdispersion. For heat transport, pass the
707 thermal diffusivity D_th = lambda / (rho*c)_eff [m²/day], typically
708 0.01-0.1 m²/day.
710 Internally, this contributes ``2 * molecular_diffusivity * tau`` to the
711 variance, where ``tau`` is the elapsed time in days (no extra factor of
712 R). The retardation factor instead enters the flux coefficient
713 ``D_s/v_s = R D_m / v_fluid + alpha_L`` through the solute-front velocity
714 ``v_s = Q L / (R V_pore)``. For heat transport, the thermal diffusivity
715 already represents the effective diffusivity D_eff in the porous matrix;
716 for solutes the contribution is typically negligible.
717 longitudinal_dispersivity : float or array-like
718 Longitudinal dispersivity [m]. Can be a scalar (same for all pore
719 volumes) or an array with the same length as aquifer_pore_volumes.
720 Must be non-negative. Represents microdispersion from pore-scale velocity variations.
721 Set to 0 for pure molecular diffusion.
722 retardation_factor : float, optional
723 Retardation factor of the compound in the aquifer (default 1.0).
724 Values > 1.0 indicate slower transport due to sorption.
725 spinup : {'constant'} or None, optional
726 Spin-up policy (default ``'constant'``). ``'constant'`` extends tedges by
727 100 years on each side so that output bins near the boundary are always
728 informed. ``None`` disables the extension; output bins without sufficient
729 upstream data become NaN. Float fraction-threshold mode is not implemented
730 and raises ``NotImplementedError``.
732 Returns
733 -------
734 numpy.ndarray
735 Bin-averaged concentration in the extracted water. Same units as cin.
736 Length equals len(cout_tedges) - 1. NaN values indicate time periods
737 with no valid contributions from the infiltration data.
739 Raises
740 ------
741 ValueError
742 If input dimensions are inconsistent, if diffusivity is negative,
743 or if aquifer_pore_volumes and streamline_length have different lengths.
745 See Also
746 --------
747 extraction_to_infiltration : Inverse operation (deconvolution)
748 gwtransport.advection.infiltration_to_extraction : Pure advection (no dispersion)
749 gwtransport.diffusion_fast.infiltration_to_extraction : Fast closed-form equivalent
750 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion
752 Notes
753 -----
754 The algorithm constructs a coefficient matrix W where cout = W @ cin:
756 1. For each pore volume, build a cell grid in cumulative volume space:
758 - cells span ``(V_cout[i], V_cout[i+1]) x V_cin[j]`` for each
759 (cout-bin i, cin-edge j)
760 - delta_volume = V_cout - V_cin - r_vpv encodes the parcel's offset
761 from the outlet at each (cout-edge, cin-edge)
763 2. For each cell, compute the bin-averaged Kreft-Zuber flux concentration
764 ``frac[i, j] = (1/dV_i) * integral C_F(L, V; t_j) dV`` by resolution-aware
765 composite Gauss-Legendre quadrature in volume space, split at flow-bin
766 boundaries so that ``t(V)`` is linear within each sub-interval. Where the
767 erf-like front (width ``sqrt(4*D_t)`` in volume units) is under-resolved by
768 a single 16-point rule -- as for near-zero dispersivity -- the sub-interval
769 is tiled with front-centred panels; smooth sub-intervals keep the single
770 rule. The moving-frame variance ``D_t = D_m*tau + alpha_L*xi`` is evaluated
771 at each quadrature node (never capped at the residence time).
773 3. Coefficient for bin: ``coeff[i,j] = frac[i, j] - frac[i, j+1]``. This
774 is the contribution of cin[j] to cout[i] in the W matrix.
776 4. Average coefficients across all pore volumes.
778 Examples
779 --------
780 Basic usage with constant flow:
782 >>> import pandas as pd
783 >>> import numpy as np
784 >>> from gwtransport.diffusion import infiltration_to_extraction
785 >>>
786 >>> # Create time edges
787 >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
788 >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
789 >>>
790 >>> # Input concentration (step function) and constant flow
791 >>> cin = np.zeros(len(tedges) - 1)
792 >>> cin[5:10] = 1.0 # Pulse of concentration
793 >>> flow = np.ones(len(tedges) - 1) * 100.0 # 100 m³/day
794 >>>
795 >>> # Single pore volume of 500 m³, travel distance 100 m
796 >>> aquifer_pore_volumes = np.array([500.0])
797 >>> streamline_length = np.array([100.0])
798 >>>
799 >>> # Compute with dispersion (molecular diffusion + dispersivity)
800 >>> # Scalar values broadcast to all pore volumes
801 >>> cout = infiltration_to_extraction(
802 ... cin=cin,
803 ... flow=flow,
804 ... tedges=tedges,
805 ... cout_tedges=cout_tedges,
806 ... aquifer_pore_volumes=aquifer_pore_volumes,
807 ... streamline_length=streamline_length,
808 ... molecular_diffusivity=1e-4, # m²/day, same for all pore volumes
809 ... longitudinal_dispersivity=1.0, # m, same for all pore volumes
810 ... )
812 With multiple pore volumes (heterogeneous aquifer):
814 >>> # Distribution of pore volumes and corresponding travel distances
815 >>> aquifer_pore_volumes = np.array([400.0, 500.0, 600.0])
816 >>> streamline_length = np.array([80.0, 100.0, 120.0])
817 >>>
818 >>> # Scalar diffusion parameters broadcast to all pore volumes
819 >>> cout = infiltration_to_extraction(
820 ... cin=cin,
821 ... flow=flow,
822 ... tedges=tedges,
823 ... cout_tedges=cout_tedges,
824 ... aquifer_pore_volumes=aquifer_pore_volumes,
825 ... streamline_length=streamline_length,
826 ... molecular_diffusivity=1e-4, # m²/day
827 ... longitudinal_dispersivity=1.0, # m
828 ... )
829 """
830 cout_tedges = pd.DatetimeIndex(cout_tedges)
831 tedges = pd.DatetimeIndex(tedges)
833 cin = np.asarray(cin, dtype=float)
834 flow, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity = (
835 _prepare_diffusion_arrays(
836 flow=flow,
837 aquifer_pore_volumes=aquifer_pore_volumes,
838 streamline_length=streamline_length,
839 molecular_diffusivity=molecular_diffusivity,
840 longitudinal_dispersivity=longitudinal_dispersivity,
841 )
842 )
844 _validate_diffusion_inputs(
845 tedges=tedges,
846 flow=flow,
847 aquifer_pore_volumes=aquifer_pore_volumes,
848 streamline_length=streamline_length,
849 molecular_diffusivity=molecular_diffusivity,
850 longitudinal_dispersivity=longitudinal_dispersivity,
851 retardation_factor=retardation_factor,
852 cin_values=cin,
853 )
855 extend_tedges = _extend_tedges_flag(spinup)
856 coeff_matrix, valid_cout_bins = _infiltration_to_extraction_coeff_matrix(
857 flow=flow,
858 tedges=tedges,
859 cout_tedges=cout_tedges,
860 aquifer_pore_volumes=aquifer_pore_volumes,
861 streamline_length=streamline_length,
862 molecular_diffusivity=molecular_diffusivity,
863 longitudinal_dispersivity=longitudinal_dispersivity,
864 retardation_factor=retardation_factor,
865 extend_tedges=extend_tedges,
866 )
868 cout = coeff_matrix @ cin
870 # Output bins are invalid where the coefficient sum is near zero (no cin has broken
871 # through yet) or the bin extends beyond the input data range (valid_cout_bins).
872 total_coeff = np.sum(coeff_matrix, axis=1)
873 no_valid_contribution = (total_coeff < _EPSILON_COEFF_SUM) | ~valid_cout_bins
874 cout[no_valid_contribution] = np.nan
876 return cout
879def extraction_to_infiltration(
880 *,
881 cout: npt.ArrayLike,
882 flow: npt.ArrayLike,
883 tedges: pd.DatetimeIndex,
884 cout_tedges: pd.DatetimeIndex,
885 aquifer_pore_volumes: npt.ArrayLike,
886 streamline_length: npt.ArrayLike,
887 molecular_diffusivity: npt.ArrayLike,
888 longitudinal_dispersivity: npt.ArrayLike,
889 retardation_factor: float = 1.0,
890 regularization_strength: float = 1e-10,
891 spinup: str | None = "constant",
892) -> npt.NDArray[np.floating]:
893 """
894 Compute infiltration concentration from extracted water (deconvolution with dispersion).
896 Inverts the forward transport model by building the forward coefficient
897 matrix ``W_forward`` from :func:`infiltration_to_extraction` and solving
898 ``W_forward @ cin = cout`` via Tikhonov regularization. Well-determined
899 modes are dominated by the data; poorly-determined modes are pulled
900 toward the physically motivated target (transpose-and-normalize of the
901 forward matrix).
903 Parameters
904 ----------
905 cout : array-like
906 Concentration of the compound in extracted water [concentration units].
907 Length must match the number of time bins defined by cout_tedges.
908 flow : array-like
909 Flow rate of water in the aquifer [m³/day].
910 Length must match the number of time bins defined by tedges.
911 tedges : pandas.DatetimeIndex
912 Time edges defining bins for cin (output) and flow data.
913 Has length of len(flow) + 1. Output cin has length len(tedges) - 1.
914 cout_tedges : pandas.DatetimeIndex
915 Time edges for cout data bins. Has length of len(cout) + 1.
916 Can have different time alignment and resolution than tedges.
917 aquifer_pore_volumes : array-like
918 Array of aquifer pore volumes [m³] representing the distribution
919 of flow paths. Each pore volume determines the residence time for
920 that flow path: tau = V_pore / Q.
921 streamline_length : array-like
922 Array of travel distances [m] corresponding to each pore volume.
923 Must have the same length as aquifer_pore_volumes.
924 molecular_diffusivity : float or array-like
925 Effective molecular diffusivity [m²/day]. Can be a scalar (same for all
926 pore volumes) or an array with the same length as aquifer_pore_volumes.
927 Must be non-negative. See :func:`infiltration_to_extraction` for
928 details on the physical interpretation and the interaction with
929 retardation_factor.
930 longitudinal_dispersivity : float or array-like
931 Longitudinal dispersivity [m]. Can be a scalar (same for all pore
932 volumes) or an array with the same length as aquifer_pore_volumes.
933 Must be non-negative.
934 retardation_factor : float, optional
935 Retardation factor of the compound in the aquifer (default 1.0).
936 Values > 1.0 indicate slower transport due to sorption.
937 regularization_strength : float, optional
938 Tikhonov regularization parameter λ. See
939 :func:`gwtransport.advection.extraction_to_infiltration` for details.
940 Default is 1e-10.
941 spinup : {'constant'} or None, optional
942 Spin-up policy (default ``'constant'``). ``'constant'`` extends tedges by
943 100 years on each side so that output bins near the boundary are always
944 informed. ``None`` disables the extension; output bins without sufficient
945 upstream data become NaN. Float fraction-threshold mode is not implemented
946 and raises ``NotImplementedError``.
948 Returns
949 -------
950 numpy.ndarray
951 Bin-averaged concentration in the infiltrating water. Same units as cout.
952 Length equals len(tedges) - 1. NaN values indicate time periods
953 with no valid contributions from the extraction data.
955 Raises
956 ------
957 ValueError
958 If input dimensions are inconsistent, if diffusivity is negative,
959 or if aquifer_pore_volumes and streamline_length have different lengths.
961 See Also
962 --------
963 infiltration_to_extraction : Forward operation (convolution)
964 gwtransport.advection.extraction_to_infiltration : Pure advection (no dispersion)
965 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion
967 Notes
968 -----
969 The algorithm builds the forward coefficient matrix ``W_forward`` (same as
970 used by :func:`infiltration_to_extraction`) and solves ``W_forward @ cin = cout``
971 using :func:`gwtransport.utils.solve_inverse_transport` (which builds the
972 reverse regularization target and then applies Tikhonov regularization).
973 This ensures mathematical consistency between forward and inverse operations.
975 NaN values in ``cout`` mark measurement gaps (e.g. sparse lab samples).
976 Their rows are excluded from the Tikhonov solve, matching
977 :func:`gwtransport.deposition.extraction_to_deposition`; cin bins
978 constrained only by gapped ``cout`` bins are returned as NaN.
980 Examples
981 --------
982 Basic usage with constant flow:
984 >>> import pandas as pd
985 >>> import numpy as np
986 >>> from gwtransport.diffusion import extraction_to_infiltration
987 >>>
988 >>> # Create time edges: tedges for cin/flow, cout_tedges for cout
989 >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
990 >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
991 >>>
992 >>> # Extracted concentration and constant flow
993 >>> cout = np.zeros(len(cout_tedges) - 1)
994 >>> cout[5:10] = 1.0 # Observed pulse at extraction
995 >>> flow = np.ones(len(tedges) - 1) * 100.0 # 100 m³/day
996 >>>
997 >>> # Single pore volume of 500 m³, travel distance 100 m
998 >>> aquifer_pore_volumes = np.array([500.0])
999 >>> streamline_length = np.array([100.0])
1000 >>>
1001 >>> # Reconstruct infiltration concentration
1002 >>> cin = extraction_to_infiltration(
1003 ... cout=cout,
1004 ... flow=flow,
1005 ... tedges=tedges,
1006 ... cout_tedges=cout_tedges,
1007 ... aquifer_pore_volumes=aquifer_pore_volumes,
1008 ... streamline_length=streamline_length,
1009 ... molecular_diffusivity=1e-4,
1010 ... longitudinal_dispersivity=1.0,
1011 ... )
1012 """
1013 tedges = pd.DatetimeIndex(tedges)
1014 cout_tedges = pd.DatetimeIndex(cout_tedges)
1016 cout = np.asarray(cout, dtype=float)
1017 flow, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity = (
1018 _prepare_diffusion_arrays(
1019 flow=flow,
1020 aquifer_pore_volumes=aquifer_pore_volumes,
1021 streamline_length=streamline_length,
1022 molecular_diffusivity=molecular_diffusivity,
1023 longitudinal_dispersivity=longitudinal_dispersivity,
1024 )
1025 )
1027 _validate_diffusion_inputs(
1028 tedges=tedges,
1029 flow=flow,
1030 aquifer_pore_volumes=aquifer_pore_volumes,
1031 streamline_length=streamline_length,
1032 molecular_diffusivity=molecular_diffusivity,
1033 longitudinal_dispersivity=longitudinal_dispersivity,
1034 retardation_factor=retardation_factor,
1035 cout_values=cout,
1036 cout_tedges=cout_tedges,
1037 )
1039 n_cin = len(tedges) - 1
1041 # Build forward weight matrix: W_forward @ cin = cout
1042 extend_tedges = _extend_tedges_flag(spinup)
1043 w_forward, valid_cout_bins = _infiltration_to_extraction_coeff_matrix(
1044 flow=flow,
1045 tedges=tedges,
1046 cout_tedges=cout_tedges,
1047 aquifer_pore_volumes=aquifer_pore_volumes,
1048 streamline_length=streamline_length,
1049 molecular_diffusivity=molecular_diffusivity,
1050 longitudinal_dispersivity=longitudinal_dispersivity,
1051 retardation_factor=retardation_factor,
1052 extend_tedges=extend_tedges,
1053 )
1055 return solve_inverse_transport(
1056 w_forward=w_forward,
1057 observed=cout,
1058 n_output=n_cin,
1059 regularization_strength=regularization_strength,
1060 valid_rows=valid_cout_bins,
1061 )
1064def gamma_infiltration_to_extraction(
1065 *,
1066 cin: npt.ArrayLike,
1067 flow: npt.ArrayLike,
1068 tedges: pd.DatetimeIndex,
1069 cout_tedges: pd.DatetimeIndex,
1070 mean: float | None = None,
1071 std: float | None = None,
1072 loc: float = 0.0,
1073 alpha: float | None = None,
1074 beta: float | None = None,
1075 n_bins: int = 100,
1076 streamline_length: float,
1077 molecular_diffusivity: float,
1078 longitudinal_dispersivity: float,
1079 retardation_factor: float = 1.0,
1080 spinup: str | None = "constant",
1081) -> npt.NDArray[np.floating]:
1082 """
1083 Compute extracted concentration with advection and dispersion for gamma-distributed pore volumes.
1085 Combines advection with microdispersion and molecular diffusion along each streamline
1086 (gamma-distributed pore volumes, whose spread provides macrodispersion). This is a
1087 convenience wrapper around :func:`infiltration_to_extraction` that parameterizes
1088 the aquifer pore volume distribution as a (shifted) gamma distribution.
1090 Provide either (mean, std) or (alpha, beta); ``loc`` is optional and defaults to 0.
1092 Parameters
1093 ----------
1094 cin : array-like
1095 Concentration of the compound in infiltrating water.
1096 flow : array-like
1097 Flow rate of water in the aquifer [m³/day].
1098 tedges : pandas.DatetimeIndex
1099 Time edges for cin and flow data. Has length len(cin) + 1.
1100 cout_tedges : pandas.DatetimeIndex
1101 Time edges for output data bins. Has length of desired output + 1.
1102 mean : float, optional
1103 Mean of the gamma distribution of the aquifer pore volume. Must be strictly
1104 greater than ``loc``.
1105 std : float, optional
1106 Standard deviation of the gamma distribution of the aquifer pore volume
1107 (invariant under the ``loc`` shift).
1108 loc : float, optional
1109 Location (minimum pore volume) of the gamma distribution. Must satisfy
1110 ``0 <= loc < mean``. Default is ``0.0``.
1111 alpha : float, optional
1112 Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).
1113 beta : float, optional
1114 Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).
1115 n_bins : int, optional
1116 Number of bins to discretize the gamma distribution. Default is 100.
1117 streamline_length : float
1118 Travel distance through the aquifer [m]. Applied uniformly to all
1119 gamma-discretized pore volumes.
1120 molecular_diffusivity : float
1121 Effective molecular diffusivity [m²/day]. Must be non-negative.
1122 See :func:`infiltration_to_extraction` for details on the interaction
1123 with retardation_factor.
1124 longitudinal_dispersivity : float
1125 Longitudinal dispersivity [m]. Must be non-negative.
1126 retardation_factor : float, optional
1127 Retardation factor (default 1.0). Values > 1.0 indicate slower transport.
1128 spinup : {'constant'} or None, optional
1129 Spin-up policy (default ``'constant'``). ``'constant'`` extends tedges by
1130 100 years on each side so that output bins near the boundary are always
1131 informed. ``None`` disables the extension; output bins without sufficient
1132 upstream data become NaN. Float fraction-threshold mode is not implemented
1133 and raises ``NotImplementedError``.
1135 Returns
1136 -------
1137 numpy.ndarray
1138 Bin-averaged concentration in the extracted water. Length equals
1139 len(cout_tedges) - 1. NaN values indicate time periods with no valid
1140 contributions from the infiltration data.
1142 See Also
1143 --------
1144 infiltration_to_extraction : Transport with explicit pore volume distribution
1145 gamma_extraction_to_infiltration : Reverse operation (deconvolution)
1146 gwtransport.gamma.bins : Create gamma distribution bins
1147 gwtransport.advection.gamma_infiltration_to_extraction : Pure advection (no dispersion)
1148 :ref:`concept-gamma-distribution` : Two-parameter pore volume model
1149 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion
1151 Notes
1152 -----
1153 The APVD is only time-invariant under the steady-streamlines assumption
1154 (see :ref:`assumption-steady-streamlines`).
1156 The spreading from the gamma-distributed pore volumes represents macrodispersion
1157 (aquifer-scale heterogeneity). When ``std`` comes from calibration on measurements,
1158 it absorbs all mixing: macrodispersion, microdispersion, and an average molecular
1159 diffusion contribution. When ``std`` comes from streamline analysis, it represents
1160 macrodispersion only; microdispersion and molecular diffusion can be added via the
1161 dispersion parameters.
1162 See :ref:`concept-dispersion-scales` for guidance on when to add microdispersion.
1164 Examples
1165 --------
1166 >>> import pandas as pd
1167 >>> import numpy as np
1168 >>> from gwtransport.diffusion import gamma_infiltration_to_extraction
1169 >>>
1170 >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
1171 >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
1172 >>> cin = np.zeros(len(tedges) - 1)
1173 >>> cin[5:10] = 1.0
1174 >>> flow = np.ones(len(tedges) - 1) * 100.0
1175 >>>
1176 >>> cout = gamma_infiltration_to_extraction(
1177 ... cin=cin,
1178 ... flow=flow,
1179 ... tedges=tedges,
1180 ... cout_tedges=cout_tedges,
1181 ... mean=500.0,
1182 ... std=100.0,
1183 ... n_bins=5,
1184 ... streamline_length=100.0,
1185 ... molecular_diffusivity=1e-4,
1186 ... longitudinal_dispersivity=1.0,
1187 ... )
1188 """
1189 bins = gamma.bins(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta, n_bins=n_bins)
1190 return infiltration_to_extraction(
1191 cin=cin,
1192 flow=flow,
1193 tedges=tedges,
1194 cout_tedges=cout_tedges,
1195 aquifer_pore_volumes=bins["expected_values"],
1196 streamline_length=streamline_length,
1197 molecular_diffusivity=molecular_diffusivity,
1198 longitudinal_dispersivity=longitudinal_dispersivity,
1199 retardation_factor=retardation_factor,
1200 spinup=spinup,
1201 )
1204def gamma_extraction_to_infiltration(
1205 *,
1206 cout: npt.ArrayLike,
1207 flow: npt.ArrayLike,
1208 tedges: pd.DatetimeIndex,
1209 cout_tedges: pd.DatetimeIndex,
1210 mean: float | None = None,
1211 std: float | None = None,
1212 loc: float = 0.0,
1213 alpha: float | None = None,
1214 beta: float | None = None,
1215 n_bins: int = 100,
1216 streamline_length: float,
1217 molecular_diffusivity: float,
1218 longitudinal_dispersivity: float,
1219 retardation_factor: float = 1.0,
1220 regularization_strength: float = 1e-10,
1221 spinup: str | None = "constant",
1222) -> npt.NDArray[np.floating]:
1223 """
1224 Compute infiltration concentration from extracted water for gamma-distributed pore volumes.
1226 Inverts the forward transport model (advection + dispersion with gamma-distributed
1227 pore volumes) via Tikhonov regularization. This is a convenience wrapper around
1228 :func:`extraction_to_infiltration` that parameterizes the aquifer pore volume
1229 distribution as a (shifted) gamma distribution.
1231 Provide either (mean, std) or (alpha, beta); ``loc`` is optional and defaults to 0.
1233 Parameters
1234 ----------
1235 cout : array-like
1236 Concentration of the compound in extracted water.
1237 flow : array-like
1238 Flow rate of water in the aquifer [m³/day].
1239 tedges : pandas.DatetimeIndex
1240 Time edges for cin (output) and flow data. Has length of len(flow) + 1.
1241 cout_tedges : pandas.DatetimeIndex
1242 Time edges for cout data bins. Has length of len(cout) + 1.
1243 mean : float, optional
1244 Mean of the gamma distribution of the aquifer pore volume. Must be strictly
1245 greater than ``loc``.
1246 std : float, optional
1247 Standard deviation of the gamma distribution of the aquifer pore volume
1248 (invariant under the ``loc`` shift).
1249 loc : float, optional
1250 Location (minimum pore volume) of the gamma distribution. Must satisfy
1251 ``0 <= loc < mean``. Default is ``0.0``.
1252 alpha : float, optional
1253 Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).
1254 beta : float, optional
1255 Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).
1256 n_bins : int, optional
1257 Number of bins to discretize the gamma distribution. Default is 100.
1258 streamline_length : float
1259 Travel distance through the aquifer [m]. Applied uniformly to all
1260 gamma-discretized pore volumes.
1261 molecular_diffusivity : float
1262 Effective molecular diffusivity [m²/day]. Must be non-negative.
1263 See :func:`infiltration_to_extraction` for details on the interaction
1264 with retardation_factor.
1265 longitudinal_dispersivity : float
1266 Longitudinal dispersivity [m]. Must be non-negative.
1267 retardation_factor : float, optional
1268 Retardation factor (default 1.0). Values > 1.0 indicate slower transport.
1269 regularization_strength : float, optional
1270 Tikhonov regularization parameter. Default is 1e-10.
1271 spinup : {'constant'} or None, optional
1272 Spin-up policy (default ``'constant'``). ``'constant'`` extends tedges by
1273 100 years on each side so that output bins near the boundary are always
1274 informed. ``None`` disables the extension; output bins without sufficient
1275 upstream data become NaN. Float fraction-threshold mode is not implemented
1276 and raises ``NotImplementedError``.
1278 Returns
1279 -------
1280 numpy.ndarray
1281 Bin-averaged concentration in the infiltrating water. Length equals
1282 len(tedges) - 1. NaN values indicate time periods with no valid
1283 contributions from the extraction data.
1285 See Also
1286 --------
1287 extraction_to_infiltration : Deconvolution with explicit pore volume distribution
1288 gamma_infiltration_to_extraction : Forward operation (convolution)
1289 gwtransport.gamma.bins : Create gamma distribution bins
1290 gwtransport.advection.gamma_extraction_to_infiltration : Pure advection (no dispersion)
1291 :ref:`concept-gamma-distribution` : Two-parameter pore volume model
1292 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion
1294 Notes
1295 -----
1296 The APVD is only time-invariant under the steady-streamlines assumption
1297 (see :ref:`assumption-steady-streamlines`).
1299 The spreading from the gamma-distributed pore volumes represents macrodispersion
1300 (aquifer-scale heterogeneity). When ``std`` comes from calibration on measurements,
1301 it absorbs all mixing: macrodispersion, microdispersion, and an average molecular
1302 diffusion contribution. When ``std`` comes from streamline analysis, it represents
1303 macrodispersion only; microdispersion and molecular diffusion can be added via the
1304 dispersion parameters.
1305 See :ref:`concept-dispersion-scales` for guidance on when to add microdispersion.
1307 Examples
1308 --------
1309 >>> import pandas as pd
1310 >>> import numpy as np
1311 >>> from gwtransport.diffusion import gamma_extraction_to_infiltration
1312 >>>
1313 >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
1314 >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
1315 >>> cout = np.zeros(len(cout_tedges) - 1)
1316 >>> cout[5:10] = 1.0
1317 >>> flow = np.ones(len(tedges) - 1) * 100.0
1318 >>>
1319 >>> cin = gamma_extraction_to_infiltration(
1320 ... cout=cout,
1321 ... flow=flow,
1322 ... tedges=tedges,
1323 ... cout_tedges=cout_tedges,
1324 ... mean=500.0,
1325 ... std=100.0,
1326 ... n_bins=5,
1327 ... streamline_length=100.0,
1328 ... molecular_diffusivity=1e-4,
1329 ... longitudinal_dispersivity=1.0,
1330 ... )
1331 """
1332 bins = gamma.bins(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta, n_bins=n_bins)
1333 return extraction_to_infiltration(
1334 cout=cout,
1335 flow=flow,
1336 tedges=tedges,
1337 cout_tedges=cout_tedges,
1338 aquifer_pore_volumes=bins["expected_values"],
1339 streamline_length=streamline_length,
1340 molecular_diffusivity=molecular_diffusivity,
1341 longitudinal_dispersivity=longitudinal_dispersivity,
1342 retardation_factor=retardation_factor,
1343 regularization_strength=regularization_strength,
1344 spinup=spinup,
1345 )