Coverage for src/gwtransport/diffusion_fast_fast.py: 100%
178 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
1"""
2Fast *approximate* 1D advection-dispersion transport (Kreft-Zuber flux concentration).
4This module shares the conceptual model of :mod:`gwtransport.diffusion` and
5:mod:`gwtransport.diffusion_fast` -- advection with microdispersion (``alpha_L``) and molecular
6diffusion (``D_m``) along orthogonal (Cartesian) flow paths, one independent streamtube per aquifer
7pore volume, the spread across the pore volume distribution providing macrodispersion, and linear
8sorption via the retardation factor. It
9targets the bin-averaged Kreft-Zuber (1978) flux concentration ``C_F`` on the streamtube bundle, but
10trades exactness for a single fast (~1.5 ms) native-grid evaluation that does not depend on the flow
11being constant.
12It is **approximate**: where :mod:`gwtransport.diffusion_fast` reproduces the quadrature reference to
13machine precision, this module is accurate to ~3e-4 in the common regime and degrades in a
14documented corner (below). When you need machine precision, use :mod:`gwtransport.diffusion_fast`.
16How it works -- an operator split in two coordinates
17----------------------------------------------------
19The moving-frame dispersion product ``D_t = D_m*tau + alpha_L*xi`` mixes a *time* term (molecular
20diffusion ``D_m*tau``) and a *volume* term (microdispersion ``alpha_L*xi``). The two are split into
21the coordinate each is stationary in, so the dominant part is built once and is flow-independent:
231. **Advection + macrodispersion + microdispersion** are the *exact* skewed ``D_m=0`` Kreft-Zuber
24 breakthrough, applied banded on the **native cumulative-volume grid**. The whole aquifer pore
25 volume distribution (APVD) is pre-summed into a single 1D antiderivative ``Ibar(dV)`` -- exact
26 for any APVD shape -- finely sampled once and read back by interpolation. This part is
27 volume-stationary, hence **flow-independent** (constant and strongly variable flow alike).
282. **Molecular diffusion** is a symmetric **time-domain Gaussian** applied to the outlet signal
29 (variance ``2*D_m*tau_bt*(R*Vbar/L)^2/Q^2``). This is the only modelling approximation: the true
30 Kreft-Zuber molecular breakthrough is skewed, and at realistic (sub-bin) spreading the Gaussian
31 is nearly a no-op, so the molecular term is dropped rather than skewed.
33``tedges`` need **not** be regularly spaced and ``cout_tedges`` need not equal ``tedges`` (supply
34``flow_out`` when they differ): step 1 runs on the native cumulative-volume grid for any spacing.
35Only the molecular Gaussian assumes a roughly regular grid -- it convolves in bin-index space using
36the mean bin width -- so a strongly irregular grid adds a small extra error to the (usually
37sub-dominant) molecular term; use :mod:`gwtransport.diffusion_fast` for the molecular-dominated +
38irregular-grid corner.
40Accuracy (vs :mod:`gwtransport.diffusion_fast`, flow-independent unless noted)
41------------------------------------------------------------------------------
43- **~3e-4 whenever microdispersion is present** (``alpha_L > 0`` -- the typical groundwater
44 regime, Peclet number >> 1), constant *and* variable flow, for realistic solute diffusivities
45 (``D_m`` ~ 1e-4) or ``R = 1``. Here molecular diffusion is sub-dominant, so approximating it
46 barely matters. This survives retardation for a typical APVD (measured <~1e-3 up to ``R = 3``).
47- In the **molecular-diffusion-dominated** corner (``alpha_L`` ~ 0): ~1e-4 for smooth inputs, but
48 degrading to ~1e-2 for sharp inputs (and ~5e-2 for sharp inputs with a very wide / bimodal APVD or
49 a large single pore volume), because the symmetric time-Gaussian cannot reproduce the skewed
50 molecular breakthrough. **Retardation enlarges this corner:** the Gaussian's variance scales as
51 ``sigma_t^2 ~ D_m * R^3``, so ``R > 1`` reaches the ~1e-2 looseness at a smaller ``D_m`` -- a
52 sharp input at ``D_m = 0.01`` degrades from ~8e-4 at ``R = 1`` to ~1.7e-2 at ``R = 2`` and ~3.5e-2
53 at ``R = 3``. **Use** :mod:`gwtransport.diffusion_fast` **for exact results in this regime** --
54 in particular for heat transport (``R > 1`` with a large ``D_m``).
56The inverse (:func:`extraction_to_infiltration`) deconvolves the *same* approximate operator the
57forward applies. It assembles ``W = G . M`` directly in banded form (one ``Ibar`` gather plus a
58sparse ``G . M`` product -- no per-pore-volume closed-form loop, no dense ``(n_cout, n_cin)`` matrix)
59and solves it with banded Tikhonov regularisation (banded Cholesky, ``O(n * band**2)``), so it is
60much faster than :mod:`gwtransport.diffusion_fast`'s reverse, especially for many streamtubes.
61Inverting exactly the forward operator makes a round trip self-consistent: it recovers the input up
62to the deconvolution conditioning, with the only error being the forward operator's approximation of
63:mod:`gwtransport.diffusion_fast` (use that module when the approximation is unacceptable).
65Available functions:
67- :func:`infiltration_to_extraction` -- forward transport (approximate).
68- :func:`extraction_to_infiltration` -- inverse via banded Tikhonov regularisation (approximate).
69- :func:`gamma_infiltration_to_extraction` -- gamma-distributed APVD (forward).
70- :func:`gamma_extraction_to_infiltration` -- same, inverse.
72References
73----------
74Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its
75solutions for different initial and boundary conditions. Chemical Engineering Science,
7633(11), 1471-1480.
78This file is part of gwtransport which is released under AGPL-3.0 license.
79See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details.
80"""
82import numpy as np
83import numpy.typing as npt
84import pandas as pd
85from scipy.ndimage import gaussian_filter1d
86from scipy.sparse import coo_array
88from gwtransport import gamma
89from gwtransport._diffusion_shared import (
90 _DT_FLOOR,
91 _EPSILON_COEFF_SUM,
92 _breakthrough_antideriv,
93 _broadcast_to_pore_volumes,
94 _cout_cumulative_volume,
95 _extend_tedges_flag,
96 _solve_reverse_banded,
97 _validate_inputs,
98)
99from gwtransport._time import dt_to_days, tedges_to_days
100from gwtransport.diffusion_fast import _DEFAULT_SATURATION_THRESHOLD
101from gwtransport.residence_time import fraction_explained_full
102from gwtransport.utils import cumulative_flow_volume
104# Samples per native bin used to discretise the 1D breakthrough antiderivative ``Ibar``. Higher =
105# more accurate ``Ibar`` interpolation (advection+micro error ~ O(1/_KERNEL_FINE^2)) at the cost of
106# a larger one-time precompute; 16 gives ~1e-4 in the advection+micro part, which is below the
107# molecular-Gaussian floor, so it is not exposed as a user knob.
108_KERNEL_FINE = 16
110# Upper bound on the number of fine ``Ibar`` samples. Caps the one-time precompute when the
111# breakthrough band spans far more than the record (e.g. tiny flow -> enormous front offset); the
112# affected output bins are masked by residence time anyway, so coarsening the band there is benign.
113_MAX_KERNEL_SAMPLES = 20000
116def _summed_antideriv(
117 *,
118 aquifer_pore_volumes: npt.NDArray[np.floating],
119 streamline_length: npt.NDArray[np.floating],
120 longitudinal_dispersivity: npt.NDArray[np.floating],
121 retardation_factor: float,
122 mean_bin_volume: float,
123 saturation_threshold: float,
124) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating], float, float, float]:
125 r"""Precompute the APVD-summed breakthrough antiderivative ``Ibar(dV)`` on a fine 1D grid.
127 For one streamtube the bin-averaged ``D_m=0`` flux fraction over a cout bin equals the second
128 difference of the antiderivative ``I(x)`` (:func:`gwtransport._diffusion_shared._breakthrough_antideriv`)
129 of the resident concentration in the breakthrough coordinate ``x = (dV - r_vpv)*L/r_vpv``
130 (``r_vpv = R*V_pore``). The antiderivative *with respect to cumulative volume* ``dV`` is
131 ``(r_vpv/L)*I(x(dV))``; averaging it over the APVD gives a single 1D function
133 .. math::
135 \bar I(\Delta V) = \operatorname{mean}_{pv}\Bigl[\tfrac{r_{vpv}}{L}\,
136 I\bigl((\Delta V - r_{vpv})L/r_{vpv}\bigr)\Bigr],\quad D_t = \alpha_L\,\xi,
138 whose edge-differences reproduce the per-streamtube-averaged ``C_F`` **exactly** for any APVD
139 (the ``r_vpv/L`` Jacobian and the cout-bin volume normalisation cancel the per-streamtube ``dx``).
140 Only the interpolation of ``Ibar`` is approximate.
142 The grid is **uniform** over the breakthrough band ``[off_lo, off_hi]`` (front ``r_vpv`` plus the
143 conservative ``alpha_L`` dispersion smear, unioned over streamtubes) plus a margin, sampled at
144 ``mean_bin_volume / _KERNEL_FINE`` (uniformity lets :func:`_eval_antideriv` interpolate by
145 fractional indexing instead of a per-point search). For ``alpha_L > 0`` ``Ibar`` is smooth and
146 the interpolation error is ``O(1/_KERNEL_FINE^2)``; at ``alpha_L = 0`` it has a kink at each
147 ``r_vpv`` and the error is ``O(1/_KERNEL_FINE)`` (sub-1e-3, well below the molecular floor).
149 Returns
150 -------
151 grid : ndarray
152 Cumulative-volume offsets ``dV`` (uniformly spaced, strictly increasing).
153 ibar : ndarray
154 ``Ibar`` sampled at ``grid``.
155 mean_r_vpv : float
156 ``R * mean(V_pore)`` -- the saturated offset (``Ibar -> dV - mean_r_vpv`` above the band).
157 off_lo, off_hi : float
158 Lower / upper cumulative-volume offset of the breakthrough band.
159 """
160 r_vpv = retardation_factor * aquifer_pore_volumes
161 mean_r_vpv = float(r_vpv.mean())
162 u = saturation_threshold
163 # D_m=0 dispersion half-widths in the breakthrough coordinate x (front D_t = alpha_L*L): the
164 # pre side shrinks (|x| = U*2*sqrt(alpha_L*L)); the post side grows with slope alpha_L, giving
165 # the quadratic root x = 2U^2*alpha_L + 2U*sqrt(U^2*alpha_L^2 + alpha_L*L). Mapped to volume
166 # offsets via r_vpv/L and unioned over streamtubes (conservative, never under-covers the band).
167 pre_x = u * 2.0 * np.sqrt(longitudinal_dispersivity * streamline_length)
168 post_x = 2.0 * u * u * longitudinal_dispersivity + 2.0 * u * np.sqrt(
169 u * u * longitudinal_dispersivity**2 + longitudinal_dispersivity * streamline_length
170 )
171 off_lo = float(np.min(r_vpv - (r_vpv / streamline_length) * pre_x))
172 off_hi = float(np.max(r_vpv + (r_vpv / streamline_length) * post_x))
174 margin = 4.0 * mean_bin_volume
175 span = (off_hi - off_lo) + 2.0 * margin
176 dv_fine = mean_bin_volume / _KERNEL_FINE
177 if span / dv_fine > _MAX_KERNEL_SAMPLES:
178 dv_fine = span / _MAX_KERNEL_SAMPLES
179 grid = np.arange(off_lo - margin, off_hi + margin + dv_fine, dv_fine)
181 x = (grid[None, :] - r_vpv[:, None]) * streamline_length[:, None] / r_vpv[:, None]
182 dt_var = np.maximum(longitudinal_dispersivity[:, None] * np.maximum(x + streamline_length[:, None], 0.0), _DT_FLOOR)
183 antideriv = _breakthrough_antideriv(x, dt_var)
184 ibar = ((r_vpv[:, None] / streamline_length[:, None]) * antideriv).mean(axis=0)
185 return grid, ibar, mean_r_vpv, off_lo, off_hi
188def _eval_antideriv(
189 dv: npt.NDArray[np.floating],
190 grid: npt.NDArray[np.floating],
191 ibar: npt.NDArray[np.floating],
192 mean_r_vpv: float,
193) -> npt.NDArray[np.floating]:
194 """Evaluate ``Ibar`` at arbitrary cumulative-volume offsets.
196 Linear interpolation on the *uniform* precomputed grid by fractional indexing (no per-point
197 search -- the gather is evaluated at ``O(N*band)`` points, so this dominates the runtime).
198 Below the grid ``Ibar = 0`` (not broken through); above it ``Ibar = dV - mean_r_vpv`` (saturated,
199 the exact linear asymptote).
201 Returns
202 -------
203 ndarray
204 ``Ibar(dv)`` with the same shape as ``dv``.
205 """
206 g0 = grid[0]
207 dstep = grid[1] - grid[0]
208 f = (dv - g0) / dstep
209 # astype truncates toward zero; equals floor on f >= 0 (the only regime that survives the
210 # dv < g0 override below and the lower clip), so the floor call is redundant. Precompute the
211 # segment slopes once so the O(N*band) gather reads a single array instead of differencing two.
212 slopes = np.diff(ibar)
213 i0 = np.clip(f.astype(np.intp), 0, grid.size - 2)
214 out = ibar[i0] + (f - i0) * slopes[i0]
215 out = np.where(dv < g0, 0.0, out)
216 return np.where(dv > grid[-1], dv - mean_r_vpv, out)
219def _advection_micro_band(
220 *,
221 cumulative_volume_at_cin: npt.NDArray[np.floating],
222 cumulative_volume_at_cout: npt.NDArray[np.floating],
223 grid: npt.NDArray[np.floating],
224 ibar: npt.NDArray[np.floating],
225 mean_r_vpv: float,
226 off_lo: float,
227 off_hi: float,
228 extend: bool,
229) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.intp]]:
230 """Banded ``D_m=0`` advection+macro+micro coefficients on the native cumulative-volume grid.
232 For each cout bin, gathers the band of cin edges whose breakthrough offset falls in
233 ``[off_lo, off_hi]`` (a conservative fixed window from ``searchsorted``, mirroring
234 :func:`gwtransport.diffusion_fast._closed_form_coeff_matrix`), evaluates ``Ibar`` at the native
235 edge offsets, and telescopes the edge-differences into per-cin-bin coefficients. With
236 ``extend`` the cin axis is extended by one wide virtual bin each side carrying the constant
237 boundary value (``cin[0]`` / ``cin[-1]``), reproducing the 100-year warm-start.
239 The coefficients depend only on the volume grid (not on ``cin``), so this band is built once and
240 shared: :func:`infiltration_to_extraction` applies it to the (extended) ``cin``, and
241 :func:`extraction_to_infiltration` folds it onto the real cin axis to assemble the banded
242 operator it deconvolves -- guaranteeing forward and reverse use the same operator.
244 Returns
245 -------
246 coeff : ndarray, shape (n_cout, band)
247 Per-(cout bin, band slot) coefficient.
248 cin_bin : ndarray of int, shape (n_cout, band)
249 Column index of each coefficient on the (warm-start-extended when ``extend``) cin axis:
250 ``cin_ext[cin_bin]`` for the forward, ``clip(cin_bin - int(extend), 0, n_cin - 1)`` for the
251 real cin axis in the reverse.
252 """
253 vi = cumulative_volume_at_cin
254 vc = cumulative_volume_at_cout
255 if extend:
256 big = abs(off_hi) + abs(off_lo) + (vc[-1] - vc[0]) + (vi[-1] - vi[0]) + 1.0
257 vi_ext = np.concatenate([[vi[0] - big], vi, [vi[-1] + big]])
258 n_cin_ext = vi.size + 1 # (vi.size - 1) real bins + 2 virtual boundary bins
259 else:
260 vi_ext = vi
261 n_cin_ext = vi.size - 1
263 vc_lo, vc_hi = vc[:-1], vc[1:]
264 w = vc_hi - vc_lo
265 vc_mid = 0.5 * (vc_lo + vc_hi)
266 # Conservative two-sided fixed window: center on the front, widen to the farthest cin edge whose
267 # offset is still inside the band on either side. The +1 absorbs the edge consumed by the
268 # telescoping difference and searchsorted rounding (an under-sized window silently drops mass).
269 center = np.searchsorted(vi_ext, vc_mid - mean_r_vpv)
270 j_lo = np.searchsorted(vi_ext, vc_lo - off_hi, side="left")
271 j_hi = np.searchsorted(vi_ext, vc_hi - off_lo, side="right")
272 hw_lo = int(np.max(center - j_lo)) + 1
273 hw_hi = int(np.max(j_hi - center)) + 1
275 cols = center[:, None] + np.arange(-hw_lo, hw_hi + 1)[None, :]
276 vi_band = vi_ext[np.clip(cols, 0, len(vi_ext) - 1)]
277 ibar_hi = _eval_antideriv(vc_hi[:, None] - vi_band, grid, ibar, mean_r_vpv)
278 ibar_lo = _eval_antideriv(vc_lo[:, None] - vi_band, grid, ibar, mean_r_vpv)
279 with np.errstate(divide="ignore", invalid="ignore"):
280 frac_edge = np.where(w[:, None] > 0.0, (ibar_hi - ibar_lo) / w[:, None], 0.0)
281 coeff = frac_edge[:, :-1] - frac_edge[:, 1:]
282 cin_bin = np.clip(cols[:, :-1], 0, n_cin_ext - 1)
283 return coeff, cin_bin
286def _build_forward_operator(
287 *,
288 flow: npt.NDArray[np.floating],
289 tedges: pd.DatetimeIndex,
290 cout_tedges: pd.DatetimeIndex,
291 flow_out: npt.NDArray[np.floating] | None,
292 aquifer_pore_volumes: npt.NDArray[np.floating],
293 streamline_length: npt.NDArray[np.floating],
294 molecular_diffusivity: npt.NDArray[np.floating],
295 longitudinal_dispersivity: npt.NDArray[np.floating],
296 retardation_factor: float,
297 extend: bool,
298 saturation_threshold: float,
299) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.intp], float, npt.NDArray[np.bool_]] | None:
300 r"""Build the cin-independent pieces of the approximate banded forward operator ``W = G . M``.
302 Both directions share this build: the advection+macro+micro band ``M`` (``coeff`` / ``cin_bin``,
303 :func:`_advection_micro_band`), the molecular time-Gaussian width ``sigma_bins`` (the operator
304 ``G``), and the residence-time ``valid`` mask. Because the band depends only on the volume grid
305 (not on ``cin`` / ``cout``), forward transport and reverse deconvolution operate on exactly the
306 same operator.
308 Returns
309 -------
310 coeff : ndarray, shape (n_cout, band)
311 Banded ``M`` coefficients.
312 cin_bin : ndarray of int, shape (n_cout, band)
313 Column indices of ``coeff`` on the warm-start-extended cin axis.
314 sigma_bins : float
315 Molecular Gaussian width in output-bin units (0 -> ``G`` is the identity).
316 valid : ndarray of bool, shape (n_cout,)
317 Output bins with residence time finite at both edges (complete breakthrough).
319 None
320 Returned instead of the tuple when there is no through-flow (nothing breaks through).
321 """
322 tedges_days = tedges_to_days(tedges)
323 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0])
324 cumulative_volume_at_cin = cumulative_flow_volume(flow, dt_to_days(tedges))
325 total_volume = float(cumulative_volume_at_cin[-1])
326 if total_volume <= 0.0:
327 return None
329 cumulative_volume_at_cout = _cout_cumulative_volume(
330 flow_out=flow_out,
331 cout_tedges=cout_tedges,
332 cout_tedges_days=cout_tedges_days,
333 tedges_days=tedges_days,
334 cumulative_volume_at_cin=cumulative_volume_at_cin,
335 )
337 n_cout = len(cout_tedges) - 1
338 mean_bin_volume = total_volume / len(flow)
339 grid, ibar, mean_r_vpv, off_lo, off_hi = _summed_antideriv(
340 aquifer_pore_volumes=aquifer_pore_volumes,
341 streamline_length=streamline_length,
342 longitudinal_dispersivity=longitudinal_dispersivity,
343 retardation_factor=retardation_factor,
344 mean_bin_volume=mean_bin_volume,
345 saturation_threshold=saturation_threshold,
346 )
347 coeff, cin_bin = _advection_micro_band(
348 cumulative_volume_at_cin=cumulative_volume_at_cin,
349 cumulative_volume_at_cout=cumulative_volume_at_cout,
350 grid=grid,
351 ibar=ibar,
352 mean_r_vpv=mean_r_vpv,
353 off_lo=off_lo,
354 off_hi=off_hi,
355 extend=extend,
356 )
358 # Molecular diffusion: a single mean-streamtube time-domain Gaussian on the outlet signal.
359 # sigma_t^2 = 2*D_m*tau_bt*(r_vpv/L)^2/Q^2, with tau_bt = R*mean(V_pore)/Q the mean breakthrough
360 # time and Q = total_volume/total_time the flow-weighted mean throughflow. sigma_t (days) is
361 # converted with the OUTPUT-grid mean bin width (not the flow grid -- they differ for a coarse
362 # cout grid); a smear wider than the record cannot be resolved. Cap sigma_bins so the forward's
363 # truncation radius int(6*sigma_bins + 0.5) cannot exceed n_cout - 1 -- this matches the explicit
364 # min(..., n_cout - 1) clip the reverse applies in _banded_forward_matrix, so forward and reverse
365 # always use the identical molecular kernel (the cap is unreachable for realistic D_m).
366 total_days = float(tedges_days[-1] - tedges_days[0])
367 q_mean = total_volume / total_days
368 tau_bt = retardation_factor * float(aquifer_pore_volumes.mean()) / q_mean
369 sigma_t2 = (
370 2.0 * float(molecular_diffusivity.mean()) * tau_bt * (mean_r_vpv / float(streamline_length.mean())) ** 2
371 ) / q_mean**2
372 mean_cout_dt = (cout_tedges_days[-1] - cout_tedges_days[0]) / n_cout
373 sigma_bins = min(float(np.sqrt(sigma_t2)) / mean_cout_dt, (n_cout - 1) / 6.0)
375 # Mask bins beyond the data range (and, without warm-start, incompletely-broken-through spin-up
376 # bins). residence_time uses the extended grid when warm-starting so spin-up bins stay valid.
377 work_tedges = tedges
378 if extend:
379 edge_values = tedges.to_numpy().copy()
380 delta = np.timedelta64(36500, "D")
381 edge_values[0] -= delta
382 edge_values[-1] += delta
383 work_tedges = pd.DatetimeIndex(edge_values)
384 # Output bin valid where every streamtube's advective look-back is in-record across the whole
385 # bin (advective coverage == 1 for all pore volumes; NaN outside the record -> invalid).
386 valid = np.all(
387 fraction_explained_full(
388 flow=flow,
389 tedges=work_tedges,
390 cout_tedges=cout_tedges,
391 aquifer_pore_volumes=aquifer_pore_volumes,
392 retardation_factor=retardation_factor,
393 direction="extraction_to_infiltration",
394 )
395 >= 1.0,
396 axis=0,
397 )
398 return coeff, cin_bin, sigma_bins, valid
401def _banded_forward_matrix(
402 *,
403 coeff: npt.NDArray[np.floating],
404 cin_bin: npt.NDArray[np.intp],
405 extend: bool,
406 n_cin: int,
407 sigma_bins: float,
408) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.intp]]:
409 """Assemble ``W = G . M`` as a per-row contiguous band for ``_solve_reverse_banded``.
411 ``M`` (advection + macro + microdispersion) is scattered from the native band onto the real cin
412 axis, folding the warm-start virtual columns into the boundary bins
413 (``clip(cin_bin - int(extend), 0, n_cin - 1)``, so ``M @ cin`` equals the forward's
414 ``coeff @ cin_ext`` exactly). ``G`` is the molecular time-Gaussian along the output-bin axis (the
415 same ``mode="nearest"`` kernel the forward applies with :func:`scipy.ndimage.gaussian_filter1d`).
416 The returned band carries the forward operator verbatim; ``_solve_reverse_banded`` masks the
417 spin-up rows/columns and normalizes, so a forward-then-inverse round trip is self-consistent.
419 Returns
420 -------
421 band_vals : ndarray, shape (n_cout, full_band)
422 Forward weights in banded layout (explicit zeros outside each row's support).
423 col_start : ndarray of int, shape (n_cout,)
424 First real-cin column of each row's band.
425 """
426 n_cout = coeff.shape[0]
427 rows = np.broadcast_to(np.arange(n_cout)[:, None], coeff.shape)
428 real_col = np.clip(cin_bin - int(extend), 0, n_cin - 1)
429 m_mat = coo_array((coeff.ravel(), (rows.ravel(), real_col.ravel())), shape=(n_cout, n_cin)).tocsr()
431 lw = min(int(6.0 * sigma_bins + 0.5), n_cout - 1) if sigma_bins > 0.0 else 0
432 if lw > 0:
433 offsets = np.arange(-lw, lw + 1)
434 kernel = np.exp(-0.5 * (offsets / sigma_bins) ** 2)
435 kernel /= kernel.sum()
436 g_rows = np.repeat(np.arange(n_cout), offsets.size)
437 g_cols = np.clip(np.arange(n_cout)[:, None] + offsets[None, :], 0, n_cout - 1).ravel()
438 g_mat = coo_array((np.tile(kernel, n_cout), (g_rows, g_cols)), shape=(n_cout, n_cout)).tocsr()
439 w_mat = (g_mat @ m_mat).tocsr()
440 else:
441 w_mat = m_mat
443 # CSR -> contiguous per-row band. Each W row is the union of overlapping contiguous M bands, so
444 # it stays contiguous (a flow spike that briefly opens a gap only adds explicit interior zeros).
445 w_mat.sort_indices()
446 indptr, indices, data = w_mat.indptr, w_mat.indices, w_mat.data
447 row_counts = np.diff(indptr)
448 nonempty = row_counts > 0
449 col_start = np.zeros(n_cout, dtype=np.intp)
450 last_col = np.zeros(n_cout, dtype=np.intp)
451 col_start[nonempty] = indices[indptr[:-1][nonempty]]
452 last_col[nonempty] = indices[indptr[1:][nonempty] - 1]
453 full_band = int((last_col[nonempty] - col_start[nonempty] + 1).max()) if nonempty.any() else 1
455 band_vals = np.zeros((n_cout, full_band))
456 rows_of_nz = np.repeat(np.arange(n_cout), row_counts)
457 band_vals[rows_of_nz, indices - col_start[rows_of_nz]] = data
458 return band_vals, col_start
461def infiltration_to_extraction(
462 *,
463 cin: npt.ArrayLike,
464 flow: npt.ArrayLike,
465 tedges: pd.DatetimeIndex,
466 cout_tedges: pd.DatetimeIndex,
467 aquifer_pore_volumes: npt.ArrayLike,
468 streamline_length: npt.NDArray[np.floating] | float,
469 molecular_diffusivity: npt.NDArray[np.floating] | float,
470 longitudinal_dispersivity: npt.NDArray[np.floating] | float,
471 retardation_factor: float = 1.0,
472 flow_out: npt.ArrayLike | None = None,
473 spinup: str | None = "constant",
474 saturation_threshold: float = _DEFAULT_SATURATION_THRESHOLD,
475) -> npt.NDArray[np.floating]:
476 """Compute extracted concentration with advection, microdispersion, and molecular diffusion (approximate).
478 Fast *approximate* counterpart of :func:`gwtransport.diffusion_fast.infiltration_to_extraction`.
479 The advection + macrodispersion + microdispersion (``alpha_L``) part is the exact skewed
480 ``D_m=0`` Kreft-Zuber breakthrough applied on the native cumulative-volume grid; molecular
481 diffusion (``D_m``) is a symmetric time-domain Gaussian. The result is flow-independent and
482 accurate to ~3e-4 whenever ``alpha_L > 0`` (the typical regime) for realistic solute ``D_m``
483 (~1e-4) or ``R = 1``. It loosens to ~1e-2 (sharp inputs) in the molecular-diffusion-dominated
484 corner (``alpha_L`` ~ 0), and retardation enlarges that corner because the Gaussian variance
485 grows as ``D_m * R^3`` (a sharp input at ``D_m = 0.01`` reaches ~1.7e-2 at ``R = 2`` and ~3.5e-2
486 at ``R = 3``). For machine precision -- or heat transport with ``R > 1`` and large ``D_m`` -- use
487 :mod:`gwtransport.diffusion_fast`.
489 Parameters
490 ----------
491 cin : array-like
492 Concentration of the compound in the infiltrating water. Length ``len(tedges) - 1``.
493 flow : array-like
494 Flow rate of water in the aquifer [m³/day]. Length ``len(tedges) - 1``.
495 tedges : pandas.DatetimeIndex
496 Time edges for cin and flow data. Length ``len(cin) + 1``.
497 cout_tedges : pandas.DatetimeIndex
498 Time edges for output data bins. Length ``len(output) + 1``.
499 aquifer_pore_volumes : array-like
500 Aquifer pore volumes [m³] -- one independent streamtube per entry. Any distribution shape
501 (the APVD is pre-summed exactly).
502 streamline_length : float or ndarray
503 Travel distance L [m]: a scalar (shared by all streamtubes) or an array with one
504 value per aquifer pore volume. Must be positive.
505 molecular_diffusivity : float or ndarray
506 Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume.
507 Must be non-negative.
508 longitudinal_dispersivity : float or ndarray
509 Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume.
510 Must be non-negative.
511 retardation_factor : float, optional
512 Retardation factor (default 1.0). Values > 1.0 indicate slower transport.
513 flow_out : array-like or None, optional
514 Extraction flow rate [m³/day] on the output grid (aligned to ``cout_tedges``,
515 length ``len(cout_tedges) - 1``). Required when ``cout_tedges`` differs from ``tedges``;
516 may be omitted only when ``cout_tedges`` equals ``tedges``. Default None.
517 spinup : {"constant"} | None, optional
518 ``"constant"`` (default) extends ``tedges`` by 100 years on each side so a constant
519 warm-start fills the left-edge spin-up region; ``None`` leaves spin-up cout as NaN.
520 saturation_threshold : float, optional
521 Breakthrough-band cutoff ``U`` (default 7.0). Sets how far into the breakthrough tail the
522 banded build reaches; see :func:`gwtransport.diffusion_fast.infiltration_to_extraction`.
524 Returns
525 -------
526 numpy.ndarray
527 Bin-averaged Kreft-Zuber flux concentration ``C_F`` in the extracted water. Length
528 ``len(cout_tedges) - 1``. NaN where no infiltration data has broken through.
530 See Also
531 --------
532 gwtransport.diffusion_fast.infiltration_to_extraction : Exact (machine-precision) counterpart;
533 use it when approximation is unacceptable, especially in the molecular-dominant regime.
534 gwtransport.diffusion.infiltration_to_extraction : Quadrature reference.
535 extraction_to_infiltration : Inverse operation (deconvolves this same operator).
536 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion.
537 """
538 tedges = pd.DatetimeIndex(tedges)
539 cout_tedges = pd.DatetimeIndex(cout_tedges)
540 cin = np.asarray(cin, dtype=float)
541 flow = np.asarray(flow, dtype=float)
542 aquifer_pore_volumes = np.asarray(aquifer_pore_volumes, dtype=float)
543 if flow_out is not None:
544 flow_out = np.asarray(flow_out, dtype=float)
546 _validate_inputs(
547 cin_or_cout=cin,
548 flow=flow,
549 tedges=tedges,
550 cout_tedges=cout_tedges,
551 aquifer_pore_volumes=aquifer_pore_volumes,
552 streamline_length=streamline_length,
553 molecular_diffusivity=molecular_diffusivity,
554 longitudinal_dispersivity=longitudinal_dispersivity,
555 retardation_factor=retardation_factor,
556 is_forward=True,
557 flow_out=flow_out,
558 )
560 n_pv = len(aquifer_pore_volumes)
561 streamline_length = _broadcast_to_pore_volumes(streamline_length, n_pv)
562 molecular_diffusivity = _broadcast_to_pore_volumes(molecular_diffusivity, n_pv)
563 longitudinal_dispersivity = _broadcast_to_pore_volumes(longitudinal_dispersivity, n_pv)
565 n_cout = len(cout_tedges) - 1
566 extend = _extend_tedges_flag(spinup)
567 operator = _build_forward_operator(
568 flow=flow,
569 tedges=tedges,
570 cout_tedges=cout_tedges,
571 flow_out=flow_out,
572 aquifer_pore_volumes=aquifer_pore_volumes,
573 streamline_length=streamline_length,
574 molecular_diffusivity=molecular_diffusivity,
575 longitudinal_dispersivity=longitudinal_dispersivity,
576 retardation_factor=retardation_factor,
577 extend=extend,
578 saturation_threshold=saturation_threshold,
579 )
580 if operator is None:
581 # No through-flow: nothing breaks through (matches diffusion_fast's all-NaN result).
582 return np.full(n_cout, np.nan)
583 coeff, cin_bin, sigma_bins, valid = operator
585 # Apply the advection+macro+micro band M to the (warm-start-extended) cin, then the molecular
586 # time-Gaussian G. Output bins with no through-flow carry a hard 0 in cout_micro; a plain
587 # Gaussian would smear those zeros into the valid neighbours. Convolve mask-aware instead --
588 # G(cout_micro*support)/G(support) -- so gap bins act as missing (not zero) data. This equals a
589 # plain G where support is all-True (constants stay constant, incl. exactly across a gap).
590 cin_ext = np.concatenate([[cin[0]], cin, [cin[-1]]]) if extend else cin
591 cout_micro = np.einsum("kb,kb->k", coeff, cin_ext[cin_bin])
592 support = coeff.sum(axis=1) >= _EPSILON_COEFF_SUM
593 if sigma_bins == 0.0:
594 cout = cout_micro
595 else:
596 num = gaussian_filter1d(cout_micro * support, sigma_bins, mode="nearest", truncate=6.0)
597 den = gaussian_filter1d(support.astype(float), sigma_bins, mode="nearest", truncate=6.0)
598 with np.errstate(divide="ignore", invalid="ignore"):
599 cout = num / den # den > 0 wherever support is True; 0/0 gap bins are masked out below
600 return np.where(support & valid, cout, np.nan)
603def extraction_to_infiltration(
604 *,
605 cout: npt.ArrayLike,
606 flow: npt.ArrayLike,
607 tedges: pd.DatetimeIndex,
608 cout_tedges: pd.DatetimeIndex,
609 aquifer_pore_volumes: npt.ArrayLike,
610 streamline_length: npt.NDArray[np.floating] | float,
611 molecular_diffusivity: npt.NDArray[np.floating] | float,
612 longitudinal_dispersivity: npt.NDArray[np.floating] | float,
613 retardation_factor: float = 1.0,
614 regularization_strength: float = 1e-10,
615 flow_out: npt.ArrayLike | None = None,
616 spinup: str | None = "constant",
617 saturation_threshold: float = _DEFAULT_SATURATION_THRESHOLD,
618) -> npt.NDArray[np.floating]:
619 """Reconstruct infiltration concentration from extracted water (fast approximate deconvolution).
621 Inverts the **same** approximate operator the forward applies: it assembles ``W = G . M`` (the
622 advection+macro+micro band ``M`` times the molecular time-Gaussian ``G``) directly in banded form
623 and deconvolves it with banded Tikhonov regularization (``_solve_reverse_banded`` -- banded
624 Cholesky on the normal equations, ``O(n * band**2)``). It builds ``W`` from one ``Ibar`` gather
625 plus a sparse ``G . M`` product -- no per-pore-volume closed-form loop and no dense
626 ``(n_cout, n_cin)`` matrix -- so it is much faster than
627 :func:`gwtransport.diffusion_fast.extraction_to_infiltration` (which evaluates the exact
628 breakthrough per streamtube), especially for many streamtubes. Because the deconvolved operator
629 is exactly the forward operator, a forward-then-inverse round trip recovers ``cin`` up to the
630 deconvolution conditioning and regularization; the approximation lives entirely in the forward
631 operator vs :mod:`gwtransport.diffusion_fast`.
633 Parameters
634 ----------
635 cout : array-like
636 Concentration of the compound in extracted water. Length ``len(cout_tedges) - 1``.
637 flow : array-like
638 Flow rate of water in the aquifer [m³/day]. Length ``len(tedges) - 1``.
639 tedges : pandas.DatetimeIndex
640 Time edges for cin (output) and flow data. Length ``len(flow) + 1``.
641 cout_tedges : pandas.DatetimeIndex
642 Time edges for cout data bins. Length ``len(cout) + 1``.
643 aquifer_pore_volumes : array-like
644 Aquifer pore volumes [m³] -- one independent streamtube per entry.
645 streamline_length : float or ndarray
646 Travel distance L [m]: scalar or one value per pore volume. Must be positive.
647 molecular_diffusivity : float or ndarray
648 Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume.
649 Must be non-negative.
650 longitudinal_dispersivity : float or ndarray
651 Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume.
652 Must be non-negative.
653 retardation_factor : float, optional
654 Retardation factor (default 1.0).
655 regularization_strength : float, optional
656 Tikhonov regularization parameter (default 1e-10). Must be strictly positive: the banded
657 solver relies on it to make the normal equations positive-definite (it cannot return the
658 dense ``lambda = 0`` minimum-norm solution).
659 flow_out : array-like or None, optional
660 Extraction flow rate [m³/day] on the output grid (aligned to ``cout_tedges``). See
661 :func:`infiltration_to_extraction`. Default None.
662 spinup : {"constant"} | None, optional
663 See :func:`infiltration_to_extraction`. Default ``"constant"``.
664 saturation_threshold : float, optional
665 See :func:`infiltration_to_extraction`. Default 7.0.
667 Returns
668 -------
669 numpy.ndarray
670 Bin-averaged concentration in the infiltrating water. Length ``len(tedges) - 1``.
671 NaN where no extraction data constrains the bin.
673 See Also
674 --------
675 infiltration_to_extraction : Forward operation (the operator inverted here).
676 gwtransport.diffusion_fast.extraction_to_infiltration : Exact (machine-precision) counterpart;
677 use it when the approximation is unacceptable.
678 :ref:`concept-dispersion-scales` : Macrodispersion vs microdispersion.
679 """
680 tedges = pd.DatetimeIndex(tedges)
681 cout_tedges = pd.DatetimeIndex(cout_tedges)
682 cout = np.asarray(cout, dtype=float)
683 flow = np.asarray(flow, dtype=float)
684 aquifer_pore_volumes = np.asarray(aquifer_pore_volumes, dtype=float)
685 if flow_out is not None:
686 flow_out = np.asarray(flow_out, dtype=float)
688 _validate_inputs(
689 cin_or_cout=cout,
690 flow=flow,
691 tedges=tedges,
692 cout_tedges=cout_tedges,
693 aquifer_pore_volumes=aquifer_pore_volumes,
694 streamline_length=streamline_length,
695 molecular_diffusivity=molecular_diffusivity,
696 longitudinal_dispersivity=longitudinal_dispersivity,
697 retardation_factor=retardation_factor,
698 is_forward=False,
699 flow_out=flow_out,
700 )
702 n_pv = len(aquifer_pore_volumes)
703 streamline_length = _broadcast_to_pore_volumes(streamline_length, n_pv)
704 molecular_diffusivity = _broadcast_to_pore_volumes(molecular_diffusivity, n_pv)
705 longitudinal_dispersivity = _broadcast_to_pore_volumes(longitudinal_dispersivity, n_pv)
707 n_cin = len(tedges) - 1
708 extend = _extend_tedges_flag(spinup)
709 operator = _build_forward_operator(
710 flow=flow,
711 tedges=tedges,
712 cout_tedges=cout_tedges,
713 flow_out=flow_out,
714 aquifer_pore_volumes=aquifer_pore_volumes,
715 streamline_length=streamline_length,
716 molecular_diffusivity=molecular_diffusivity,
717 longitudinal_dispersivity=longitudinal_dispersivity,
718 retardation_factor=retardation_factor,
719 extend=extend,
720 saturation_threshold=saturation_threshold,
721 )
722 if operator is None:
723 # No through-flow: nothing constrains the infiltration signal.
724 return np.full(n_cin, np.nan)
725 coeff, cin_bin, sigma_bins, valid = operator
727 band_vals, col_start = _banded_forward_matrix(
728 coeff=coeff, cin_bin=cin_bin, extend=extend, n_cin=n_cin, sigma_bins=sigma_bins
729 )
730 return _solve_reverse_banded(
731 band_vals=band_vals,
732 col_start=col_start,
733 valid_cout_bins=valid,
734 cout=cout,
735 n_cin=n_cin,
736 regularization_strength=regularization_strength,
737 )
740def gamma_infiltration_to_extraction(
741 *,
742 cin: npt.ArrayLike,
743 flow: npt.ArrayLike,
744 tedges: pd.DatetimeIndex,
745 cout_tedges: pd.DatetimeIndex,
746 mean: float | None = None,
747 std: float | None = None,
748 loc: float = 0.0,
749 alpha: float | None = None,
750 beta: float | None = None,
751 n_bins: int = 100,
752 streamline_length: float,
753 molecular_diffusivity: float,
754 longitudinal_dispersivity: float,
755 retardation_factor: float = 1.0,
756 flow_out: npt.ArrayLike | None = None,
757 spinup: str | None = "constant",
758 saturation_threshold: float = _DEFAULT_SATURATION_THRESHOLD,
759) -> npt.NDArray[np.floating]:
760 """Compute extracted concentration for a gamma-distributed pore volume distribution (approximate).
762 Convenience wrapper around :func:`infiltration_to_extraction` that discretizes a (shifted)
763 gamma aquifer pore-volume distribution into ``n_bins`` equal-probability streamtubes. Provide
764 either (mean, std) or (alpha, beta); ``loc`` defaults to 0. Approximate -- see
765 :func:`infiltration_to_extraction`.
767 Parameters
768 ----------
769 cin : array-like
770 Concentration of the compound in infiltrating water.
771 flow : array-like
772 Flow rate of water in the aquifer [m³/day].
773 tedges : pandas.DatetimeIndex
774 Time edges for cin and flow data. Length ``len(cin) + 1``.
775 cout_tedges : pandas.DatetimeIndex
776 Time edges for output data bins. Length ``len(result) + 1``.
777 mean, std : float, optional
778 Mean and standard deviation of the gamma pore-volume distribution.
779 loc : float, optional
780 Location (minimum pore volume), ``0 <= loc < mean``. Default 0.0.
781 alpha, beta : float, optional
782 Shape and scale parameters of the gamma distribution (alternative to mean/std).
783 n_bins : int, optional
784 Number of equal-probability streamtubes. Default 100.
785 streamline_length : float
786 Travel distance L [m], applied to all gamma streamtubes. Must be positive.
787 molecular_diffusivity : float
788 Effective molecular diffusivity D_m [m²/day], applied to all streamtubes. Must be
789 non-negative.
790 longitudinal_dispersivity : float
791 Longitudinal dispersivity alpha_L [m] (microdispersion), applied to all streamtubes. Must be non-negative.
792 retardation_factor : float, optional
793 Retardation factor (default 1.0).
794 flow_out : array-like or None, optional
795 Extraction flow rate [m³/day] on the output grid. See
796 :func:`infiltration_to_extraction`. Default None.
797 spinup : {"constant"} | None, optional
798 See :func:`infiltration_to_extraction`. Default ``"constant"``.
799 saturation_threshold : float, optional
800 See :func:`infiltration_to_extraction`. Default 7.0.
802 Returns
803 -------
804 numpy.ndarray
805 Bin-averaged Kreft-Zuber flux concentration ``C_F`` in the extracted water.
806 Length ``len(cout_tedges) - 1``. NaN where no infiltration data has broken through.
808 See Also
809 --------
810 infiltration_to_extraction : Transport with an explicit pore volume distribution.
811 gamma_extraction_to_infiltration : Reverse operation.
812 gwtransport.gamma.bins : Create gamma distribution bins.
813 :ref:`concept-gamma-distribution` : Two-parameter pore volume model.
814 """
815 bins = gamma.bins(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta, n_bins=n_bins)
816 return infiltration_to_extraction(
817 cin=cin,
818 flow=flow,
819 tedges=tedges,
820 cout_tedges=cout_tedges,
821 aquifer_pore_volumes=bins["expected_values"],
822 streamline_length=streamline_length,
823 molecular_diffusivity=molecular_diffusivity,
824 longitudinal_dispersivity=longitudinal_dispersivity,
825 retardation_factor=retardation_factor,
826 flow_out=flow_out,
827 spinup=spinup,
828 saturation_threshold=saturation_threshold,
829 )
832def gamma_extraction_to_infiltration(
833 *,
834 cout: npt.ArrayLike,
835 flow: npt.ArrayLike,
836 tedges: pd.DatetimeIndex,
837 cout_tedges: pd.DatetimeIndex,
838 mean: float | None = None,
839 std: float | None = None,
840 loc: float = 0.0,
841 alpha: float | None = None,
842 beta: float | None = None,
843 n_bins: int = 100,
844 streamline_length: float,
845 molecular_diffusivity: float,
846 longitudinal_dispersivity: float,
847 retardation_factor: float = 1.0,
848 regularization_strength: float = 1e-10,
849 flow_out: npt.ArrayLike | None = None,
850 spinup: str | None = "constant",
851 saturation_threshold: float = _DEFAULT_SATURATION_THRESHOLD,
852) -> npt.NDArray[np.floating]:
853 """Reconstruct infiltration concentration for a gamma-distributed pore volume distribution.
855 Convenience wrapper around :func:`extraction_to_infiltration` that discretizes a (shifted)
856 gamma aquifer pore-volume distribution into ``n_bins`` equal-probability streamtubes. Provide
857 either (mean, std) or (alpha, beta); ``loc`` defaults to 0. Fast approximate banded deconvolution
858 (see :func:`extraction_to_infiltration`).
860 Parameters
861 ----------
862 cout : array-like
863 Concentration of the compound in extracted water.
864 flow : array-like
865 Flow rate of water in the aquifer [m³/day].
866 tedges : pandas.DatetimeIndex
867 Time edges for cin (output) and flow data. Length ``len(flow) + 1``.
868 cout_tedges : pandas.DatetimeIndex
869 Time edges for cout data bins. Length ``len(cout) + 1``.
870 mean, std : float, optional
871 Mean and standard deviation of the gamma pore-volume distribution.
872 loc : float, optional
873 Location (minimum pore volume), ``0 <= loc < mean``. Default 0.0.
874 alpha, beta : float, optional
875 Shape and scale parameters of the gamma distribution (alternative to mean/std).
876 n_bins : int, optional
877 Number of equal-probability streamtubes. Default 100.
878 streamline_length : float
879 Travel distance L [m], applied to all gamma streamtubes. Must be positive.
880 molecular_diffusivity : float
881 Effective molecular diffusivity D_m [m²/day], applied to all streamtubes. Must be
882 non-negative.
883 longitudinal_dispersivity : float
884 Longitudinal dispersivity alpha_L [m] (microdispersion), applied to all streamtubes. Must be non-negative.
885 retardation_factor : float, optional
886 Retardation factor (default 1.0).
887 regularization_strength : float, optional
888 Tikhonov regularization parameter (default 1e-10).
889 flow_out : array-like or None, optional
890 Extraction flow rate [m³/day] on the output grid. See
891 :func:`infiltration_to_extraction`. Default None.
892 spinup : {"constant"} | None, optional
893 See :func:`infiltration_to_extraction`. Default ``"constant"``.
894 saturation_threshold : float, optional
895 See :func:`infiltration_to_extraction`. Default 7.0.
897 Returns
898 -------
899 numpy.ndarray
900 Bin-averaged concentration in the infiltrating water. Length ``len(tedges) - 1``.
902 See Also
903 --------
904 extraction_to_infiltration : Deconvolution with an explicit pore volume distribution.
905 gamma_infiltration_to_extraction : Forward operation.
906 gwtransport.gamma.bins : Create gamma distribution bins.
907 :ref:`concept-gamma-distribution` : Two-parameter pore volume model.
908 """
909 bins = gamma.bins(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta, n_bins=n_bins)
910 return extraction_to_infiltration(
911 cout=cout,
912 flow=flow,
913 tedges=tedges,
914 cout_tedges=cout_tedges,
915 aquifer_pore_volumes=bins["expected_values"],
916 streamline_length=streamline_length,
917 molecular_diffusivity=molecular_diffusivity,
918 longitudinal_dispersivity=longitudinal_dispersivity,
919 retardation_factor=retardation_factor,
920 regularization_strength=regularization_strength,
921 flow_out=flow_out,
922 spinup=spinup,
923 saturation_threshold=saturation_threshold,
924 )