gwtransport

Contents

gwtransport#

gwtransport: A Python package for solving one-dimensional groundwater transport problems.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

advection#

Advective Transport Modeling Along Aquifer Pore Volumes.

Water infiltrates and is transported in parallel along multiple aquifer pore volumes to extraction. For each aquifer pore volume, transport is 1D advection with linear or non-linear sorption; there is no microdispersion or molecular diffusion, while the spread across aquifer pore volumes provides macrodispersion. Forward and backward modeling are supported. No assumption is made about whether the flow is radial or orthogonal.

Note on dispersion: The spreading from the pore volume distribution (APVD) represents macrodispersion—aquifer-scale velocity heterogeneity that depends on both aquifer properties and hydrological boundary conditions. To add microdispersion and molecular diffusion separately (when APVD comes from streamline analysis), use gwtransport.diffusion. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for details.

Note on cross-compound calibration: When APVD is calibrated from measurements of one compound (e.g., temperature with D_m ~ 0.1 m²/day) and used to predict another (e.g., a solute with D_m ~ 1e-4 m²/day), the molecular diffusion contribution is baked into the calibrated std. The cleanest fix is to calibrate with gwtransport.diffusion_fast instead, which keeps the three contributions separate.

The aquifer pore volume distribution (APVD) enters in one of two forms. The gamma_* pair takes the APVD in closed form as a (shifted) gamma distribution, parameterized by either (mean, std, loc) or (alpha, beta, loc), and discretizes it internally into n_bins equal-probability-mass streamtubes. The plain pair takes aquifer_pore_volumes: an explicit array of pore volumes [m³], one per streamtube, treated as equally weighted at the outlet.

Available functions:

  • infiltration_to_extraction() - Compute the concentration (or temperature) of the extracted water from an infiltration series and an explicit array of aquifer pore volumes. Each cout_tedges bin gets the flow-weighted average of cin over each streamtube’s back-projected source window, averaged over the streamtubes; units are those of cin. Sorption is linear, through a constant retardation_factor. The spinup policy (default "constant") warm-starts the record before tedges[0]; spinup=None instead returns NaN for every cout bin whose streamtube source windows are not fully inside the cin range.

  • extraction_to_infiltration() - Reverse operation: reconstruct the infiltrating concentration from measured extraction concentrations by inverting the forward weight matrix with Tikhonov regularization (regularization_strength). Returns one value per tedges bin. NaN in cout marks measurement gaps; those rows are excluded from the solve, and cin bins constrained only by gapped cout bins come back as NaN.

  • gamma_infiltration_to_extraction() - Forward transport as in infiltration_to_extraction(), with the APVD supplied as gamma parameters rather than as an explicit array of pore volumes.

  • gamma_extraction_to_infiltration() - Deconvolution as in extraction_to_infiltration(), with the APVD supplied as gamma parameters rather than as an explicit array of pore volumes.

  • infiltration_to_extraction_nonlinear_sorption() - Forward transport with a concentration-dependent isotherm – Freundlich, Langmuir, or a constant retardation factor – solved by front tracking along each pore volume. Returns a tuple: the flow-weighted bin-averaged extraction concentration, and one diagnostic structure per pore volume holding the waves, events, first arrival and final solver state. Cout bins whose source window leaves the flow record are returned as 0.0 rather than NaN, while bins with zero throughflow are NaN. Forward only: non-linear sorption forms shocks, and there is no extraction-to-infiltration counterpart.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.advection.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, retardation_factor=1.0, spinup='constant')[source]#

Compute the concentration of the extracted water by shifting cin with its residence time.

The compound is retarded in the aquifer with a retardation factor. The residence time is computed based on the flow rate of the water in the aquifer and the pore volume of the aquifer. The aquifer pore volume is approximated by a (shifted) gamma distribution parameterized by either (mean, std, loc) or (alpha, beta, loc).

This function represents infiltration to extraction modeling by flow-weighted averaging.

Provide either (mean, std) or (alpha, beta); loc is optional and defaults to 0.

Parameters:
  • cin (ArrayLike) – Concentration of the compound in infiltrating water or temperature of infiltrating water. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • tedges (DatetimeIndex) – Time edges for both cin and flow data. Used to compute the cumulative concentration. Has a length of one more than cin and flow.

  • cout_tedges (DatetimeIndex) – Time edges for the output data. Used to compute the cumulative concentration. Has a length of one more than the desired output length.

  • mean (float | None, default: None) – Mean of the gamma distribution of the aquifer pore volume. Must be strictly greater than loc.

  • std (float | None, default: None) – Standard deviation of the gamma distribution of the aquifer pore volume (invariant under the loc shift).

  • loc (float, default: 0.0) – Location (minimum pore volume) of the gamma distribution. Must satisfy 0 <= loc < mean. Default is 0.0.

  • alpha (float | None, default: None) – Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).

  • beta (float | None, default: None) – Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).

  • n_bins (int, default: 100) – Number of bins to discretize the gamma distribution. Default 100.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer (default 1.0). Values > 1.0 indicate slower transport due to sorption/interaction.

  • spinup (str | None, default: 'constant') – Forwarded to infiltration_to_extraction(). Default "constant" warm-starts the system before tedges[0].

Returns:

Concentration of the compound in the extracted water, or temperature. Same units as cin.

Return type:

NDArray[floating]

See also

infiltration_to_extraction

Transport with explicit pore volume distribution

gamma_extraction_to_infiltration

Reverse operation (deconvolution)

gwtransport.gamma.bins

Create gamma distribution bins

gwtransport.residence_time.full

Compute residence times

gwtransport.diffusion.infiltration_to_extraction

Add microdispersion and molecular diffusion

Gamma Distribution Model

Two-parameter pore volume model

8. Gamma Distribution Adequacy

When gamma distribution is adequate

Notes

The APVD is only time-invariant under the steady-streamlines assumption (see 2. Steady Streamlines).

The spreading from the gamma-distributed pore volumes represents macrodispersion (aquifer-scale heterogeneity). When std comes from calibration on measurements, it absorbs all mixing: macrodispersion, microdispersion, and an average molecular diffusion contribution. When calibrating with the diffusion module, these three components are taken into account separately. When std comes from streamline analysis, it represents macrodispersion only; microdispersion and molecular diffusion can be added via gwtransport.diffusion_fast or gwtransport.diffusion.

For cross-compound prediction (calibrating on temperature and predicting a solute), calibrate with gwtransport.diffusion_fast so the three contributions are tracked separately rather than lumped into a single calibrated std. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for background.

Examples

Basic usage with alpha and beta parameters:

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.utils import compute_time_edges
>>> from gwtransport.advection import gamma_infiltration_to_extraction
>>>
>>> # Create input data with aligned time edges
>>> dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
>>> tedges = compute_time_edges(
...     tedges=None, tstart=None, tend=dates, number_of_bins=len(dates)
... )
>>>
>>> # Create output time edges (can be different alignment)
>>> cout_dates = pd.date_range(start="2020-01-05", end="2020-01-15", freq="D")
>>> cout_tedges = compute_time_edges(
...     tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates)
... )
>>>
>>> # Input concentration and flow (same length, aligned with tedges)
>>> cin = pd.Series(np.ones(len(dates)), index=dates)
>>> flow = pd.Series(np.ones(len(dates)) * 100, index=dates)  # 100 m³/day
>>>
>>> # Run gamma_infiltration_to_extraction with alpha/beta parameters
>>> cout = gamma_infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     alpha=10.0,
...     beta=10.0,
...     n_bins=5,
... )
>>> cout.shape
(11,)

Using mean and std parameters instead:

>>> cout = gamma_infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     mean=100.0,
...     std=20.0,
...     n_bins=5,
... )
gwtransport.advection.gamma_extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, retardation_factor=1.0, regularization_strength=1e-10, spinup='constant')[source]#

Compute the concentration of the infiltrating water from extracted water (deconvolution).

The compound is retarded in the aquifer with a retardation factor. The residence time is computed based on the flow rate of the water in the aquifer and the pore volume of the aquifer. The aquifer pore volume is approximated by a (shifted) gamma distribution parameterized by either (mean, std, loc) or (alpha, beta, loc).

This function inverts the forward flow-weighted averaging (deconvolution). It is symmetric to gamma_infiltration_to_extraction.

Provide either (mean, std) or (alpha, beta); loc is optional and defaults to 0.

Parameters:
  • cout (ArrayLike) – Concentration of the compound in extracted water or temperature of extracted water. The model assumes this value is constant over each interval [cout_tedges[i], cout_tedges[i+1]).

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • tedges (DatetimeIndex) – Time edges for cin (output) and flow data. Has a length of one more than flow.

  • cout_tedges (DatetimeIndex) – Time edges for the cout data. Has a length of one more than cout.

  • mean (float | None, default: None) – Mean of the gamma distribution of the aquifer pore volume. Must be strictly greater than loc.

  • std (float | None, default: None) – Standard deviation of the gamma distribution of the aquifer pore volume (invariant under the loc shift).

  • loc (float, default: 0.0) – Location (minimum pore volume) of the gamma distribution. Must satisfy 0 <= loc < mean. Default is 0.0.

  • alpha (float | None, default: None) – Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).

  • beta (float | None, default: None) – Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).

  • n_bins (int, default: 100) – Number of bins to discretize the gamma distribution. Default 100.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer (default 1.0). Values > 1.0 indicate slower transport due to sorption/interaction.

  • regularization_strength (float, default: 1e-10) – Tikhonov regularization parameter λ. See extraction_to_infiltration() for details. Default is 1e-10.

  • spinup (str | None, default: 'constant') – Forwarded to extraction_to_infiltration(). Default "constant" warm-starts the system before tedges[0].

Returns:

Concentration of the compound in the infiltrating water, or temperature. Same units as cout.

Return type:

NDArray[floating]

See also

extraction_to_infiltration

Deconvolution with explicit pore volume distribution

gamma_infiltration_to_extraction

Forward operation (flow-weighted averaging)

gwtransport.gamma.bins

Create gamma distribution bins

gwtransport.diffusion.extraction_to_infiltration

Deconvolution with microdispersion and molecular diffusion

Gamma Distribution Model

Two-parameter pore volume model

8. Gamma Distribution Adequacy

When gamma distribution is adequate

Notes

The APVD is only time-invariant under the steady-streamlines assumption (see 2. Steady Streamlines).

The spreading from the gamma-distributed pore volumes represents macrodispersion (aquifer-scale heterogeneity). When std comes from calibration on measurements, it absorbs all mixing: macrodispersion, microdispersion, and an average molecular diffusion contribution. When calibrating with the diffusion module, these three components are taken into account separately. When std comes from streamline analysis, it represents macrodispersion only; microdispersion and molecular diffusion can be added via gwtransport.diffusion_fast or gwtransport.diffusion.

For cross-compound prediction (calibrating on temperature and predicting a solute), calibrate with gwtransport.diffusion_fast so the three contributions are tracked separately rather than lumped into a single calibrated std. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for background.

Examples

Basic usage with alpha and beta parameters:

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.utils import compute_time_edges
>>> from gwtransport.advection import gamma_extraction_to_infiltration
>>>
>>> # Create cin/flow time edges
>>> cin_dates = pd.date_range(start="2019-12-25", end="2020-01-15", freq="D")
>>> tedges = compute_time_edges(
...     tedges=None, tstart=None, tend=cin_dates, number_of_bins=len(cin_dates)
... )
>>>
>>> # Create cout time edges
>>> cout_dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
>>> cout_tedges = compute_time_edges(
...     tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates)
... )
>>>
>>> # Input concentration and flow
>>> cout = np.ones(len(cout_dates))
>>> flow = np.ones(len(cin_dates)) * 100  # 100 m³/day
>>>
>>> # Run gamma_extraction_to_infiltration with alpha/beta parameters
>>> cin = gamma_extraction_to_infiltration(
...     cout=cout,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     alpha=10.0,
...     beta=10.0,
...     n_bins=5,
... )
>>> cin.shape
(22,)

Using mean and std parameters instead:

>>> cin = gamma_extraction_to_infiltration(
...     cout=cout,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     mean=100.0,
...     std=20.0,
...     n_bins=5,
... )
gwtransport.advection.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, aquifer_pore_volumes, retardation_factor=1.0, spinup='constant')[source]#

Compute the concentration of the extracted water using flow-weighted advection.

This function implements an infiltration to extraction advection model where cin and flow values correspond to the same aligned time bins defined by tedges.

Pure advection is volume-stationary, so the weights are built on the cumulative-throughflow-volume axis rather than by inverting residence times:

  1. Map the cin and cout time edges to cumulative throughflow volume.

  2. Back-project each cout bin by every retarded pore volume to its infiltration-time source window. The window spans one cout bin’s worth of volume, so it overlaps only a narrow band of cin bins.

  3. Compute the flow-weighted time overlap of each window with those cin bins, normalize per streamtube (each row sums to 1), and average over the streamtubes whose source window lies fully inside the cin range.

Parameters:
  • cin (ArrayLike) – Concentration values of infiltrating water or temperature [concentration units]. Length must match the number of time bins defined by tedges. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • flow (ArrayLike) – Flow rate values in the aquifer [m³/day]. Length must match cin and the number of time bins defined by tedges. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • tedges (DatetimeIndex) – Time edges defining bins for both cin and flow data. Has length of len(cin) + 1 and len(flow) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for output data bins. Has length of desired output + 1. Can have different time alignment and resolution than tedges.

  • aquifer_pore_volumes (ArrayLike) – Array of aquifer pore volumes [m³] representing the distribution of residence times in the aquifer system.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer (default 1.0). Values > 1.0 indicate slower transport due to sorption/interaction.

  • spinup (str | None, default: 'constant') –

    How to treat cout bins where one or more streamtube source windows fall outside the cin time range. Default is "constant".

    • "constant" — warm-start: shift tedges[0] backward by retardation_factor * max(aquifer_pore_volumes) / flow[0] and treat cin and flow as constant at their first value over the extended window. The forward strict-validity logic then has no NaN cout bins from spin-up; right-edge spin-up (cout extending past the cin range) is unchanged.

    • None — strict mass-conservation: NaN whenever any streamtube has not fully broken through into the cin range, or extraction flow during the bin is zero. Bundle row sums to 1 across cin.

Returns:

Flow-weighted concentration in the extracted water. Same units as cin. Length equals len(cout_tedges) - 1. NaN values mark cout bins where the chosen spinup policy is not satisfied: the default "constant" leaves NaN for any cout bin extending past the end of the flow record (a cout edge beyond tedges[-1], whose back-projected source window leaves the cin range) and for zero-throughflow bins; spinup=None additionally NaNs left-edge spin-up bins.

Return type:

NDArray[floating]

Raises:

ValueError – If tedges length doesn’t match cin/flow arrays plus one, or if infiltration time edges become non-monotonic (invalid input conditions).

See also

gamma_infiltration_to_extraction

Transport with gamma-distributed pore volumes

extraction_to_infiltration

Reverse operation (deconvolution)

gwtransport.residence_time.full

Compute residence times from flow and pore volume

gwtransport.residence_time.freundlich_retardation

Compute concentration-dependent retardation

The Central Concept: Pore Volume Distribution

Background on aquifer heterogeneity modeling

Core Transport Equation

Flow-weighted averaging approach

Examples

Basic usage with pandas Series:

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.utils import compute_time_edges
>>> from gwtransport.advection import infiltration_to_extraction
>>>
>>> # Create input data
>>> dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
>>> tedges = compute_time_edges(
...     tedges=None, tstart=None, tend=dates, number_of_bins=len(dates)
... )
>>>
>>> # Create output time edges (different alignment)
>>> cout_dates = pd.date_range(start="2020-01-05", end="2020-01-15", freq="D")
>>> cout_tedges = compute_time_edges(
...     tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates)
... )
>>>
>>> # Input concentration and flow
>>> cin = pd.Series(np.ones(len(dates)), index=dates)
>>> flow = pd.Series(np.ones(len(dates)) * 100, index=dates)  # 100 m³/day
>>>
>>> # Define distribution of aquifer pore volumes
>>> aquifer_pore_volumes = np.array([50, 100, 200])  # m³
>>>
>>> # Run infiltration_to_extraction
>>> cout = infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
... )
>>> cout.shape
(11,)

With constant retardation factor (linear sorption):

>>> cout = infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
...     retardation_factor=2.0,  # Compound moves twice as slowly
... )

Note: For concentration-dependent retardation (nonlinear sorption), use infiltration_to_extraction_nonlinear_sorption instead, as this function only supports constant (float) retardation factors.

gwtransport.advection.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, aquifer_pore_volumes, retardation_factor=1.0, regularization_strength=1e-10, spinup='constant')[source]#

Compute the concentration of the infiltrating water from extracted water (deconvolution).

Inverts the forward transport model by solving the linear system W_forward @ cin = cout where W_forward is the weight matrix from infiltration_to_extraction(). Uses Tikhonov regularization to smoothly blend data fitting with a physically motivated target (transpose-and-normalize of the forward matrix).

Well-determined modes (large singular values relative to √λ) are dominated by the data; poorly-determined modes are pulled toward the target. This avoids edge oscillations and is less sensitive to the regularization parameter than truncated SVD (rcond).

Parameters:
  • cout (ArrayLike) – Concentration values of extracted water [concentration units]. Length must match the number of time bins defined by cout_tedges. The model assumes this value is constant over each interval [cout_tedges[i], cout_tedges[i+1]).

  • flow (ArrayLike) – Flow rate values in the aquifer [m³/day]. Length must match the number of time bins defined by tedges. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • tedges (DatetimeIndex) – Time edges defining bins for both cin (output) and flow data. Has length of len(flow) + 1. Output cin has length len(tedges) - 1.

  • cout_tedges (DatetimeIndex) – Time edges for cout data bins. Has length of len(cout) + 1. Can have different time alignment and resolution than tedges.

  • aquifer_pore_volumes (ArrayLike) – Array of aquifer pore volumes [m³] representing the distribution of residence times in the aquifer system.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer (default 1.0). Values > 1.0 indicate slower transport due to sorption/interaction.

  • regularization_strength (float, default: 1e-10) –

    Tikhonov regularization parameter λ. Controls the tradeoff between fitting the data (||W cin - cout||²) and staying close to the regularization target (λ ||cin - cin_target||²). The target is the transpose-and-normalize of the forward matrix applied to cout.

    Larger values trust the target more (smoother, more biased); smaller values trust the data more (noisier, less biased). The solution varies continuously with λ. Default is 1e-10.

    A good starting value for noisy data is λ (noise_std / signal_amplitude)². For example, temperature data with 0.05 °C noise and ~10 °C seasonal amplitude suggests regularization_strength (0.05 / 10)² 2.5e-5. Increase by a factor of 2-10 for additional smoothing. For noiseless synthetic data (e.g., roundtrip tests), the default 1e-10 preserves machine precision.

  • spinup (str | float | None, default: 'constant') – Spin-up policy applied when building the forward weight matrix used to set up the inverse problem. Same semantics as in infiltration_to_extraction(); default "constant" shifts tedges[0] backward by retardation_factor * max(aquifer_pore_volumes) / flow[0] so the inverse problem has no spin-up zero-rows for cout bins inside the original tedges range. The warm-start prefix is solved for internally but dropped before returning, so the output cin stays aligned with the user-provided tedges (length len(tedges) - 1), not the padded grid. Passing None keeps the strict-validity behavior (zero-rows in W from incomplete breakthrough).

Returns:

Concentration in the infiltrating water. Same units as cout. Length equals len(tedges) - 1 (unchanged whether or not spinup="constant" shifted tedges[0]). NaN values indicate cin bins with no temporal overlap with the extraction data. The forward weight matrix used to set up the inverse problem treats spin-up and zero-flow cout bins as zero-rows according to the spinup policy.

Return type:

NDArray[floating]

Raises:

ValueError – If tedges length doesn’t match flow plus one, if cout_tedges length doesn’t match cout plus one, or if flow contains NaN.

See also

gamma_extraction_to_infiltration

Deconvolution with gamma-distributed pore volumes

infiltration_to_extraction

Forward operation (flow-weighted averaging)

gwtransport.residence_time.full

Compute residence times from flow and pore volume

gwtransport.utils.solve_tikhonov

Solver used for inversion

The Central Concept: Pore Volume Distribution

Background on aquifer heterogeneity modeling

Core Transport Equation

Flow-weighted averaging approach

Notes

NaN values in cout mark measurement gaps (e.g. sparse lab samples). Their rows are excluded from the banded Tikhonov solve, matching gwtransport.deposition.extraction_to_deposition(); cin bins constrained only by gapped cout bins are returned as NaN.

Examples

Basic usage with pandas Series:

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.utils import compute_time_edges
>>> from gwtransport.advection import extraction_to_infiltration
>>>
>>> # Create cin/flow time edges
>>> cin_dates = pd.date_range(start="2019-12-25", end="2020-01-15", freq="D")
>>> tedges = compute_time_edges(
...     tedges=None, tstart=None, tend=cin_dates, number_of_bins=len(cin_dates)
... )
>>>
>>> # Create cout time edges
>>> cout_dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
>>> cout_tedges = compute_time_edges(
...     tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates)
... )
>>>
>>> # Input concentration and flow
>>> cout = np.ones(len(cout_dates))
>>> flow = np.ones(len(cin_dates)) * 100  # 100 m³/day
>>>
>>> # Define distribution of aquifer pore volumes
>>> aquifer_pore_volumes = np.array([50, 100, 200])  # m³
>>>
>>> # Run extraction_to_infiltration
>>> cin = extraction_to_infiltration(
...     cout=cout,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
... )
>>> cin.shape
(22,)

Round-trip reconstruction (symmetric with infiltration_to_extraction). The default spinup="constant" warm-starts the left edge; the cout window must therefore stay inside the cin window with margin matching the longest residence time on the right (forward NaN at the right edge would otherwise be rejected by extraction_to_infiltration):

>>> from gwtransport.advection import infiltration_to_extraction
>>> rt_cout_dates = pd.date_range(start="2020-01-01", end="2020-01-10", freq="D")
>>> rt_cout_tedges = compute_time_edges(
...     tedges=None,
...     tstart=None,
...     tend=rt_cout_dates,
...     number_of_bins=len(rt_cout_dates),
... )
>>> cin_original = np.sin(np.linspace(0, 2 * np.pi, len(cin_dates))) + 2
>>> cout_rt = infiltration_to_extraction(
...     cin=cin_original,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=rt_cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
... )
>>> cin_recovered = extraction_to_infiltration(
...     cout=cout_rt,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=rt_cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
... )
gwtransport.advection.infiltration_to_extraction_nonlinear_sorption(*, cin, flow, tedges, cout_tedges, aquifer_pore_volumes, freundlich_k=None, freundlich_n=None, bulk_density=None, porosity=None, retardation_factor=None, langmuir_s_max=None, langmuir_k_l=None, max_iterations=10000)[source]#

Compute extracted concentration with complete diagnostic information.

Returns both bin-averaged concentrations and detailed simulation structure for each pore volume.

Exactly one sorption model must be specified:

  • retardation_factor for constant (linear) retardation.

  • freundlich_k + freundlich_n + bulk_density + porosity for Freundlich isotherm.

  • langmuir_s_max + langmuir_k_l + bulk_density + porosity for Langmuir isotherm.

Parameters:
  • cin (ArrayLike) – Infiltration concentration [mg/L or any units]. Length = len(tedges) - 1. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • flow (ArrayLike) – Flow rate [m³/day]. Must be non-negative. Length = len(tedges) - 1. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • tedges (DatetimeIndex) – Time bin edges. Length = len(cin) + 1.

  • cout_tedges (DatetimeIndex) – Output time bin edges. Can be different from tedges. Length = number of output bins + 1 (n+1 edges for n output values).

  • aquifer_pore_volumes (ArrayLike) – Array of aquifer pore volumes [m³] representing the distribution of residence times in the aquifer system. Each pore volume must be positive.

  • freundlich_k (float | None, default: None) – Freundlich coefficient [(m³/kg)^(1/n)]. Must be positive. Same convention (isotherm s = k_f * C^(1/n)) as gwtransport.residence_time.freundlich_retardation().

  • freundlich_n (float | None, default: None) – Freundlich exponent [-]. Must be positive and != 1.

  • bulk_density (float | None, default: None) – Bulk density [kg/m³]. Must be positive. Shared by Freundlich and Langmuir models.

  • porosity (float | None, default: None) – Porosity [-]. Must be in (0, 1). Shared by Freundlich and Langmuir models.

  • retardation_factor (float | None, default: None) – Constant retardation factor [-]. Must be >= 1.0.

  • langmuir_s_max (float | None, default: None) – Langmuir maximum sorption capacity [mg/kg]. Must be positive.

  • langmuir_k_l (float | None, default: None) – Langmuir half-saturation constant [mg/L]. Must be positive.

  • max_iterations (int, default: 10000) – Maximum number of events. Default 10000.

Return type:

tuple[NDArray[floating], list[dict]]

Returns:

  • cout (numpy.ndarray) – Flow-weighted concentrations averaged across all pore volumes. Output bins whose source window leaves the inlet flow record (e.g. cout bins before first breakthrough, or extending past the flow record) are returned as 0.0, not NaN; the front-tracking solver clamps such out-of-range windows to the last known state rather than masking them. An output bin with zero throughflow (every overlapping input bin has zero flow) has an undefined flow-weighted average and is returned as NaN, matching infiltration_to_extraction().

  • structures (list of dict) – List of detailed simulation structures, one for each pore volume, with keys:

    • ’waves’: List[Wave] - All wave objects created during simulation

    • ’events’: List[dict] - All events; each record carries "theta" (m³) and "type". Translate to user-facing time t via tracker_state.t_at_theta(event["theta"]) if needed.

    • ’theta_first_arrival’: float - Cumulative flow at first nonzero arrival [m³]

    • ’n_events’: int - Total number of events

    • ’n_shocks’: int - Number of shocks created

    • ’n_rarefactions’: int - Number of rarefactions created

    • ’n_characteristics’: int - Number of characteristics created

    • ’theta_current’: float - Final simulation cumulative flow [m³]

    • ’sorption’: SorptionModel - Sorption object

    • ’tracker_state’: FrontTrackerState - Complete simulation state

    • ’aquifer_pore_volume’: float - Pore volume for this simulation

Raises:

RuntimeError – If the front-tracking solver leaves an unresolved wave interaction (the domain-mass field over-counts stored mass). This is an internal-consistency tripwire, not an input limitation: the solver composes every wave interaction, so a firing indicates a bug.

See also

infiltration_to_extraction

Convolution-based approach for linear retardation

gamma_infiltration_to_extraction

For distributions of pore volumes

Non-Linear Sorption: Exact Solutions

Freundlich isotherm and front-tracking theory

1. Advection-Dominated Transport

When diffusion/dispersion is negligible

Examples

cout, structures = infiltration_to_extraction_nonlinear_sorption(
    cin=cin,
    flow=flow,
    tedges=tedges,
    cout_tedges=cout_tedges,
    aquifer_pore_volumes=np.array([500.0]),
    freundlich_k=0.01,
    freundlich_n=2.0,
    bulk_density=1500.0,
    porosity=0.3,
)

# Access spin-up period for first pore volume
theta_first = structures[0]["theta_first_arrival"]
t_first = structures[0]["tracker_state"].t_at_theta(theta_first)
print(f"First arrival: θ={theta_first:.2f} m³ (t={t_first:.2f} days)")

# Analyze events for first pore volume
for event in structures[0]["events"]:
    print(f"θ={event['theta']:.2f}: {event['type']}")

deposition#

Deposition Analysis for 1D Aquifer Systems.

Areal deposition supplies mass to the groundwater, mixed instantaneously over the height of the aquifer. The aquifer has a constant thickness with a finite pore volume; water with zero concentration infiltrates at one end and is extracted at the other, whether the flow is radial or orthogonal. Transport is 1D advection with linear sorption; there is no microdispersion, molecular diffusion, or macrodispersion. Forward and backward modeling are supported.

The model is a source term (positive deposition adds mass to the water); it does NOT model removal processes such as pathogen attachment, particle filtration, or chemical precipitation, which would remove mass from the water and require the opposite sign convention.

Available functions:

  • deposition_to_extraction() - Forward model: from areal deposition rates [g/m²/day] and flow, compute the concentration [g/m³] of the extracted water on cout_tedges. Each output bin is the flow-weighted average over the water extracted in that bin; a parcel’s concentration gain is proportional to the residence-time contact with the areal flux, mixed over porosity * thickness. Bins whose residence time is not yet resolved (spin-up) return NaN, while bins that extract zero volume – including bins lying outside the flow record – return 0.0.

  • extraction_to_deposition() - Inverse model (deconvolution): from the concentration of the extracted water [g/m³], estimate the mean deposition rate [g/m²/day] in each tedges bin. Solves the banded forward system with Tikhonov regularization toward a physically motivated target (transpose-and-normalize of the forward operator), with regularization_strength trading data fit against that target. Rows without a resolved residence time, zero-flow output bins and NaN entries in cout are excluded from the solve. This is the recommended inverse.

  • extraction_to_deposition_full() - Same inversion routed through the dense nullspace solver gwtransport.utils.solve_underdetermined_system(), exposing its options: the nullspace objective ("squared_differences" for smooth solutions, "summed_differences" for piecewise-constant ones, or a callable), the scipy optimization_method, and the least-squares rcond cutoff. Reach for it when the choice of nullspace component matters; otherwise prefer extraction_to_deposition(), which is cheaper and needs no dense operator.

  • compute_deposition_weights() - Build the operator relating deposition rates to extracted concentrations in a compact banded layout, returning the band values, the first cin column of each row, and the row masks for valid and spin-up output bins. Row k sums to residence_time_k / (retardation_factor * porosity * thickness) rather than to one, because a deposition rate maps to a concentration through the residence time. Exposed for custom inverse solvers; the forward and both inverse entry points build on it.

  • spinup_duration() - Time in days, measured from tedges[0], at which the cumulative flow first reaches retardation_factor * aquifer_pore_volume: the earliest extraction time whose water carries a complete deposition history, and hence the start of the valid analysis period. Under constant flow this is retardation_factor * aquifer_pore_volume / flow. Raises ValueError when the flow record is too short to reach that volume.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.deposition.compute_deposition_weights(*, flow, tedges, cout_tedges, aquifer_pore_volume, porosity, thickness, retardation_factor=1.0)[source]#

Build the deposition weight operator in a compact banded layout.

Row k of the dense (n_cout, n_cin) operator is band_vals[k] placed at columns [col_start[k], col_start[k] + full_band). The operator is genuinely banded – row k is nonzero only on the cin bins whose cumulative through-flow volume lies in the residence-time window [min(start_vol_k, start_vol_{k+1}), max(start_vol_k, start_vol_{k+1}) + R * aquifer_pore_volume] – so each band has at most full_band slots, bounded by R * aquifer_pore_volume in volume (independent of record length n_cin). The window is located by numpy.searchsorted() on the cumulative flow volume flow_cum; the per-cell math reuses gwtransport.deposition_utils._clipped_linear_integral restricted to the band columns, so each row sums to r_k = residence_time_k / (retardation_factor * porosity * thickness). Reconstruct the dense (n_cout, n_cin) matrix with gwtransport.advection_utils._densify_weights when a dense operator is required (the nullspace inverse).

Parameters:
  • flow (ArrayLike) – Flow rates in aquifer [m³/day]. Length must equal len(tedges) - 1.

  • tedges (DatetimeIndex) – Time bin edges for flow data.

  • cout_tedges (DatetimeIndex) – Time bin edges for output concentration data.

  • aquifer_pore_volume (float) – Aquifer pore volume [m³].

  • porosity (float) – Aquifer porosity [dimensionless].

  • thickness (float) – Aquifer thickness [m].

  • retardation_factor (float, default: 1.0) – Compound retardation factor, by default 1.0.

Return type:

tuple[NDArray[floating], NDArray[int_], NDArray[bool], NDArray[bool]]

Returns:

  • band_vals (numpy.ndarray) – Banded weights of shape (n_cout, full_band). Slot band_vals[k, b] is the weight on cin bin col_start[k] + b. Row k sums to r_k = residence_time_k / (retardation_factor * porosity * thickness); invalid rows (NaN residence time, zero-flow cout bins) are zero.

  • col_start (numpy.ndarray of int) – First cin bin index of each cout row’s band, shape (n_cout,).

  • row_valid (numpy.ndarray of bool) – True for cout bins whose residence-time window is fully defined and carries flow (the finite, nonzero rows), shape (n_cout,).

  • spinup_row (numpy.ndarray of bool) – True for cout bins whose residence time is undefined (spin-up period), shape (n_cout,). These rows carry an all-zero band; the forward path returns NaN for these bins (distinct from zero-flow cout bins, which return 0).

See also

gwtransport.advection_utils._densify_weights : Reconstruct the dense matrix.

gwtransport.deposition.deposition_to_extraction(*, dep, flow, tedges, cout_tedges, aquifer_pore_volume, porosity, thickness, retardation_factor=1.0, spinup='constant')[source]#

Compute concentrations from deposition rates (convolution).

Parameters:
  • dep (ArrayLike) – Deposition rates [g/m²/day]. Length must equal len(tedges) - 1.

  • flow (ArrayLike) – Flow rates in aquifer [m³/day]. Length must equal len(tedges) - 1. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • tedges (DatetimeIndex | ndarray) – Time bin edges for deposition and flow data.

  • cout_tedges (DatetimeIndex | ndarray) – Time bin edges for output concentration data.

  • aquifer_pore_volume (float) – Aquifer pore volume [m³].

  • porosity (float) – Aquifer porosity [dimensionless].

  • thickness (float) – Aquifer thickness [m].

  • retardation_factor (float, default: 1.0) – Compound retardation factor, by default 1.0.

  • spinup (str | None, default: 'constant') – Spin-up policy applied before computing deposition weights. Default "constant" shifts tedges[0] backward by at least retardation_factor * aquifer_pore_volume / flow[0] (rounded up to whole bins, plus one extra bin) and treats dep and flow as constant at their first observed values over the prepended interval. When the warm-start is undefined – flow[0] <= 0, zero/NaN pore volume, a non-positive first bin width, or a pad exceeding the memory cap – "constant" silently reverts to the strict-validity behavior. None keeps the existing strict-validity behavior (NaN cout rows during spin-up). A float raises NotImplementedError – the fraction-threshold mode is not implemented for deposition (matching the diffusion family).

Returns:

Concentration changes [g/m³] with length len(cout_tedges) - 1.

Zero-extraction-flow cout bins (no water leaves the aquifer over the bin) return 0.0, not NaN. This deliberately differs from advection, which returns NaN for its undefined zero-flow output: the deposition source term is defined even with no water (an areal flux still supplies mass), and a bin that extracts zero volume carries zero mass, so 0.0 is the physically correct value rather than an undefined result. NaN is reserved for spin-up bins whose residence time is not yet resolved.

Cout bins lying entirely outside the flow record (before tedges[0] or after tedges[-1]) also return 0.0: their out-of-record edges clamp to the record boundaries, so they extract zero volume and fall under the same zero-mass convention.

Return type:

NDArray[floating]

Raises:
  • ValueError – If tedges does not have one more element than dep or flow, if input arrays contain NaN values, if flow contains negative values, if retardation_factor is less than 1.0, or if physical parameters are out of valid range (porosity not in (0, 1), non-positive thickness or aquifer pore volume).

  • NotImplementedError – If spinup is anything other than None or "constant" (the fraction-threshold mode is not implemented for deposition).

See also

extraction_to_deposition

Inverse operation (deconvolution)

spinup_duration

Earliest extraction time with a fully resolved deposition history

gwtransport.advection.infiltration_to_extraction

For concentration transport without deposition

Core Transport Equation

Flow-weighted averaging approach

Notes

This is a source term – positive dep raises cout. Sink processes (pathogen attachment, first-order decay, particle filtration) require the opposite sign convention and are not modelled here.

Examples

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.deposition import deposition_to_extraction
>>> dates = pd.date_range("2020-01-01", "2020-01-10", freq="D")
>>> tedges = pd.date_range("2019-12-31 12:00", "2020-01-10 12:00", freq="D")
>>> cout_tedges = pd.date_range("2020-01-03 12:00", "2020-01-12 12:00", freq="D")
>>> dep = np.ones(len(dates))
>>> flow = np.full(len(dates), 100.0)
>>> cout = deposition_to_extraction(
...     dep=dep,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volume=500.0,
...     porosity=0.3,
...     thickness=10.0,
... )
>>> print(f"First finite cout: {cout[np.isfinite(cout)][0]:.4f} g/m³")
First finite cout: 1.6667 g/m³
gwtransport.deposition.extraction_to_deposition(*, cout, flow, tedges, cout_tedges, aquifer_pore_volume, porosity, thickness, retardation_factor=1.0, regularization_strength=1e-10, spinup='constant')[source]#

Compute deposition rates from concentration changes (deconvolution).

Inverts the forward model by solving W @ dep = cout where W is the weight matrix from compute_deposition_weights(). Uses Tikhonov regularization to smoothly blend data fitting with a physically motivated target (transpose-and-normalize of the forward matrix).

Well-determined modes (large singular values relative to sqrt(λ)) are dominated by the data; poorly-determined modes are pulled toward the target.

Parameters:
  • cout (ArrayLike) – Concentration changes in extracted water [g/m³]. Length must equal len(cout_tedges) - 1. May contain NaN values, which will be excluded from the computation along with corresponding rows in the weight matrix. The model assumes this value is constant over each interval [cout_tedges[i], cout_tedges[i+1]).

  • flow (ArrayLike) – Flow rates in aquifer [m³/day]. Length must equal len(tedges) - 1. Must not contain NaN values. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • tedges (DatetimeIndex | ndarray) – Time bin edges for deposition and flow data. Length must equal len(flow) + 1.

  • cout_tedges (DatetimeIndex | ndarray) – Time bin edges for output concentration data. Length must equal len(cout) + 1.

  • aquifer_pore_volume (float) – Aquifer pore volume [m³].

  • porosity (float) – Aquifer porosity [dimensionless].

  • thickness (float) – Aquifer thickness [m].

  • retardation_factor (float, default: 1.0) – Compound retardation factor, by default 1.0. Values > 1.0 indicate slower transport due to sorption/interaction.

  • regularization_strength (float, default: 1e-10) –

    Tikhonov regularization parameter λ. Controls the tradeoff between fitting the data (||W dep - cout||²) and staying close to the regularization target (λ ||dep - dep_target||²). The target is the transpose-and-normalize of the forward matrix applied to cout.

    Larger values trust the target more (smoother, more biased); smaller values trust the data more (noisier, less biased). Default is 1e-10.

  • spinup (str | None, default: 'constant') – Spin-up policy applied before building the forward weight matrix. Default "constant" shifts tedges[0] backward by at least retardation_factor * aquifer_pore_volume / flow[0] (rounded up to whole bins, plus one extra bin) and treats flow as constant at its first value over the prepended interval; the recovered deposition vector is sliced back to the original tedges length so the public output shape is unchanged. When the warm-start is undefined (flow[0] <= 0, zero/NaN pore volume, non-positive first bin width, or a pad exceeding the memory cap), "constant" silently reverts to strict-validity behavior. None keeps strict-validity behavior. A float raises NotImplementedError – the fraction-threshold mode is not implemented for deposition (matching the diffusion family).

Returns:

Mean deposition rates [g/m²/day] between tedges. Length equals len(tedges) - 1.

Return type:

NDArray[floating]

Raises:
  • ValueError – If input dimensions are incompatible, if flow contains NaN or negative values, if retardation_factor is less than 1.0, or if physical parameters are out of valid range (porosity not in (0, 1), non-positive thickness or aquifer pore volume).

  • NotImplementedError – If spinup is anything other than None or "constant" (the fraction-threshold mode is not implemented for deposition).

See also

deposition_to_extraction

Forward operation (convolution)

extraction_to_deposition_full

Full solver with nullspace options

spinup_duration

Earliest extraction time with a fully resolved deposition history

gwtransport.advection.extraction_to_infiltration

For concentration transport without deposition

gwtransport.utils.solve_inverse_transport_banded

Banded Tikhonov solver used for inversion

Core Transport Equation

Flow-weighted averaging approach

Notes

This is a source term – positive dep raises cout. Sink processes (pathogen attachment, first-order decay, particle filtration) require the opposite sign convention and are not modelled here.

The forward model is W @ dep = cout, where the weight matrix W encodes the physical relationship between deposition rates and concentrations. W is genuinely banded – row i is nonzero only on the cin bins inside its residence-time window – and is built in a compact banded layout (peak memory O(n_cin * band)); the Tikhonov solve (gwtransport.utils.solve_inverse_transport_banded()) transiently materializes the dense matrix and its Gram product (O(n_cout * n_cin + n_cin**2)). Unlike advection (where rows sum to ~1), deposition rows sum to r_i = residence_time_i / (retardation_factor * porosity * thickness). Rows are rescaled by r_i before solving: when W has full column rank and cout lies in its column space this preserves the exact dep (with the default square spinup="constant" system the warm-start padding makes W structurally rank-deficient, so even a forward-generated cout is recovered only up to the nullspace, regardless of regularization_strength), while for overdetermined systems with noise it is equivalent to weighted least squares with weights 1 / r_i^2 (shorter residence times get more weight; under constant flow all r_i are equal and this reduces to OLS). The rescaling puts the regularization target (transpose-and-normalize of W applied to cout) on the same scale as dep, which controls the regularization scale. Rows where the residence time cannot be computed (spin-up period) and zero-flow cout bins are excluded automatically; NaN values in cout are also excluded. The banded Tikhonov solve stays well-defined via regularization_strength even when W is rank-deficient (constant flow with integer RT/dt makes it a uniform moving average with exact transfer-function zeros), so no rank-deficiency warning is emitted.

Examples

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.deposition import extraction_to_deposition
>>>
>>> dates = pd.date_range("2020-01-01", "2020-01-10", freq="D")
>>> tedges = pd.date_range("2019-12-31 12:00", "2020-01-10 12:00", freq="D")
>>> cout_tedges = pd.date_range("2020-01-03 12:00", "2020-01-12 12:00", freq="D")
>>>
>>> flow = np.full(len(dates), 100.0)  # m³/day
>>> cout = np.ones(len(cout_tedges) - 1) * 10.0  # g/m³
>>>
>>> dep = extraction_to_deposition(
...     cout=cout,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volume=500.0,
...     porosity=0.3,
...     thickness=10.0,
... )
>>> print(f"Deposition rates shape: {dep.shape}")
Deposition rates shape: (10,)
>>> print(f"Mean deposition rate: {np.nanmean(dep):.2f} g/m²/day")
Mean deposition rate: 6.00 g/m²/day
gwtransport.deposition.extraction_to_deposition_full(*, cout, flow, tedges, cout_tedges, aquifer_pore_volume, porosity, thickness, retardation_factor=1.0, nullspace_objective='squared_differences', optimization_method='BFGS', rcond=None, spinup='constant')[source]#

Compute deposition rates from concentration changes using nullspace solver.

Full-featured inverse solver exposing all options of solve_underdetermined_system(). For most use cases, prefer extraction_to_deposition() which uses Tikhonov regularization.

Parameters:
  • cout (ArrayLike) – Concentration changes in extracted water [g/m³]. Length must equal len(cout_tedges) - 1. May contain NaN values, which will be excluded from the computation along with corresponding rows in the weight matrix.

  • flow (ArrayLike) – Flow rates in aquifer [m³/day]. Length must equal len(tedges) - 1. Must not contain NaN values.

  • tedges (DatetimeIndex | ndarray) – Time bin edges for deposition and flow data. Length must equal len(flow) + 1.

  • cout_tedges (DatetimeIndex | ndarray) – Time bin edges for output concentration data. Length must equal len(cout) + 1.

  • aquifer_pore_volume (float) – Aquifer pore volume [m³].

  • porosity (float) – Aquifer porosity [dimensionless].

  • thickness (float) – Aquifer thickness [m].

  • retardation_factor (float, default: 1.0) – Compound retardation factor, by default 1.0.

  • nullspace_objective (str | Callable, default: 'squared_differences') –

    Objective function to minimize in the nullspace. Options:

    • "squared_differences" : Minimize sum of squared differences between adjacent deposition rates (default, smooth solutions).

    • "summed_differences" : Minimize sum of absolute differences (sparse/piecewise constant solutions).

    • callable : Custom objective f(coeffs, x_ls, nullspace_basis).

  • optimization_method (str, default: 'BFGS') – Scipy optimization method. Default is "BFGS".

  • rcond (float | None, default: None) – Cutoff for small singular values in the least-squares step. Default is None (uses numpy default).

  • spinup (str | None, default: 'constant') – Spin-up policy applied before building the forward weight matrix. Default "constant" shifts tedges[0] backward by at least retardation_factor * aquifer_pore_volume / flow[0] (rounded up to whole bins, plus one extra bin), silently reverting to strict-validity when the warm-start is undefined; the recovered deposition is sliced back to the original tedges length. None keeps strict-validity behavior. A float raises NotImplementedError – the fraction-threshold mode is not implemented for deposition (matching the diffusion family). See extraction_to_deposition() for full semantics.

Returns:

Mean deposition rates [g/m²/day] between tedges. Length equals len(tedges) - 1.

Return type:

NDArray[floating]

Raises:
  • ValueError – If cout_tedges does not have one more element than cout, if tedges does not have one more element than flow, if flow contains NaN or negative values, if retardation_factor is less than 1.0, or if physical parameters are out of valid range (porosity not in (0, 1), non-positive thickness or aquifer pore volume).

  • NotImplementedError – If spinup is anything other than None or "constant" (the fraction-threshold mode is not implemented for deposition).

See also

extraction_to_deposition

Recommended solver using Tikhonov regularization.

spinup_duration

Earliest extraction time with a fully resolved deposition history.

gwtransport.utils.solve_underdetermined_system

Underlying solver.

Notes

This is a source term – positive dep raises cout. Sink processes (pathogen attachment, first-order decay, particle filtration) require the opposite sign convention and are not modelled here.

gwtransport.deposition.spinup_duration(*, flow, tedges, aquifer_pore_volume, retardation_factor=1.0)[source]#

Compute the spinup duration for deposition modeling.

The spinup duration is the smallest extraction time t* (relative to tedges[0]) at which the extracted water was infiltrated exactly at tedges[0]: equivalently, the time at which the cumulative flow first reaches retardation_factor * aquifer_pore_volume. For extraction times earlier than t* the extracted concentration lacks complete deposition history. Under constant flow this equals aquifer_pore_volume * retardation_factor / flow.

Parameters:
  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day].

  • tedges (DatetimeIndex) – Time edges for the flow data.

  • aquifer_pore_volume (float) – Pore volume of the aquifer [m³].

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer [dimensionless], by default 1.0.

Returns:

Spinup duration in days.

Return type:

float

Raises:

ValueError – If the cumulative flow over the entire tedges window does not reach retardation_factor * aquifer_pore_volume, indicating the flow timeseries is too short to characterise the spin-up duration.

See also

deposition_to_extraction

Forward solver that uses the spin-up duration to resolve NaN cout rows.

extraction_to_deposition

Inverse solver.

diffusion_fast#

Fast closed-form 1D advection-dispersion transport (Kreft-Zuber flux concentration).

This module shares the conceptual model of gwtransport.diffusion – advection with microdispersion (alpha_L) and molecular diffusion (D_m) along orthogonal (Cartesian) flow paths, one independent streamtube per aquifer pore volume, with the spread across the pore volume distribution providing macrodispersion and linear sorption entering through the retardation factor. It reports the same Kreft-Zuber (1978) flux concentration C_F at the outlet of the streamtube bundle, but evaluates the bin-averaged breakthrough in closed form instead of by Gauss-Legendre quadrature.

For each streamtube (one aquifer pore volume) the resident concentration in moving-frame cumulative-volume (V) coordinates is the Gaussian CDF C_R = 0.5 * erfc((L - xi) / (2 * sqrt(D_t))), with D_t = D_m * tau + alpha_L * xi the moving-frame dispersion product. Its bin-average over a cout bin has the closed-form antiderivative I(x) = 0.5*x + 0.5*[x*erf(x/s) + (s/sqrt(pi))*exp(-(x/s)^2)], s = 2*sqrt(D_t). Evaluating I once per cout edge with D_t carried per edge and differencing yields the flux concentration C_F directly – not merely C_R – because dD_t/dx = D_m/v_s + alpha_L = D_s/v_s is exactly the Kreft-Zuber flux coefficient at the solute-front velocity v_s = Q*L/(R*V_pore) (using d(tau)/dx = 1/v_s = R*V_pore/(L*Q)). The dispersive boundary-flux correction therefore emerges from the D_t variation across the bin; no explicit correction term is added.

The elapsed time tau and travel distance xi are read directly from the time and cumulative-volume edges (tau_ij = t_cout_i - t_cin_j, xi geometric), so no per-cell quadrature and no residence-time inversion is needed. The coefficient matrix is built only on the breakthrough band – the cumulative-volume band where the bin-averaged C_F is unsaturated, the only region with non-zero coefficients – so the build cost scales with the band width (a few percent of the matrix at realistic dispersion) rather than with the full grid.

Streamtube assumption (no cross-sectional area parameter)#

Each entry in aquifer_pore_volumes is an independent 1D streamtube; molecular diffusion enters the V-space variance through D_m * tau and microdispersion through alpha_L * xi. streamline_length / molecular_diffusivity / longitudinal_dispersivity may be a scalar (shared by all streamtubes) or an array with one value per pore volume, exactly as in gwtransport.diffusion.

Choosing among the three diffusion modules#

Whenever the cout grid is at or finer than the flow grid, this module reproduces gwtransport.diffusion to machine precision for every parameter regime – including retardation_factor != 1 with molecular_diffusivity > 0, where the antiderivative’s slope dD_t/dx = D_s/v_s already carries the solute-front Kreft-Zuber flux coefficient natively – while being ~80-90x faster even before banding. So it is the right default. The only case that favours gwtransport.diffusion is a cout grid coarser than the flow detail: this module treats flow_out as constant within each cout bin, whereas gwtransport.diffusion integrates the full tedges-resolution flow within each cout bin – a ~0.1%-of-peak difference for a rapidly-varying cin over wide cout bins under variable flow.

The third rung is gwtransport.diffusion_fast_fast, which is approximate by design: it folds molecular diffusion into an effective dispersivity per streamtube and evaluates one banded breakthrough on the native cumulative-volume grid, so it is faster still. It agrees with this module to ~1e-6 (smooth inputs) to ~1e-4 (sharp inputs) at constant flow and degrades in the molecular-diffusion-dominated corner (alpha_L ~ 0) under strongly variable flow; this module is the one to use whenever machine precision against gwtransport.diffusion is required.

Available functions:

  • infiltration_to_extraction() - Forward transport from an explicit aquifer_pore_volumes distribution: returns the bin-averaged Kreft-Zuber flux concentration on cout_tedges, evaluated from the closed-form antiderivative on the breakthrough band only and averaged with equal weight over the streamtubes. streamline_length, molecular_diffusivity and longitudinal_dispersivity are either scalars shared by all streamtubes or one value per pore volume. flow_out (extraction flow on the cout_tedges grid) is required whenever cout_tedges differs from tedges: it sets the cout-bin volumes and the outlet velocity. Output bins without complete breakthrough information are NaN.

  • extraction_to_infiltration() - Reverse direction: assembles the same banded forward operator and deconvolves it with banded Tikhonov regularization (banded Cholesky on the normal equations), returning the bin-averaged infiltration concentration on tedges. NaN entries in cout mark measurement gaps and are excluded from the solve; spin-up and unconstrained cin bins come back NaN.

  • gamma_infiltration_to_extraction() - infiltration_to_extraction() with the pore volume distribution given as a (shifted) gamma – either (mean, std) or (alpha, beta), plus loc – discretized into n_bins equal-probability streamtubes that share one streamline_length and one pair of dispersion parameters.

  • gamma_extraction_to_infiltration() - extraction_to_infiltration() with the same gamma parameterization of the pore volume distribution: reconstructs cin on tedges from cout.

References

Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its solutions for different initial and boundary conditions. Chemical Engineering Science, 33(11), 1471-1480.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.diffusion_fast.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, flow_out=None, spinup='constant')[source]#

Compute extracted concentration with advection, microdispersion, and molecular diffusion.

Fast closed-form counterpart of gwtransport.diffusion.infiltration_to_extraction(). Reports the Kreft-Zuber (1978) flux concentration C_F and reproduces the slow module to machine precision when the cout grid aligns with the flow grid (supply flow_out).

Parameters:
  • cin (ArrayLike) – Concentration of the compound in the infiltrating water. Length len(tedges) - 1.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length len(tedges) - 1.

  • tedges (DatetimeIndex) – Time edges for cin and flow data. Length len(cin) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for output data bins. Length len(output) + 1.

  • aquifer_pore_volumes (ArrayLike) – Aquifer pore volumes [m³] – one independent streamtube per entry.

  • streamline_length (NDArray[floating] | float) – Travel distance L [m]: a scalar (shared by all streamtubes) or an array with one value per aquifer pore volume. Must be positive.

  • molecular_diffusivity (NDArray[floating] | float) – Effective (retarded-frame) molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.

  • longitudinal_dispersivity (NDArray[floating] | float) – Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0). Values > 1.0 indicate slower transport.

  • flow_out (ArrayLike | None, default: None) – Extraction flow rate [m³/day] on the output grid (aligned to cout_tedges, length len(cout_tedges) - 1); constant within each cout bin, like flow is within each tedges bin. It defines the cout-bin volumes and the outlet velocity. Required when ``cout_tedges`` differs from ``tedges``; may be omitted only when cout_tedges equals tedges (then it equals flow). Default None.

  • spinup (str | None, default: 'constant') – "constant" (default) extends tedges by 100 years on each side so a constant warm-start fills the left-edge spin-up region; None leaves spin-up cout as NaN.

Returns:

Bin-averaged Kreft-Zuber flux concentration C_F in the extracted water. Length len(cout_tedges) - 1. NaN where no infiltration data has broken through.

Return type:

NDArray[floating]

See also

gwtransport.diffusion.infiltration_to_extraction

Quadrature reference; prefer for cout grids coarser than the flow detail.

extraction_to_infiltration

Inverse operation.

Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity

Macrodispersion vs microdispersion.

gwtransport.diffusion_fast.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, flow_out=None, spinup='constant')[source]#

Reconstruct infiltration concentration from extracted water (deconvolution).

Inverts the forward model by building the same closed-form flux-concentration matrix as infiltration_to_extraction() and solving W @ cin = cout via Tikhonov regularization. Fast closed-form counterpart of gwtransport.diffusion.extraction_to_infiltration().

Parameters:
  • cout (ArrayLike) – Concentration of the compound in extracted water. Length len(cout_tedges) - 1.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length len(tedges) - 1.

  • tedges (DatetimeIndex) – Time edges for cin (output) and flow data. Length len(flow) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for cout data bins. Length len(cout) + 1.

  • aquifer_pore_volumes (ArrayLike) – Aquifer pore volumes [m³] – one independent streamtube per entry.

  • streamline_length (NDArray[floating] | float) – Travel distance L [m]: a scalar (shared by all streamtubes) or an array with one value per aquifer pore volume. Must be positive.

  • molecular_diffusivity (NDArray[floating] | float) – Effective (retarded-frame) molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.

  • longitudinal_dispersivity (NDArray[floating] | float) – Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0).

  • regularization_strength (float, default: 1e-10) – Tikhonov regularization parameter (default 1e-10).

  • flow_out (ArrayLike | None, default: None) – Extraction flow rate [m³/day] on the output grid (aligned to cout_tedges). See infiltration_to_extraction(). Default None.

  • spinup (str | None, default: 'constant') – See infiltration_to_extraction(). Default "constant".

Returns:

Bin-averaged concentration in the infiltrating water. Length len(tedges) - 1. NaN where no extraction data constrains the bin.

Return type:

NDArray[floating]

gwtransport.diffusion_fast.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, flow_out=None, spinup='constant')[source]#

Compute extracted concentration for a gamma-distributed pore volume distribution.

Convenience wrapper around infiltration_to_extraction() that discretizes a (shifted) gamma aquifer pore-volume distribution into n_bins equal-probability streamtubes. Provide either (mean, std) or (alpha, beta); loc defaults to 0.

Parameters:
  • cin (ArrayLike) – Concentration of the compound in infiltrating water.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day].

  • tedges (DatetimeIndex) – Time edges for cin and flow data. Length len(cin) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for output data bins.

  • mean (float | None, default: None) – Mean and standard deviation of the gamma pore-volume distribution [m³].

  • std (float | None, default: None) – Mean and standard deviation of the gamma pore-volume distribution [m³].

  • loc (float, default: 0.0) – Location (minimum pore volume) [m³], 0 <= loc < mean. Default 0.0.

  • alpha (float | None, default: None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).

  • beta (float | None, default: None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).

  • n_bins (int, default: 100) – Number of equal-probability streamtubes. Default 100.

  • streamline_length (float) – Travel distance L [m], applied to all gamma streamtubes. Must be positive.

  • molecular_diffusivity (float) – Effective (retarded-frame) molecular diffusivity D_m [m²/day], applied to all streamtubes. Must be non-negative.

  • longitudinal_dispersivity (float) – Longitudinal dispersivity alpha_L [m] (microdispersion), applied to all streamtubes. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0).

  • flow_out (ArrayLike | None, default: None) – Extraction flow rate [m³/day] on the output grid. See infiltration_to_extraction(). Default None.

  • spinup (str | None, default: 'constant') – See infiltration_to_extraction(). Default "constant".

Returns:

Bin-averaged Kreft-Zuber flux concentration C_F in the extracted water. Length len(cout_tedges) - 1.

Return type:

NDArray[floating]

See also

infiltration_to_extraction

Transport with an explicit pore volume distribution.

gamma_extraction_to_infiltration

Reverse operation.

gwtransport.gamma.bins

Create gamma distribution bins.

Gamma Distribution Model

Two-parameter pore volume model.

gwtransport.diffusion_fast.gamma_extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, flow_out=None, spinup='constant')[source]#

Reconstruct infiltration concentration for a gamma-distributed pore volume distribution.

Convenience wrapper around extraction_to_infiltration() that discretizes a (shifted) gamma aquifer pore-volume distribution into n_bins equal-probability streamtubes. Provide either (mean, std) or (alpha, beta); loc defaults to 0.

Parameters:
  • cout (ArrayLike) – Concentration of the compound in extracted water.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day].

  • tedges (DatetimeIndex) – Time edges for cin (output) and flow data. Length len(flow) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for cout data bins. Length len(cout) + 1.

  • mean (float | None, default: None) – Mean and standard deviation of the gamma pore-volume distribution [m³].

  • std (float | None, default: None) – Mean and standard deviation of the gamma pore-volume distribution [m³].

  • loc (float, default: 0.0) – Location (minimum pore volume) [m³], 0 <= loc < mean. Default 0.0.

  • alpha (float | None, default: None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).

  • beta (float | None, default: None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).

  • n_bins (int, default: 100) – Number of equal-probability streamtubes. Default 100.

  • streamline_length (float) – Travel distance L [m], applied to all gamma streamtubes. Must be positive.

  • molecular_diffusivity (float) – Effective (retarded-frame) molecular diffusivity D_m [m²/day], applied to all streamtubes. Must be non-negative.

  • longitudinal_dispersivity (float) – Longitudinal dispersivity alpha_L [m] (microdispersion), applied to all streamtubes. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0).

  • regularization_strength (float, default: 1e-10) – Tikhonov regularization parameter (default 1e-10).

  • flow_out (ArrayLike | None, default: None) – Extraction flow rate [m³/day] on the output grid. See infiltration_to_extraction(). Default None.

  • spinup (str | None, default: 'constant') – See infiltration_to_extraction(). Default "constant".

Returns:

Bin-averaged concentration in the infiltrating water. Length len(tedges) - 1.

Return type:

NDArray[floating]

See also

extraction_to_infiltration

Deconvolution with an explicit pore volume distribution.

gamma_infiltration_to_extraction

Forward operation.

gwtransport.gamma.bins

Create gamma distribution bins.

Gamma Distribution Model

Two-parameter pore volume model.

diffusion_fast_fast#

Fast approximate 1D advection-dispersion transport (Kreft-Zuber flux concentration).

This module shares the conceptual model of gwtransport.diffusion and gwtransport.diffusion_fast – advection with microdispersion (alpha_L) and molecular diffusion (D_m) along orthogonal (Cartesian) flow paths, one independent streamtube per aquifer pore volume, the spread across the pore volume distribution providing macrodispersion, and linear sorption via the retardation factor. It targets the bin-averaged Kreft-Zuber (1978) flux concentration C_F on the streamtube bundle, but trades exactness for a single fast (~1.5 ms) native-grid evaluation that does not depend on the flow being constant. It is approximate – see Accuracy below for the error budget and when to reach for gwtransport.diffusion_fast instead.

The three modules form an accuracy/speed ladder: gwtransport.diffusion is the dense reference implementation and the ground-truth oracle (composite Gauss-Legendre quadrature, slowest), gwtransport.diffusion_fast is its banded closed-form equivalent (agrees to machine precision, ~80-90x faster), and this module is the approximate rung – fastest, and exact only up to the error budget below.

How it works – one skewed breakthrough on the native volume grid#

The moving-frame dispersion product D_t = D_m*tau + alpha_L*xi mixes a time term (molecular diffusion D_m*tau) and a volume term (microdispersion alpha_L*xi). Under constant flow the two coincide: elapsed time and breakthrough coordinate are locked, tau = r_vpv*xi/(L*Q) (r_vpv = R*V_pore), so D_m*tau = (D_m*r_vpv/(L*Q))*xi – the same xi-proportional form as alpha_L*xi. Molecular diffusion is therefore an effective dispersivity alpha_eff = alpha_L + D_m*r_vpv/(L*q_mean) per streamtube (q_mean the record-mean flow), and the whole method is a single skewed D_t = alpha_eff*xi Kreft-Zuber breakthrough applied banded on the native cumulative-volume grid: the whole aquifer pore volume distribution (APVD) is pre-summed into one 1D antiderivative Ibar(dV) – exact for any APVD shape – finely sampled once and read back by interpolation.

tedges need not be regularly spaced and cout_tedges need not equal tedges (supply flow_out when they differ): the build runs on the native cumulative-volume grid for any spacing. The only approximations are the Ibar interpolation (~1e-4, below) and – under variable flow – freezing q_mean at the record mean: the tau = r_vpv*xi/(L*Q) map holds exactly only at constant flow, so the microdispersion part stays exact but the molecular part picks up a commutator residual.

Accuracy (vs gwtransport.diffusion_fast)#

  • Constant flow: exact to the Ibar interpolation floor – ~1e-6 for smooth inputs, degrading to ~1e-4 for sharp inputs at alpha_L = 0 (the kink limit; ~1e-3 for a single large pore volume). This holds across D_m and R at realistic Peclet numbers Pe = L/alpha_eff >> 1, including heat transport (R > 1, large D_m): the effective-dispersivity fold reproduces the skewed molecular breakthrough exactly, not merely its second moment. Only in the extreme low-Peclet corner (alpha_eff approaching L – a short streamline with very strong molecular diffusion, Pe <~ 2) does the fold’s wide dispersive band exceed the fine-grid sample cap (_MAX_KERNEL_SAMPLES), coarsening the breakthrough shape; use gwtransport.diffusion_fast there.

  • Variable flow: the microdispersion part stays exact; the molecular part carries the frozen- q_mean commutator residual. It is small when molecular diffusion is sub-dominant – the typical alpha_L > 0 regime, <~1e-3 for realistic solute D_m even through strong flow – and grows in the molecular-diffusion-dominated corner (alpha_L ~ 0) with sharp inputs under strongly variable or seasonal flow, reaching the multi-percent range where no fast approximation is reliable. Use gwtransport.diffusion_fast there (in particular for heat transport under strongly variable flow).

The inverse (extraction_to_infiltration()) deconvolves the same approximate operator W the forward applies. It assembles W directly in banded form (one Ibar gather – no per-pore-volume closed-form loop, no dense (n_cout, n_cin) matrix) and solves it with banded Tikhonov regularisation (banded Cholesky, O(n * band**2)), so it is much faster than gwtransport.diffusion_fast’s reverse, especially for many streamtubes. Inverting exactly the forward operator makes a round trip self-consistent (recovering the input up to the deconvolution conditioning); on real extraction data the reverse carries the forward’s error budget above, amplified by the deconvolution conditioning.

Available functions:

  • infiltration_to_extraction() - Forward transport from an explicit aquifer_pore_volumes distribution: returns the approximate bin-averaged Kreft-Zuber flux concentration on cout_tedges, from one banded breakthrough on the native cumulative-volume grid with molecular diffusion folded into the effective dispersivity alpha_eff = alpha_L + D_m * R * V_pore / (L * q_mean) per streamtube. streamline_length, molecular_diffusivity and longitudinal_dispersivity are either scalars shared by all streamtubes or one value per pore volume. flow_out (extraction flow on the cout_tedges grid) is required whenever cout_tedges differs from tedges. Output bins without complete breakthrough information are NaN.

  • extraction_to_infiltration() - Reverse direction: assembles the same approximate banded operator and deconvolves it with banded Tikhonov regularization (banded Cholesky on the normal equations, O(n * band**2)), returning the bin-averaged infiltration concentration on tedges. NaN entries in cout mark measurement gaps and are excluded from the solve; spin-up and unconstrained cin bins come back NaN.

  • gamma_infiltration_to_extraction() - infiltration_to_extraction() with the pore volume distribution given as a (shifted) gamma – either (mean, std) or (alpha, beta), plus loc – discretized into n_bins equal-probability streamtubes that share one streamline_length and one pair of dispersion parameters.

  • gamma_extraction_to_infiltration() - extraction_to_infiltration() with the same gamma parameterization of the pore volume distribution: reconstructs cin on tedges from cout.

References

Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its solutions for different initial and boundary conditions. Chemical Engineering Science, 33(11), 1471-1480.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.diffusion_fast_fast.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, flow_out=None, spinup='constant')[source]#

Compute extracted concentration with advection, microdispersion, and molecular diffusion (approximate).

Fast approximate counterpart of gwtransport.diffusion_fast.infiltration_to_extraction(). Advection + macrodispersion + microdispersion (alpha_L) and molecular diffusion (D_m, folded in as an effective dispersivity alpha_L + D_m*r_vpv/(L*q_mean) per streamtube) form a single exact skewed Kreft-Zuber breakthrough on the native cumulative-volume grid. At constant flow the result reproduces gwtransport.diffusion_fast.infiltration_to_extraction() to the Ibar interpolation floor (~1e-6 smooth, ~1e-4 sharp) at realistic Peclet numbers (Pe = L/alpha_eff >> 1), including heat (R > 1, large D_m); only the extreme low-Peclet corner (Pe <~ 2) loses breakthrough-shape accuracy to the fine-grid sample cap. Under variable flow the molecular part carries a commutator residual from the frozen q_mean: small when molecular diffusion is sub-dominant (alpha_L > 0, <~1e-3 for realistic solute D_m), growing to the multi-percent range for sharp inputs in the molecular-diffusion-dominated corner (alpha_L ~ 0) under strongly variable flow. For machine precision – or that corner – use gwtransport.diffusion_fast.

Parameters:
  • cin (ArrayLike) – Concentration of the compound in the infiltrating water. Length len(tedges) - 1.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length len(tedges) - 1.

  • tedges (DatetimeIndex) – Time edges for cin and flow data. Length len(cin) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for output data bins. Length len(output) + 1.

  • aquifer_pore_volumes (ArrayLike) – Aquifer pore volumes [m³] – one independent streamtube per entry. Any distribution shape (the APVD is pre-summed exactly).

  • streamline_length (NDArray[floating] | float) – Travel distance L [m]: a scalar (shared by all streamtubes) or an array with one value per aquifer pore volume. Must be positive.

  • molecular_diffusivity (NDArray[floating] | float) – Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.

  • longitudinal_dispersivity (NDArray[floating] | float) – Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0). Values > 1.0 indicate slower transport.

  • flow_out (ArrayLike | None, default: None) – Extraction flow rate [m³/day] on the output grid (aligned to cout_tedges, length len(cout_tedges) - 1). Required when cout_tedges differs from tedges; may be omitted only when cout_tedges equals tedges. Default None.

  • spinup (str | None, default: 'constant') – "constant" (default) extends tedges by 100 years on each side so a constant warm-start fills the left-edge spin-up region; None leaves spin-up cout as NaN.

Returns:

Bin-averaged Kreft-Zuber flux concentration C_F in the extracted water. Length len(cout_tedges) - 1. NaN where no infiltration data has broken through.

Return type:

NDArray[floating]

See also

gwtransport.diffusion_fast.infiltration_to_extraction

Exact (machine-precision) counterpart; use it when approximation is unacceptable, especially in the molecular-dominant regime.

gwtransport.diffusion.infiltration_to_extraction

Quadrature reference.

extraction_to_infiltration

Inverse operation (deconvolves this same operator).

Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity

Macrodispersion vs microdispersion.

gwtransport.diffusion_fast_fast.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, flow_out=None, spinup='constant')[source]#

Reconstruct infiltration concentration from extracted water (fast approximate deconvolution).

Inverts the same approximate operator W the forward applies: it assembles the banded breakthrough operator W (advection + macro + micro + molecular, molecular folded in as an effective dispersivity) directly in banded form and deconvolves it with banded Tikhonov regularization (_solve_reverse_banded – banded Cholesky on the normal equations, O(n * band**2)). It builds W from one Ibar gather – no per-pore-volume closed-form loop and no dense (n_cout, n_cin) matrix – so it is much faster than gwtransport.diffusion_fast.extraction_to_infiltration() (which evaluates the exact breakthrough per streamtube), especially for many streamtubes. Because the deconvolved operator is exactly the forward operator, a forward-then-inverse round trip recovers cin up to the deconvolution conditioning and regularization. On real extraction data the reverse is as accurate as the forward: at constant flow it matches gwtransport.diffusion_fast.extraction_to_infiltration() to ~1e-6, while the molecular-diffusion-dominated corner under strongly variable flow amplifies the forward’s commutator residual (use gwtransport.diffusion_fast there).

Parameters:
  • cout (ArrayLike) – Concentration of the compound in extracted water. Length len(cout_tedges) - 1.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length len(tedges) - 1.

  • tedges (DatetimeIndex) – Time edges for cin (output) and flow data. Length len(flow) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for cout data bins. Length len(cout) + 1.

  • aquifer_pore_volumes (ArrayLike) – Aquifer pore volumes [m³] – one independent streamtube per entry.

  • streamline_length (NDArray[floating] | float) – Travel distance L [m]: scalar or one value per pore volume. Must be positive.

  • molecular_diffusivity (NDArray[floating] | float) – Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.

  • longitudinal_dispersivity (NDArray[floating] | float) – Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0).

  • regularization_strength (float, default: 1e-10) – Tikhonov regularization parameter (default 1e-10). Must be strictly positive: the banded solver relies on it to make the normal equations positive-definite (it cannot return the dense lambda = 0 minimum-norm solution).

  • flow_out (ArrayLike | None, default: None) – Extraction flow rate [m³/day] on the output grid (aligned to cout_tedges). See infiltration_to_extraction(). Default None.

  • spinup (str | None, default: 'constant') – See infiltration_to_extraction(). Default "constant".

Returns:

Bin-averaged concentration in the infiltrating water. Length len(tedges) - 1. NaN where no extraction data constrains the bin.

Return type:

NDArray[floating]

See also

infiltration_to_extraction

Forward operation (the operator inverted here).

gwtransport.diffusion_fast.extraction_to_infiltration

Exact (machine-precision) counterpart; use it when the approximation is unacceptable.

Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity

Macrodispersion vs microdispersion.

gwtransport.diffusion_fast_fast.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, flow_out=None, spinup='constant')[source]#

Compute extracted concentration for a gamma-distributed pore volume distribution (approximate).

Convenience wrapper around infiltration_to_extraction() that discretizes a (shifted) gamma aquifer pore-volume distribution into n_bins equal-probability streamtubes. Provide either (mean, std) or (alpha, beta); loc defaults to 0. Approximate – see infiltration_to_extraction().

Parameters:
  • cin (ArrayLike) – Concentration of the compound in infiltrating water.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day].

  • tedges (DatetimeIndex) – Time edges for cin and flow data. Length len(cin) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for output data bins. Length len(result) + 1.

  • mean (float | None, default: None) – Mean and standard deviation of the gamma pore-volume distribution.

  • std (float | None, default: None) – Mean and standard deviation of the gamma pore-volume distribution.

  • loc (float, default: 0.0) – Location (minimum pore volume), 0 <= loc < mean. Default 0.0.

  • alpha (float | None, default: None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).

  • beta (float | None, default: None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).

  • n_bins (int, default: 100) – Number of equal-probability streamtubes. Default 100.

  • streamline_length (float) – Travel distance L [m], applied to all gamma streamtubes. Must be positive.

  • molecular_diffusivity (float) – Effective molecular diffusivity D_m [m²/day], applied to all streamtubes. Must be non-negative.

  • longitudinal_dispersivity (float) – Longitudinal dispersivity alpha_L [m] (microdispersion), applied to all streamtubes. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0).

  • flow_out (ArrayLike | None, default: None) – Extraction flow rate [m³/day] on the output grid. See infiltration_to_extraction(). Default None.

  • spinup (str | None, default: 'constant') – See infiltration_to_extraction(). Default "constant".

Returns:

Bin-averaged Kreft-Zuber flux concentration C_F in the extracted water. Length len(cout_tedges) - 1. NaN where no infiltration data has broken through.

Return type:

NDArray[floating]

See also

infiltration_to_extraction

Transport with an explicit pore volume distribution.

gamma_extraction_to_infiltration

Reverse operation.

gwtransport.gamma.bins

Create gamma distribution bins.

Gamma Distribution Model

Two-parameter pore volume model.

gwtransport.diffusion_fast_fast.gamma_extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, flow_out=None, spinup='constant')[source]#

Reconstruct infiltration concentration for a gamma-distributed pore volume distribution.

Convenience wrapper around extraction_to_infiltration() that discretizes a (shifted) gamma aquifer pore-volume distribution into n_bins equal-probability streamtubes. Provide either (mean, std) or (alpha, beta); loc defaults to 0. Fast approximate banded deconvolution (see extraction_to_infiltration()).

Parameters:
  • cout (ArrayLike) – Concentration of the compound in extracted water.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day].

  • tedges (DatetimeIndex) – Time edges for cin (output) and flow data. Length len(flow) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for cout data bins. Length len(cout) + 1.

  • mean (float | None, default: None) – Mean and standard deviation of the gamma pore-volume distribution.

  • std (float | None, default: None) – Mean and standard deviation of the gamma pore-volume distribution.

  • loc (float, default: 0.0) – Location (minimum pore volume), 0 <= loc < mean. Default 0.0.

  • alpha (float | None, default: None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).

  • beta (float | None, default: None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).

  • n_bins (int, default: 100) – Number of equal-probability streamtubes. Default 100.

  • streamline_length (float) – Travel distance L [m], applied to all gamma streamtubes. Must be positive.

  • molecular_diffusivity (float) – Effective molecular diffusivity D_m [m²/day], applied to all streamtubes. Must be non-negative.

  • longitudinal_dispersivity (float) – Longitudinal dispersivity alpha_L [m] (microdispersion), applied to all streamtubes. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0).

  • regularization_strength (float, default: 1e-10) – Tikhonov regularization parameter (default 1e-10).

  • flow_out (ArrayLike | None, default: None) – Extraction flow rate [m³/day] on the output grid. See infiltration_to_extraction(). Default None.

  • spinup (str | None, default: 'constant') – See infiltration_to_extraction(). Default "constant".

Returns:

Bin-averaged concentration in the infiltrating water. Length len(tedges) - 1.

Return type:

NDArray[floating]

See also

extraction_to_infiltration

Deconvolution with an explicit pore volume distribution.

gamma_infiltration_to_extraction

Forward operation.

gwtransport.gamma.bins

Create gamma distribution bins.

Gamma Distribution Model

Two-parameter pore volume model.

diffusion#

Analytical solutions for 1D advection-dispersion transport.

Water infiltrates and is transported in parallel along multiple aquifer pore volumes to extraction. For each aquifer pore volume, transport is 1D advection with microdispersion, molecular diffusion, and linear sorption; the spread across aquifer pore volumes provides macrodispersion. Forward and backward modeling are supported. The flow is assumed orthogonal.

The orthogonal-flow (Cartesian) geometry is what makes the Kreft-Zuber breakthrough the exact 1D solution used below.

Choosing among the three diffusion modules#

This is the reference implementation: it evaluates the bin-averaged Kreft-Zuber flux concentration by resolution-aware composite Gauss-Legendre quadrature (splitting at flow-bin boundaries, with extra front-centred panels wherever a sharp breakthrough front is otherwise under-resolved). Prefer it only when the output grid is coarser than the flow detail – it integrates the full within-bin flow, which the closed-form gwtransport.diffusion_fast approximates as constant per output bin. Otherwise that module computes the same physics to machine precision for every parameter regime (including retardation_factor != 1 with non-zero molecular diffusivity, whose flux correction it also evaluates in closed form) and is ~80-90x faster (no quadrature, no residence-time inversion). Both modules accept per-streamtube streamline_length / molecular_diffusivity / longitudinal_dispersivity arrays (heterogeneous flow paths – partially-penetrating wells, wedge-shaped capture zones).

gwtransport.diffusion_fast_fast is the third rung and is approximate by design: it folds molecular diffusion into an effective dispersivity alpha_eff = alpha_L + D_m * R * V_pore / (L * q_mean) per streamtube, so the whole streamtube bundle collapses to one banded breakthrough on the native cumulative-volume grid – the fastest of the three. At constant flow it reproduces gwtransport.diffusion_fast to ~1e-6 for smooth inputs and ~1e-4 for sharp ones, and it loses accuracy in the molecular-diffusion-dominated corner (alpha_L ~ 0) under strongly variable flow, where the frozen record-mean flow leaves a commutator residual. This module is the ground truth both fast modules are validated against.

Reported outlet concentration: Kreft-Zuber (1978) flux concentration#

The outlet concentration reported by this module is the flux concentration

C_F(L, t) = C_R(L, t) - (D_s / v_s) * dC_R/dx |_{x=L}

with the solute-front (retarded-frame) velocity v_s = Q L / (R V_pore) and the dispersion D_s = D_m + alpha_L * v_s, so the flux coefficient is D_s / v_s = D_m / v_s + alpha_L = R D_m / v_fluid + alpha_L (with the fluid velocity v_fluid = Q L / V_pore). The resident profile C_R solves the retarded ADE with advection v_s and dispersion D_s, so its flux-vs-resident correction must use v_s — not v_fluid; pairing v_s with the moving-frame variance below is what conserves mass for R > 1 with D_m > 0.

— the solute mass flux at the outlet divided by the volumetric fluid flux. This is what is measured when sampling the extracted fluid. The resident concentration C_R is Bear (1972) eq. 10.6.4, the variable-flow moving-frame Ogata-Banks solution

C_R(L, V; t_j) = 0.5 * erfc((L - xi_j(V)) / (2 * sqrt(D_t(V))))

with the dispersion variance accumulated in the moving (Lagrangian) frame:

D_t(V) = sigma^2(V) / 2 = D_m * tau(V) + alpha_L * xi(V)

where:

  • D_m is the effective molecular (or thermal) diffusivity [m²/day]

  • alpha_L is the longitudinal dispersivity [m]

  • tau(V) is the elapsed time since infiltration [day], with V the cumulative extracted volume

  • xi(V) = L (V - V_j) / (R V_pore) is the distance the parcel has actually travelled [m]

The K-Z flux-correction term is what makes the column-sum invariant integral Q c_out dt = integral Q c_in dt hold under arbitrary variable Q. Without it, the leading-order C_R loses O(1/Pe) per column under variable Q + pure D_m.

Implementation: the bin-averaged C_F is computed by resolution-aware composite Gauss-Legendre quadrature in volume space, split at flow-bin boundaries so each sub-interval sees a linear t(V). Within a sub-interval the erf-like front has width sqrt(4*D_t) (in volume units); for near-zero dispersivity this can be orders of magnitude below the flow-bin width, so a single fixed-order rule cannot resolve it. Sub-intervals whose front is under-resolved are therefore tiled with front-centred panels (fine near the front, flat tails outside), which restores the column-mass invariant to ~1e-11 for every dispersion regime; smooth/already-resolved sub-intervals keep the plain single 16-point rule. The variance is evaluated at each quadrature node from the parcel’s own tau and xi histories — never capped at the residence time. The K-Z identity requires Bear’s formula to satisfy the variable-coefficient ADE exactly, which holds only when D_t is allowed to keep growing past breakthrough.

Macrodispersion vs microdispersion#

This module adds microdispersion (alpha_L) and molecular diffusion (D_m) on top of macrodispersion captured by the pore volume distribution (APVD). Both represent velocity heterogeneity at different scales. Microdispersion is an aquifer property; macrodispersion depends additionally on hydrological boundary conditions. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for guidance on when to use each approach and how to avoid double-counting spreading effects.

Streamtube assumption (no cross-sectional area parameter)#

Each entry in aquifer_pore_volumes is treated as an independent 1D streamtube. There is no cross-sectional area parameter: the variance budget uses 2 D_m tau (molecular diffusion in time) and 2 alpha_L xi (microdispersion in travelled distance), with the streamline length L and the pore volume V_pore together fixing the implicit streamtube cross-section A = V_pore / L. Callers who need distributed-area effects must provide multiple streamtubes (via aquifer_pore_volumes or the gamma-parameterised wrappers).

Available functions:

  • infiltration_to_extraction() - Forward transport from an explicit aquifer_pore_volumes distribution: returns the bin-averaged Kreft-Zuber flux concentration on cout_tedges, integrated over the full tedges-resolution flow within each output bin and averaged with equal weight over the streamtubes. streamline_length, molecular_diffusivity and longitudinal_dispersivity are either scalars shared by all streamtubes or one value per pore volume. Output bins that no infiltration has reached, or whose look-back leaves the record, are NaN.

  • extraction_to_infiltration() - Reverse direction: builds the same forward coefficient matrix and solves W @ cin = cout by Tikhonov regularization, returning the bin-averaged infiltration concentration on tedges. NaN entries in cout mark measurement gaps; their rows are dropped from the solve and cin bins that nothing else constrains are returned as NaN.

  • gamma_infiltration_to_extraction() - infiltration_to_extraction() with the pore volume distribution given as a (shifted) gamma – either (mean, std) or (alpha, beta), plus loc – discretized into n_bins equal-probability streamtubes that share one streamline_length and one pair of dispersion parameters.

  • gamma_extraction_to_infiltration() - extraction_to_infiltration() with the same gamma parameterization of the pore volume distribution: reconstructs cin on tedges from cout.

References

Bear, J. (1972). Dynamics of Fluids in Porous Media. American Elsevier Publishing Company. Equation 10.6.4 (variable-flow Ogata-Banks form). Provides the resident concentration C_R.

Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its solutions for different initial and boundary conditions. Chemical Engineering Science, 33(11), 1471-1480. Eq. 2 gives the resident-to- flux concentration transformation; Eq. 1 is the mass-balance identity that makes the column-sum invariant exact.

gwtransport.diffusion.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, spinup='constant')[source]#

Compute extracted concentration with advection, microdispersion, and molecular diffusion.

This function models 1D solute transport through an aquifer system along orthogonal (Cartesian) flow paths. Each aquifer pore volume is an independent streamline carrying advection with microdispersion (alpha_L) and molecular diffusion (D_m); the spread across the pore volume distribution provides macrodispersion. Linear sorption enters via the retardation factor.

The physical model assumes:

  1. Water infiltrates with concentration cin at time t_in

  2. Water travels distance L through aquifer with residence time tau = V_pore / Q

  3. During transport, microdispersion and molecular diffusion spread each streamline, while the spread across pore volumes provides macrodispersion

  4. At extraction, the concentration is a dispersed breakthrough curve

The reported extracted concentration is the Kreft-Zuber (1978) flux concentration at the outlet – the solute mass flux divided by the volumetric fluid flux, which is what is measured when sampling the outflowing fluid. Microdispersion and molecular diffusion enter as the moving-frame variance sigma^2(V) = 2*D_m*tau(V) + 2*alpha_L*xi(V), with tau(V) the elapsed time since infiltration and xi(V) the distance the parcel has actually travelled. See the module docstring for the derivation and for why the flux form is what makes the column-sum invariant integral Q c_out dt = integral Q c_in dt hold exactly under variable flow.

Parameters:
  • cin (ArrayLike) – Concentration of the compound in infiltrating water [concentration units]. Length must match the number of time bins defined by tedges. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length must match cin and the number of time bins defined by tedges. The model assumes this value is constant over each interval [tedges[i], tedges[i+1]).

  • tedges (DatetimeIndex) – Time edges defining bins for both cin and flow data. Has length of len(cin) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for output data bins. Has length of desired output + 1. The output concentration is averaged over each bin.

  • aquifer_pore_volumes (ArrayLike) – Array of aquifer pore volumes [m³] representing the distribution of flow paths. Each pore volume determines the residence time for that flow path: tau = V_pore / Q.

  • streamline_length (ArrayLike) – Array of travel distances [m] corresponding to each pore volume. Must have the same length as aquifer_pore_volumes.

  • molecular_diffusivity (ArrayLike) –

    Effective (retarded-frame) molecular diffusivity [m²/day]. Can be a scalar (same for all pore volumes) or an array with the same length as aquifer_pore_volumes. Must be non-negative. For solute transport, this is the molecular diffusion coefficient D_m [m²/day] — typically ~1e-5 m²/day, negligible compared to microdispersion. For heat transport, pass the thermal diffusivity D_th = lambda / (rho*c)_eff [m²/day], typically 0.01-0.1 m²/day.

    Internally, this contributes 2 * molecular_diffusivity * tau to the variance, where tau is the elapsed time in days (no extra factor of R). The retardation factor instead enters the flux coefficient D_s/v_s = R D_m / v_fluid + alpha_L through the solute-front velocity v_s = Q L / (R V_pore). For heat transport, the thermal diffusivity already represents the effective diffusivity D_eff in the porous matrix; for solutes the contribution is typically negligible.

  • longitudinal_dispersivity (ArrayLike) – Longitudinal dispersivity [m]. Can be a scalar (same for all pore volumes) or an array with the same length as aquifer_pore_volumes. Must be non-negative. Represents microdispersion from pore-scale velocity variations. Set to 0 for pure molecular diffusion.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer (default 1.0). Values > 1.0 indicate slower transport due to sorption.

  • spinup (str | None, default: 'constant') – Spin-up policy (default 'constant'). 'constant' extends tedges by 100 years on each side so that output bins near the boundary are always informed. None disables the extension; output bins without sufficient upstream data become NaN. Float fraction-threshold mode is not implemented and raises NotImplementedError.

Returns:

Bin-averaged concentration in the extracted water. Same units as cin. Length equals len(cout_tedges) - 1. NaN values indicate time periods with no valid contributions from the infiltration data.

Return type:

NDArray[floating]

Raises:

ValueError – If input dimensions are inconsistent, if diffusivity is negative, or if aquifer_pore_volumes and streamline_length have different lengths.

See also

extraction_to_infiltration

Inverse operation (deconvolution)

gwtransport.advection.infiltration_to_extraction

Pure advection (no dispersion)

gwtransport.diffusion_fast.infiltration_to_extraction

Fast closed-form equivalent

Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity

Macrodispersion vs microdispersion

Notes

The algorithm constructs a coefficient matrix W where cout = W @ cin:

  1. For each pore volume, build a cell grid in cumulative volume space:

    • cells span (V_cout[i], V_cout[i+1]) x V_cin[j] for each (cout-bin i, cin-edge j)

    • delta_volume = V_cout - V_cin - r_vpv encodes the parcel’s offset from the outlet at each (cout-edge, cin-edge)

  2. For each cell, compute the bin-averaged Kreft-Zuber flux concentration frac[i, j] = (1/dV_i) * integral C_F(L, V; t_j) dV by resolution-aware composite Gauss-Legendre quadrature in volume space, split at flow-bin boundaries so that t(V) is linear within each sub-interval. Where the erf-like front (width sqrt(4*D_t) in volume units) is under-resolved by a single 16-point rule – as for near-zero dispersivity – the sub-interval is tiled with front-centred panels; smooth sub-intervals keep the single rule. The moving-frame variance D_t = D_m*tau + alpha_L*xi is evaluated at each quadrature node (never capped at the residence time).

  3. Coefficient for bin: coeff[i,j] = frac[i, j] - frac[i, j+1]. This is the contribution of cin[j] to cout[i] in the W matrix.

  4. Average coefficients across all pore volumes.

Examples

Basic usage with constant flow:

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.diffusion import infiltration_to_extraction
>>>
>>> # Create time edges
>>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
>>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
>>>
>>> # Input concentration (step function) and constant flow
>>> cin = np.zeros(len(tedges) - 1)
>>> cin[5:10] = 1.0  # Pulse of concentration
>>> flow = np.ones(len(tedges) - 1) * 100.0  # 100 m³/day
>>>
>>> # Single pore volume of 500 m³, travel distance 100 m
>>> aquifer_pore_volumes = np.array([500.0])
>>> streamline_length = np.array([100.0])
>>>
>>> # Compute with dispersion (molecular diffusion + dispersivity)
>>> # Scalar values broadcast to all pore volumes
>>> cout = infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
...     streamline_length=streamline_length,
...     molecular_diffusivity=1e-4,  # m²/day, same for all pore volumes
...     longitudinal_dispersivity=1.0,  # m, same for all pore volumes
... )

With multiple pore volumes (heterogeneous aquifer):

>>> # Distribution of pore volumes and corresponding travel distances
>>> aquifer_pore_volumes = np.array([400.0, 500.0, 600.0])
>>> streamline_length = np.array([80.0, 100.0, 120.0])
>>>
>>> # Scalar diffusion parameters broadcast to all pore volumes
>>> cout = infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
...     streamline_length=streamline_length,
...     molecular_diffusivity=1e-4,  # m²/day
...     longitudinal_dispersivity=1.0,  # m
... )
gwtransport.diffusion.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, spinup='constant')[source]#

Compute infiltration concentration from extracted water (deconvolution with dispersion).

Inverts the forward transport model by building the forward coefficient matrix W_forward from infiltration_to_extraction() and solving W_forward @ cin = cout via Tikhonov regularization. Well-determined modes are dominated by the data; poorly-determined modes are pulled toward the physically motivated target (transpose-and-normalize of the forward matrix).

Parameters:
  • cout (ArrayLike) – Concentration of the compound in extracted water [concentration units]. Length must match the number of time bins defined by cout_tedges.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length must match the number of time bins defined by tedges.

  • tedges (DatetimeIndex) – Time edges defining bins for cin (output) and flow data. Has length of len(flow) + 1. Output cin has length len(tedges) - 1.

  • cout_tedges (DatetimeIndex) – Time edges for cout data bins. Has length of len(cout) + 1. Can have different time alignment and resolution than tedges.

  • aquifer_pore_volumes (ArrayLike) – Array of aquifer pore volumes [m³] representing the distribution of flow paths. Each pore volume determines the residence time for that flow path: tau = V_pore / Q.

  • streamline_length (ArrayLike) – Array of travel distances [m] corresponding to each pore volume. Must have the same length as aquifer_pore_volumes.

  • molecular_diffusivity (ArrayLike) – Effective molecular diffusivity [m²/day]. Can be a scalar (same for all pore volumes) or an array with the same length as aquifer_pore_volumes. Must be non-negative. See infiltration_to_extraction() for details on the physical interpretation and the interaction with retardation_factor.

  • longitudinal_dispersivity (ArrayLike) – Longitudinal dispersivity [m]. Can be a scalar (same for all pore volumes) or an array with the same length as aquifer_pore_volumes. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer (default 1.0). Values > 1.0 indicate slower transport due to sorption.

  • regularization_strength (float, default: 1e-10) – Tikhonov regularization parameter λ. See gwtransport.advection.extraction_to_infiltration() for details. Default is 1e-10.

  • spinup (str | None, default: 'constant') – Spin-up policy (default 'constant'). 'constant' extends tedges by 100 years on each side so that output bins near the boundary are always informed. None disables the extension; output bins without sufficient upstream data become NaN. Float fraction-threshold mode is not implemented and raises NotImplementedError.

Returns:

Bin-averaged concentration in the infiltrating water. Same units as cout. Length equals len(tedges) - 1. NaN values indicate time periods with no valid contributions from the extraction data.

Return type:

NDArray[floating]

Raises:

ValueError – If input dimensions are inconsistent, if diffusivity is negative, or if aquifer_pore_volumes and streamline_length have different lengths.

See also

infiltration_to_extraction

Forward operation (convolution)

gwtransport.advection.extraction_to_infiltration

Pure advection (no dispersion)

Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity

Macrodispersion vs microdispersion

Notes

The algorithm builds the forward coefficient matrix W_forward (same as used by infiltration_to_extraction()) and solves W_forward @ cin = cout using gwtransport.utils.solve_inverse_transport() (which builds the reverse regularization target and then applies Tikhonov regularization). This ensures mathematical consistency between forward and inverse operations.

NaN values in cout mark measurement gaps (e.g. sparse lab samples). Their rows are excluded from the Tikhonov solve, matching gwtransport.deposition.extraction_to_deposition(); cin bins constrained only by gapped cout bins are returned as NaN.

Examples

Basic usage with constant flow:

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.diffusion import extraction_to_infiltration
>>>
>>> # Create time edges: tedges for cin/flow, cout_tedges for cout
>>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
>>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
>>>
>>> # Extracted concentration and constant flow
>>> cout = np.zeros(len(cout_tedges) - 1)
>>> cout[5:10] = 1.0  # Observed pulse at extraction
>>> flow = np.ones(len(tedges) - 1) * 100.0  # 100 m³/day
>>>
>>> # Single pore volume of 500 m³, travel distance 100 m
>>> aquifer_pore_volumes = np.array([500.0])
>>> streamline_length = np.array([100.0])
>>>
>>> # Reconstruct infiltration concentration
>>> cin = extraction_to_infiltration(
...     cout=cout,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
...     streamline_length=streamline_length,
...     molecular_diffusivity=1e-4,
...     longitudinal_dispersivity=1.0,
... )
gwtransport.diffusion.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, spinup='constant')[source]#

Compute extracted concentration with advection and dispersion for gamma-distributed pore volumes.

Combines advection with microdispersion and molecular diffusion along each streamline (gamma-distributed pore volumes, whose spread provides macrodispersion). This is a convenience wrapper around infiltration_to_extraction() that parameterizes the aquifer pore volume distribution as a (shifted) gamma distribution.

Provide either (mean, std) or (alpha, beta); loc is optional and defaults to 0.

Parameters:
  • cin (ArrayLike) – Concentration of the compound in infiltrating water.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day].

  • tedges (DatetimeIndex) – Time edges for cin and flow data. Has length len(cin) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for output data bins. Has length of desired output + 1.

  • mean (float | None, default: None) – Mean of the gamma distribution of the aquifer pore volume. Must be strictly greater than loc.

  • std (float | None, default: None) – Standard deviation of the gamma distribution of the aquifer pore volume (invariant under the loc shift).

  • loc (float, default: 0.0) – Location (minimum pore volume) of the gamma distribution. Must satisfy 0 <= loc < mean. Default is 0.0.

  • alpha (float | None, default: None) – Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).

  • beta (float | None, default: None) – Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).

  • n_bins (int, default: 100) – Number of bins to discretize the gamma distribution. Default is 100.

  • streamline_length (float) – Travel distance through the aquifer [m]. Applied uniformly to all gamma-discretized pore volumes.

  • molecular_diffusivity (float) – Effective molecular diffusivity [m²/day]. Must be non-negative. See infiltration_to_extraction() for details on the interaction with retardation_factor.

  • longitudinal_dispersivity (float) – Longitudinal dispersivity [m]. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0). Values > 1.0 indicate slower transport.

  • spinup (str | None, default: 'constant') – Spin-up policy (default 'constant'). 'constant' extends tedges by 100 years on each side so that output bins near the boundary are always informed. None disables the extension; output bins without sufficient upstream data become NaN. Float fraction-threshold mode is not implemented and raises NotImplementedError.

Returns:

Bin-averaged concentration in the extracted water. Length equals len(cout_tedges) - 1. NaN values indicate time periods with no valid contributions from the infiltration data.

Return type:

NDArray[floating]

See also

infiltration_to_extraction

Transport with explicit pore volume distribution

gamma_extraction_to_infiltration

Reverse operation (deconvolution)

gwtransport.gamma.bins

Create gamma distribution bins

gwtransport.advection.gamma_infiltration_to_extraction

Pure advection (no dispersion)

Gamma Distribution Model

Two-parameter pore volume model

Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity

Macrodispersion vs microdispersion

Notes

The APVD is only time-invariant under the steady-streamlines assumption (see 2. Steady Streamlines).

The spreading from the gamma-distributed pore volumes represents macrodispersion (aquifer-scale heterogeneity). When std comes from calibration on measurements, it absorbs all mixing: macrodispersion, microdispersion, and an average molecular diffusion contribution. When std comes from streamline analysis, it represents macrodispersion only; microdispersion and molecular diffusion can be added via the dispersion parameters. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for guidance on when to add microdispersion.

Examples

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.diffusion import gamma_infiltration_to_extraction
>>>
>>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
>>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
>>> cin = np.zeros(len(tedges) - 1)
>>> cin[5:10] = 1.0
>>> flow = np.ones(len(tedges) - 1) * 100.0
>>>
>>> cout = gamma_infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     mean=500.0,
...     std=100.0,
...     n_bins=5,
...     streamline_length=100.0,
...     molecular_diffusivity=1e-4,
...     longitudinal_dispersivity=1.0,
... )
gwtransport.diffusion.gamma_extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, spinup='constant')[source]#

Compute infiltration concentration from extracted water for gamma-distributed pore volumes.

Inverts the forward transport model (advection + dispersion with gamma-distributed pore volumes) via Tikhonov regularization. This is a convenience wrapper around extraction_to_infiltration() that parameterizes the aquifer pore volume distribution as a (shifted) gamma distribution.

Provide either (mean, std) or (alpha, beta); loc is optional and defaults to 0.

Parameters:
  • cout (ArrayLike) – Concentration of the compound in extracted water.

  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day].

  • tedges (DatetimeIndex) – Time edges for cin (output) and flow data. Has length of len(flow) + 1.

  • cout_tedges (DatetimeIndex) – Time edges for cout data bins. Has length of len(cout) + 1.

  • mean (float | None, default: None) – Mean of the gamma distribution of the aquifer pore volume. Must be strictly greater than loc.

  • std (float | None, default: None) – Standard deviation of the gamma distribution of the aquifer pore volume (invariant under the loc shift).

  • loc (float, default: 0.0) – Location (minimum pore volume) of the gamma distribution. Must satisfy 0 <= loc < mean. Default is 0.0.

  • alpha (float | None, default: None) – Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).

  • beta (float | None, default: None) – Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).

  • n_bins (int, default: 100) – Number of bins to discretize the gamma distribution. Default is 100.

  • streamline_length (float) – Travel distance through the aquifer [m]. Applied uniformly to all gamma-discretized pore volumes.

  • molecular_diffusivity (float) – Effective molecular diffusivity [m²/day]. Must be non-negative. See infiltration_to_extraction() for details on the interaction with retardation_factor.

  • longitudinal_dispersivity (float) – Longitudinal dispersivity [m]. Must be non-negative.

  • retardation_factor (float, default: 1.0) – Retardation factor (default 1.0). Values > 1.0 indicate slower transport.

  • regularization_strength (float, default: 1e-10) – Tikhonov regularization parameter. Default is 1e-10.

  • spinup (str | None, default: 'constant') – Spin-up policy (default 'constant'). 'constant' extends tedges by 100 years on each side so that output bins near the boundary are always informed. None disables the extension; output bins without sufficient upstream data become NaN. Float fraction-threshold mode is not implemented and raises NotImplementedError.

Returns:

Bin-averaged concentration in the infiltrating water. Length equals len(tedges) - 1. NaN values indicate time periods with no valid contributions from the extraction data.

Return type:

NDArray[floating]

See also

extraction_to_infiltration

Deconvolution with explicit pore volume distribution

gamma_infiltration_to_extraction

Forward operation (convolution)

gwtransport.gamma.bins

Create gamma distribution bins

gwtransport.advection.gamma_extraction_to_infiltration

Pure advection (no dispersion)

Gamma Distribution Model

Two-parameter pore volume model

Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity

Macrodispersion vs microdispersion

Notes

The APVD is only time-invariant under the steady-streamlines assumption (see 2. Steady Streamlines).

The spreading from the gamma-distributed pore volumes represents macrodispersion (aquifer-scale heterogeneity). When std comes from calibration on measurements, it absorbs all mixing: macrodispersion, microdispersion, and an average molecular diffusion contribution. When std comes from streamline analysis, it represents macrodispersion only; microdispersion and molecular diffusion can be added via the dispersion parameters. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for guidance on when to add microdispersion.

Examples

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.diffusion import gamma_extraction_to_infiltration
>>>
>>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D")
>>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D")
>>> cout = np.zeros(len(cout_tedges) - 1)
>>> cout[5:10] = 1.0
>>> flow = np.ones(len(tedges) - 1) * 100.0
>>>
>>> cin = gamma_extraction_to_infiltration(
...     cout=cout,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     mean=500.0,
...     std=100.0,
...     n_bins=5,
...     streamline_length=100.0,
...     molecular_diffusivity=1e-4,
...     longitudinal_dispersivity=1.0,
... )

examples#

Example Data Generation for Groundwater Transport Modeling.

This module provides utilities to generate synthetic datasets for demonstrating and testing groundwater transport models. It creates realistic flow patterns, concentration/temperature time series, and deposition events suitable for testing advection, diffusion, and deposition analysis functions.

Available functions:

  • generate_example_data() - Build a daily (DataFrame, tedges) pair with columns flow (seasonal sinusoid plus noise and randomly placed low-flow spill periods, floored at 5 m³/day), cin (a seasonal sinusoid, a constant, or inline KNMI De Bilt soil temperature, selected with cin_method), and cout, obtained by transporting the noiseless cin through either a gamma-distributed or an explicitly listed set of aquifer pore volumes, using gwtransport.advection or, when the three diffusion parameters are given, gwtransport.diffusion_fast. Independent Gaussian measurement noise is added to cin and cout, so the pair is not exactly consistent when measurement_noise > 0. The aquifer and generation settings are recorded in df.attrs. Consumed by examples/04_Deposition_Analysis_Bank_Filtration.ipynb.

  • generate_temperature_example_data() - Same dataset viewed as heat transport: a thin wrapper over generate_example_data() whose defaults are thermal (retardation factor 2.0, diffusivity 0.05 m²/day, longitudinal dispersivity 1.0 m, streamline length 100 m), so the diffusion path is taken. Consumed by examples/01_Aquifer_Characterization_Temperature.ipynb, examples/02_Residence_Time_Analysis.ipynb, examples/03_Pathogen_Removal_Bank_Filtration.ipynb, and examples/08_bank_filtration_timflow.ipynb.

  • generate_example_deposition_timeseries() - Build a (Series, tedges) pair of surface deposition rates (ng/m²/day) on a UTC index: a baseline plus an annual sinusoid, Gaussian noise, and episodic events that start at the given dates and decay exponentially over event_duration days, optionally clipped at zero. Feeds gwtransport.deposition.deposition_to_extraction() in examples/04_Deposition_Analysis_Bank_Filtration.ipynb.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.examples.generate_example_data(*, date_start='2020-01-01', date_end='2021-12-31', date_freq='D', flow_mean=100.0, flow_amplitude=30.0, flow_noise=10.0, cin_method='synthetic', cin_mean=12.0, cin_amplitude=8.0, measurement_noise=1.0, aquifer_pore_volumes=None, aquifer_pore_volume_gamma_mean=None, aquifer_pore_volume_gamma_std=None, aquifer_pore_volume_gamma_loc=None, aquifer_pore_volume_gamma_nbins=None, retardation_factor=1.0, molecular_diffusivity=None, longitudinal_dispersivity=None, streamline_length=None, rng=None)[source]#

Generate synthetic concentration/temperature and flow data for groundwater transport.

Creates a synthetic dataset with seasonal flow patterns, input concentration (cin), and output concentration (cout) computed via gamma-distributed pore volume transport. When molecular_diffusivity, longitudinal_dispersivity, and streamline_length are provided, the diffusion module is used instead of pure advection.

Parameters:
  • date_start (str, default: '2020-01-01') – Start and end dates for the generated time series (YYYY-MM-DD).

  • date_end (str, default: '2021-12-31') – Start and end dates for the generated time series (YYYY-MM-DD).

  • date_freq (str, default: 'D') – Frequency string for pandas.date_range.

  • flow_mean (float, default: 100.0) – Mean flow rate [m³/day].

  • flow_amplitude (float, default: 30.0) – Seasonal amplitude of flow rate [m³/day].

  • flow_noise (float, default: 10.0) – Random noise level for flow rate [m³/day].

  • cin_method (str, default: 'synthetic') –

    Method for generating infiltration concentration. Options:

    • "synthetic": Seasonal sinusoidal pattern defined by cin_mean and cin_amplitude. Measurement noise is applied.

    • "constant": Constant value equal to cin_mean. Measurement noise is still applied.

    • "soil_temperature": Real soil temperature data from KNMI station 260.

  • cin_mean (float, default: 12.0) – Mean value of infiltrating concentration.

  • cin_amplitude (float, default: 8.0) – Seasonal amplitude of infiltration concentration (only used for "synthetic" method).

  • measurement_noise (float, default: 1.0) – Standard deviation of the Gaussian measurement noise applied independently to cin and cout. Because the two noise draws are independent, applying the forward operator to df['cin'] does not exactly reproduce df['cout'] when measurement_noise > 0; the underlying noiseless signals remain consistent.

  • aquifer_pore_volumes (ArrayLike | None, default: None) – Discrete aquifer pore volumes [m³] representing the distribution of residence times. When provided, the gamma distribution is bypassed and none of the aquifer_pore_volume_gamma_* parameters may be passed. When None, the pore volume distribution is built from the gamma parameters below.

  • aquifer_pore_volume_gamma_mean (float | None, default: None) – Mean pore volume of the aquifer gamma distribution [m³] (default 1000.0 when unset). Must be strictly greater than aquifer_pore_volume_gamma_loc. Mutually exclusive with aquifer_pore_volumes.

  • aquifer_pore_volume_gamma_std (float | None, default: None) – Standard deviation of aquifer pore volume gamma distribution [m³] (default 200.0 when unset; invariant under the loc shift). Mutually exclusive with aquifer_pore_volumes.

  • aquifer_pore_volume_gamma_loc (float | None, default: None) – Location (minimum pore volume) of the aquifer gamma distribution [m³] (default 0.0 when unset). Must satisfy 0 <= loc < mean. Mutually exclusive with aquifer_pore_volumes.

  • aquifer_pore_volume_gamma_nbins (int | None, default: None) – Number of bins to discretize the aquifer pore volume gamma distribution (default 250 when unset). Mutually exclusive with aquifer_pore_volumes.

  • retardation_factor (float, default: 1.0) – Retardation factor for transport.

  • molecular_diffusivity (float | None, default: None) – Effective molecular diffusivity [m²/day]. When provided together with longitudinal_dispersivity and streamline_length, the diffusion module is used instead of pure advection. For solutes, typically ~1e-5 m²/day (negligible). For heat, use thermal diffusivity ~0.01-0.1 m²/day.

  • longitudinal_dispersivity (float | None, default: None) – Longitudinal dispersivity [m]. Must be provided together with molecular_diffusivity and streamline_length.

  • streamline_length (float | None, default: None) – Travel distance along the streamline [m]. Must be provided together with molecular_diffusivity and longitudinal_dispersivity.

  • rng (Generator | int | None, default: None) – Source of randomness for the synthetic flow noise, spill events, and measurement noise. Accepted in any form supported by numpy.random.default_rng(). Pass an integer (or numpy.random.Generator) for reproducible output; None draws fresh entropy each call.

Returns:

A tuple containing:

  • pandas.DataFrame: DataFrame with columns 'flow', 'cin', 'cout' and metadata attributes for the aquifer parameters.

  • pandas.DatetimeIndex: Time edges (tedges) used for the flow calculations.

Return type:

tuple[DataFrame, DatetimeIndex]

Raises:

ValueError – If cin_method is not one of the supported methods, if only some of the diffusion parameters are provided, or if aquifer_pore_volumes is passed together with any aquifer_pore_volume_gamma_* parameter.

See also

generate_temperature_example_data

Wrapper with thermal transport defaults.

gwtransport.examples.generate_temperature_example_data(*, date_start='2020-01-01', date_end='2021-12-31', date_freq='D', flow_mean=100.0, flow_amplitude=30.0, flow_noise=10.0, cin_method='synthetic', cin_mean=12.0, cin_amplitude=8.0, measurement_noise=1.0, aquifer_pore_volumes=None, aquifer_pore_volume_gamma_mean=None, aquifer_pore_volume_gamma_std=None, aquifer_pore_volume_gamma_loc=None, aquifer_pore_volume_gamma_nbins=None, retardation_factor=2.0, molecular_diffusivity=0.05, longitudinal_dispersivity=1.0, streamline_length=100.0, rng=None)[source]#

Generate synthetic temperature and flow data for groundwater transport examples.

Convenience wrapper around generate_example_data() with sensible defaults for temperature transport: thermal retardation factor, thermal diffusivity, longitudinal dispersivity, and streamline length.

Typical parameter values for temperature transport in various sand types:

Parameter

Fine sand

Medium sand

Coarse sand/gravel

retardation_factor R

2.0–3.0

1.5–2.5

1.2–2.0

molecular_diffusivity (m²/day)

0.03–0.06

0.05–0.08

0.08–0.12

longitudinal_dispersivity (m)

0.1–1.0

0.5–5.0

1.0–10.0

streamline_length (m)

site-specific

Parameters:
  • retardation_factor (float, default: 2.0) – Thermal retardation factor.

  • molecular_diffusivity (float, default: 0.05) – Thermal diffusivity [m²/day].

  • longitudinal_dispersivity (float, default: 1.0) – Longitudinal dispersivity [m].

  • streamline_length (float, default: 100.0) – Travel distance along the streamline [m].

Returns:

See generate_example_data().

Return type:

tuple[DataFrame, DatetimeIndex]

See also

generate_example_data

Generic version with full parameter control.

Notes

All other parameters are forwarded unchanged to generate_example_data(); see that function for their descriptions.

gwtransport.examples.generate_example_deposition_timeseries(*, date_start='2018-01-01', date_end='2023-12-31', freq='D', base=0.8, seasonal_amplitude=0.3, noise_scale=0.1, event_dates=None, event_magnitude=3.0, event_duration=30, event_decay_scale=10.0, ensure_non_negative=True, rng=None)[source]#

Generate synthetic deposition timeseries for groundwater transport examples.

Parameters:
  • date_start (str, default: '2018-01-01') – Start and end dates for the generated time series (YYYY-MM-DD).

  • date_end (str, default: '2023-12-31') – Start and end dates for the generated time series (YYYY-MM-DD).

  • freq (str, default: 'D') – Frequency string for pandas.date_range (default ‘D’).

  • base (float, default: 0.8) – Baseline deposition rate (ng/m²/day).

  • seasonal_amplitude (float, default: 0.3) – Amplitude of the annual seasonal sinusoidal pattern (ng/m²/day).

  • noise_scale (float, default: 0.1) – Standard deviation of Gaussian noise added to the signal (ng/m²/day).

  • event_dates (ArrayLike | DatetimeIndex | None, default: None) – Dates (strings or pandas-compatible) at which to place episodic events. Time-zone-naive entries are interpreted as UTC to match the generated dates index. If None, a sensible default list is used.

  • event_magnitude (float, default: 3.0) – Peak deposition added at event onset (ng/m²/day). Decays exponentially over event_duration days at rate event_decay_scale.

  • event_duration (int, default: 30) – Duration of each event in days.

  • event_decay_scale (float, default: 10.0) – Decay scale used in the exponential decay for event time series.

  • ensure_non_negative (bool, default: True) – If True, negative values are clipped to zero.

  • rng (Generator | int | None, default: None) – Source of randomness for the additive Gaussian noise. Accepted in any form supported by numpy.random.default_rng(). Pass an integer (or numpy.random.Generator) for reproducible output; None draws fresh entropy each call.

Returns:

A tuple containing:

  • pandas.Series: Deposition time series (ng/m²/day) indexed by UTC timestamps.

  • pandas.DatetimeIndex: Time bin edges (n+1 edges for n values).

Return type:

tuple[Series, DatetimeIndex]

Raises:

ValueError – If event_decay_scale or event_duration is not positive, or if any event_dates entry falls outside the generated dates range.

See also

gwtransport.deposition.deposition_to_extraction

Forward operator consuming this data.

gwtransport.deposition.extraction_to_deposition

Inverse operator.

fronttracking#

Front tracking module for exact nonlinear transport modeling.

fronttracking.events#

Event detection for front tracking in (V, θ) coordinates.

All intersections are pure line/line geometry in the (V, θ) plane because every wave speed dV/dθ is independent of flow. Functions return θ-coordinates of intersections; the solver translates to user-facing t at the API boundary.

Events include:

  • Characteristic-characteristic collisions

  • Shock-shock collisions

  • Shock-characteristic collisions

  • Rarefaction boundary interactions

  • Outlet crossings

All calculations return exact floating-point results with machine precision. The theta coordinate is cumulative flow [m³]; see gwtransport.fronttracking.waves for the (V, θ) convention.

Available functions:

  • EventType - Enumeration of the event kinds the solver dispatches on: characteristic-characteristic, shock-shock, shock-characteristic, rarefaction-characteristic and shock-rarefaction collisions, decaying-shock fan exhaustion, wave merges from the face calculus, and outlet crossings.

  • Event - Record of one scheduled event: the θ at which it occurs, its EventType, the waves involved, the position location [m³], the 'head'/'tail' boundary_type for rarefaction collisions, and the colliding face pair for merges. It defines no ordering, because the solver orders (theta, counter, ...) tuples rather than the objects themselves.

  • find_characteristic_intersection() - First strictly-future crossing (θ, V) of two characteristics, or None when their speeds are equal or they never meet after theta_current. Both lines are evaluated from the shared reference max(θ_start_1, θ_start_2, θ_current).

  • find_shock_shock_intersection() - Same line/line crossing for two shocks, using each shock’s constant Rankine-Hugoniot speed.

  • find_shock_characteristic_intersection() - Same for a shock against a characteristic. The operand order is load-bearing: V is evaluated on the first line, so it fixes the successor wave’s v_start bit for bit.

  • find_rarefaction_boundary_intersections() - Crossings of a rarefaction’s head and tail lines (which travel at 1/R(c_head) and 1/R(c_tail)) with another wave, returned as a list of (θ, V, boundary_type) with boundary_type in {'head', 'tail'}.

  • find_outlet_crossing() - Cumulative flow at which a wave reaches v_outlet, assuming positive flow: a straight-line inverse for characteristics and shocks, and the cached-trajectory inverse outlet_crossing_theta for the fan-fed shocks (both the decaying and the doubly-fed one). Returns None if the wave is inactive, moves backward, has a pinned characteristic speed, or has already passed the outlet within a relative tolerance that stops a just-processed crossing from re-firing. Rarefactions are excluded — their callers split head and tail into separate boundary crossings.

  • is_outlet_crossing_pinned() - Whether a boundary state sits on the c_min retardation floor with an inflated R(c_min), so that its scheduled outlet crossing is a floor artifact rather than physics and the caller should drop it.

gwtransport.fronttracking.events.is_outlet_crossing_pinned(concentration, sorption)[source]#

Whether a boundary state is pinned by the c_min retardation floor.

A crossing scheduled for such a state is a non-physical artifact (its speed is a floor artifact, not physics); the caller drops it so it does not pollute the solver’s event record / theta_current.

Parameters:
  • concentration (float) – Boundary-state concentration [mass/volume].

  • sorption (SorptionModel) – Sorption model (supplies c_min and retardation).

Returns:

True only when concentration is at/below c_min AND the floored retardation R(c_min) is inflated past OUTLET_PIN_RETARDATION.

Return type:

bool

class gwtransport.fronttracking.events.EventType(*values)[source]#

Bases: Enum

All possible event types in front tracking simulation.

CHAR_CHAR_COLLISION = 'characteristic_collision'#

Two characteristics intersect (will form shock).

SHOCK_SHOCK_COLLISION = 'shock_collision'#

Two shocks collide (will merge).

SHOCK_CHAR_COLLISION = 'shock_characteristic_collision'#

Shock catches or is caught by characteristic.

RAREF_CHAR_COLLISION = 'rarefaction_characteristic_collision'#

Rarefaction boundary intersects with characteristic.

SHOCK_RAREF_COLLISION = 'shock_rarefaction_collision'#

Shock intersects with rarefaction boundary.

DSW_FAN_EXHAUSTED = 'decaying_shock_fan_exhausted'#

A decaying shock’s fan is exhausted (c_decay reached c_fan_tail).

WAVE_MERGE = 'wave_merge'#

any interaction involving a decaying/doubly-fed shock — fan-entry, doubly-fed formation, same-apex annihilation, side exhaustion (a doubly-fed shock crossing its own fan boundary line), and their compositions.

Type:

Two faces overtake (universal merge)

OUTLET_CROSSING = 'outlet_crossing'#

Wave crosses outlet boundary.

class gwtransport.fronttracking.events.Event(theta, event_type, waves_involved, location, boundary_type=None, faces=None)[source]#

Bases: object

A single event in the simulation, ordered by cumulative flow θ.

The solver’s priority queue orders (theta, counter, ...) tuples, not Event objects, so this dataclass intentionally defines no ordering.

Parameters:
  • theta (float) – Cumulative flow at which the event occurs [m³].

  • event_type (EventType) – Type of event.

  • waves_involved (list) – List of wave objects involved in this event.

  • location (float) – Volumetric position at which the event occurs [m³].

  • boundary_type (str | None, default: None) – Which rarefaction boundary collided: 'head' or 'tail'. Set for rarefaction collision events.

theta: float#
event_type: EventType#
waves_involved: list#
location: float#
boundary_type: str | None = None#
__init__(theta, event_type, waves_involved, location, boundary_type=None, faces=None)#
faces: tuple | None = None#
gwtransport.fronttracking.events.find_characteristic_intersection(char1, char2, theta_current)[source]#

Find exact analytical intersection of two characteristics in (V, θ).

Returns (θ_intersect, V_intersect) if the intersection lies in the future (θ > θ_current) and both characteristics are active there; otherwise None.

Return type:

tuple[float, float] | None

gwtransport.fronttracking.events.find_shock_shock_intersection(shock1, shock2, theta_current)[source]#

Find exact analytical intersection of two shocks in (V, θ).

Return type:

tuple[float, float] | None

gwtransport.fronttracking.events.find_shock_characteristic_intersection(shock, char, theta_current)[source]#

Find exact analytical intersection of a shock and a characteristic in (V, θ).

Return type:

tuple[float, float] | None

gwtransport.fronttracking.events.find_rarefaction_boundary_intersections(raref, other_wave, theta_current)[source]#

Intersections of a rarefaction’s head/tail with another wave.

Both rarefaction boundaries propagate at characteristic speeds (head at 1/R(c_head), tail at 1/R(c_tail)), so each is a straight (V, θ) line fed directly into _line_intersection() — no temporary CharacteristicWave objects. The operand order is per-branch: a is the raref boundary against a characteristic, but the SHOCK against a raref boundary, so V_intersect — the new wave’s v_start — matches find_shock_characteristic_intersection() bit for bit.

Returns:

(θ_intersect, V_intersect, boundary_type) for each intersection, where boundary_type is 'head' or 'tail'.

Return type:

list[tuple[float, float, str]]

gwtransport.fronttracking.events.find_outlet_crossing(wave, v_outlet, theta_current)[source]#

Find the cumulative flow θ at which the wave crosses v_outlet.

Handles CharacteristicWave, ShockWave, and DecayingShockWave. Rarefaction outlet crossings are handled by the callers directly (the solver and output.py split them into head/tail boundary crossings), so a RarefactionWave never reaches this function and returns None.

Assumes positive flow (waves always move toward larger V). Returns None if the wave has already passed the outlet, is not active, or moves backward. The “already past” check uses a relative tolerance so that a wave whose crossing event has just been processed (and is at v_outlet ± a few ULPs) does not re-emit a duplicate crossing one ULP later.

Return type:

float | None

fronttracking.handlers#

Event handlers for front tracking in (V, θ) coordinates.

Each handler receives the waves involved in an event and returns the new waves created by the interaction. In (V, θ) coordinates every wave speed is flow-free, so handlers depend only on concentrations and the sorption isotherm — flow does not appear.

All handlers enforce physical correctness:

  • Mass conservation (Rankine-Hugoniot condition)

  • Entropy conditions (Lax condition for shocks)

  • Causality (no backward-traveling information)

Handlers modify wave states in-place by deactivating parent waves and creating new child waves. Positions are volumetric [m³] and theta_event is cumulative flow [m³]; see gwtransport.fronttracking.waves for the (V, θ) convention.

Available functions:

  • handle_characteristic_collision() - Two characteristics meet, the faster catching the slower from behind. This compression always emits a single shock spanning the two concentrations, in every sorption regime; a resulting shock that fails the Lax condition raises RuntimeError.

  • handle_shock_collision() - Two shocks merge into one connecting the outer states: c_left from the upstream shock, c_right from the downstream one, with the speed recomputed from Rankine-Hugoniot. An entropy-violating merge raises RuntimeError.

  • handle_shock_characteristic_collision() - A shock and a characteristic meet; the characteristic’s concentration replaces c_right when the shock does the catching and c_left when the characteristic does. The successor is the modified shock if it satisfies entropy, otherwise a rarefaction, so mass balance is preserved either way.

  • handle_shock_rarefaction_collision() - A shock meets a rarefaction head or tail. The pair is replaced by one DecayingShockWave that subsumes fan and shock together: a head collision decays the left side from raref.c_head with c_fixed = shock.c_right, a tail collision decays the right side from raref.c_tail with c_fixed = shock.c_left, and the fan’s opposite boundary becomes c_fan_tail so partial drying is resolved exactly. Degenerate input where the boundary is not faster than the shock deactivates both waves and emits nothing.

  • handle_rarefaction_characteristic_collision() - A rarefaction boundary meets a characteristic. The characteristic is absorbed when its concentration matches the boundary value within tolerance; otherwise RuntimeError is raised rather than silently destroying mass.

  • create_inlet_waves_at_theta() - Emit the wave a step from c_prev to c_new produces at the inlet face V = 0: a shock when the new characteristic speed is higher (compression), a rarefaction when it is lower (expansion), and a characteristic when the two speeds tie. Returns an empty list for a negligible step or for a shock that fails the entropy check.

gwtransport.fronttracking.handlers.handle_characteristic_collision(char1, char2, theta_event, v_event)[source]#

Two characteristics collide → emit a shock.

The faster characteristic catches the slower one from behind. By the entropy condition this compressive interaction is always a shock, independently of the sorption regime (Freundlich n>1, n<1, or constant retardation).

Parameters:
  • char1 (CharacteristicWave) – Colliding characteristics.

  • char2 (CharacteristicWave) – Colliding characteristics.

  • theta_event (float) – Cumulative flow at which the collision occurs [m³].

  • v_event (float) – Position at which the collision occurs [m³].

Returns:

Single shock created at the collision point.

Return type:

list[ShockWave]

Raises:

RuntimeError – If the resulting shock fails the Lax entropy condition.

gwtransport.fronttracking.handlers.handle_shock_collision(shock1, shock2, theta_event, v_event)[source]#

Two shocks collide → merge into a single shock connecting outer states.

The merged shock has c_left from the faster (upstream) shock, c_right from the slower (downstream) shock; its speed is recomputed via Rankine-Hugoniot.

Parameters:
  • shock1 (ShockWave) – Colliding shocks.

  • shock2 (ShockWave) – Colliding shocks.

  • theta_event (float) – Cumulative flow [m³] and position [m³] of the collision.

  • v_event (float) – Cumulative flow [m³] and position [m³] of the collision.

Returns:

Single merged shock.

Return type:

list[ShockWave]

Raises:

RuntimeError – If the merged shock violates the entropy condition.

gwtransport.fronttracking.handlers.handle_shock_characteristic_collision(shock, char, theta_event, v_event)[source]#

Shock catches or is caught by a characteristic.

The characteristic concentration modifies one side of the shock:

  • Shock catches char (shock faster): modifies c_right.

  • Char catches shock (char faster): modifies c_left.

If the resulting shock satisfies entropy it is emitted (compression); otherwise a rarefaction is created (expansion) to preserve mass balance.

Return type:

list

gwtransport.fronttracking.handlers.handle_shock_rarefaction_collision(shock, raref, theta_event, v_event, boundary_type)[source]#

Shock interacts with a rarefaction fan (tail or head boundary).

Every shock↔rarefaction collision is resolved exactly by a single DecayingShockWave whose trajectory subsumes the fan + shock together, for any NonlinearSorption:

  • Head collision (rarefaction head catches the leading shock): the decaying side is the left, c_decay_initial = raref.c_head, c_fixed = shock.c_right, and c_fan_tail = raref.c_tail (the fan’s other boundary, which bounds the decay so partial drying is handled).

  • Tail collision (trailing shock catches the rarefaction tail): the decaying side is the right, c_decay_initial = raref.c_tail, c_fixed = shock.c_left, and c_fan_tail = raref.c_head.

The fan is bounded by c_fan_tail: the solver’s DSW_FAN_EXHAUSTED event spawns a regular shock once the decaying side reaches it, so partial drying (raref.c_tail != shock.c_right) is resolved exactly. If the rarefaction boundary is not faster than the shock (degenerate solver/test input), both waves are deactivated and nothing is emitted.

Returns:

[DecayingShockWave] for a physical collision, or [] for degenerate input.

Return type:

list

gwtransport.fronttracking.handlers.handle_rarefaction_characteristic_collision(raref, char, theta_event, v_event, boundary_type)[source]#

Rarefaction boundary intersects a characteristic.

When the characteristic’s concentration matches the boundary concentration to within tolerance the characteristic is absorbed; otherwise an informative RuntimeError is raised, because deactivating it would silently destroy mass.

Raises:

RuntimeError – If the characteristic’s concentration does not match the colliding rarefaction boundary concentration within tolerance, or if boundary_type is not 'head' or 'tail'.

Return type:

list

gwtransport.fronttracking.handlers.create_inlet_waves_at_theta(c_prev, c_new, theta, sorption)[source]#

Emit the wave produced by a step change in inlet concentration.

All inlet waves originate at the inlet face V = 0. Wave type is determined by characteristic speed comparison in (V, θ):

  • s_new > s_prev: compression → shock.

  • s_new < s_prev: expansion → rarefaction.

  • equal: contact discontinuity → characteristic.

For shocks the entropy condition is verified; if violated, an empty list is returned (mass balance may be affected — a known limitation handled by DecayingShockWave).

Return type:

list

fronttracking.math#

Mathematical Foundation for Front Tracking with Nonlinear Sorption.

This module provides exact analytical computations for:

  • Freundlich, Langmuir, and constant retardation models

  • Brooks-Corey and van Genuchten-Mualem unsaturated conductivity models (for Kinematic-Wave percolation, see gwtransport.percolation)

  • Shock velocities via Rankine-Hugoniot condition

  • Characteristic velocities and positions

  • First arrival time calculations

  • Entropy condition verification

All sorption-class computations are exact analytical formulas; the van Genuchten-Mualem class uses scipy.optimize.brentq for the two inversions that have no closed form.

Available functions:

  • NonlinearSorption - Abstract base for concentration-dependent isotherms. Subclasses supply retardation(c), total_concentration(c) and concentration_from_retardation(r); the base derives the Rankine-Hugoniot shock_speed(c_left, c_right), the Lax check_entropy_condition, the paired c_and_total_from_retardation and fan_converges_at_infinity from them.

  • FreundlichSorption - Power-law isotherm s(C) = k_f · C^(1/n) with closed-form R(C) = 1 + (ρ_b · k_f)/(n_por · n) · C^(1/n 1). n > 1 makes higher concentrations travel faster, n < 1 mirrors that, and n = 1 is rejected (use ConstantRetardation). Because R as C 0 for n > 1, retardation and concentration_from_retardation clamp C at c_min.

  • ConstantRetardation - Linear sorption s(C) = K_d · C reduced to one retardation_factor: every concentration moves at dV/dθ = 1/R, so no rarefaction ever forms and the solution is a pure θ-shift with shocks only at inlet discontinuities.

  • LangmuirSorption - Favorable isotherm s(C) = s_max · C/(K_L + C) with R(C) = 1 + (ρ_b · s_max · K_L)/(n_por · (K_L + C)²), which is finite at C = 0, so no concentration floor is needed; R decreases with C, giving shocks on concentration rises and fans on falls.

  • BrooksCoreyConductivity - Brooks-Corey unsaturated conductivity K(θ) = K_s · Θ^a recast into the (C, C_T) variables of the sorption interface by identifying C K (flux) and C_T θ θ_r (storage), which lets gwtransport.percolation run Kinematic-Wave percolation on this solver. All three curve methods are closed form; C is clamped at a dry-soil floor in retardation and concentration_from_retardation but not in total_concentration, keeping the c_R = 0 wetting-front shock speed exact.

  • VanGenuchtenMualemConductivity - Mualem conductivity for the van Genuchten retention curve, recast the same way. total_concentration and the flux derivative are closed form; the two inversions S_e(C) and S_e(R) use scipy.optimize.brentq. The retention parameter α_vG is not needed because the Kinematic-Wave approximation drops capillary suction.

  • characteristic_speed() - Characteristic speed dV/dθ = 1/R(c) in (V, θ) coordinates, a property of the isotherm alone. Its sorption argument is any of the classes above (the SorptionModel alias is their union).

  • characteristic_position() - Position of a characteristic at cumulative flow theta, evaluated as v_start + θ_start)/R(c), or None when theta lies before theta_start.

  • compute_first_front_arrival_theta() - Cumulative flow at which the first injected concentration level is fully present at the outlet: the θ-edge of the first bin that both carries water (positive θ-width) and has cin > 0, plus V · R(c_first) for Freundlich n < 1 and V · C_T(c_first)/c_first otherwise. These are tail-arrival semantics — a rarefaction head can reach the outlet much earlier — and the result is inf when no water-carrying bin injects concentration.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

class gwtransport.fronttracking.math.NonlinearSorption[source]#

Bases: ABC

Abstract base for concentration-dependent sorption models.

Subclasses must implement retardation, total_concentration, and concentration_from_retardation. Shock velocity and entropy checking are provided generically via the Rankine-Hugoniot and Lax conditions.

See also

FreundlichSorption

Freundlich isotherm implementation.

LangmuirSorption

Langmuir isotherm implementation.

ConstantRetardation

Linear (constant R) retardation model.

abstractmethod retardation(c)[source]#

Compute retardation factor R(C).

R fixes the characteristic celerity in (V, θ) coordinates: dV/dθ = 1/R(C).

Parameters:

c (float | NDArray[double]) – Dissolved concentration [mass/volume]. Non-negative.

Returns:

Retardation factor [-].

Return type:

float | NDArray[double]

abstractmethod total_concentration(c)[source]#

Compute total concentration (dissolved + sorbed per unit pore volume).

C_T is the conserved quantity of ∂C_T/∂θ + ∂C/∂V = 0; the flux carries only the dissolved part because sorbed mass is immobile.

Parameters:

c (float | NDArray[double]) – Dissolved concentration [mass/volume]. Non-negative.

Returns:

Total concentration [mass/volume].

Return type:

float | NDArray[double]

abstractmethod concentration_from_retardation(r)[source]#

Invert retardation factor to obtain concentration.

Used by rarefaction fans, where the self-similar solution gives R as a function of position and cumulative flow.

Parameters:

r (float | NDArray[double]) – Retardation factor [-].

Returns:

Dissolved concentration [mass/volume]. Non-negative.

Return type:

float | NDArray[double]

shock_speed(c_left, c_right)[source]#

Compute shock speed dV/dθ via Rankine-Hugoniot in (V, θ) coordinates.

With cumulative-flow coordinate θ = ∫flow(t’) dt’, the PDE ∂C_T/∂t + flow·∂C/∂V = 0 becomes ∂C_T/∂θ + ∂C/∂V = 0, and Rankine-Hugoniot reduces to:

dV_s/ = (C_R - C_L) / (C_T(C_R) - C_T(C_L))

Flow drops out entirely; the result is a property of the sorption isotherm alone.

Parameters:
  • c_left (float) – Concentration upstream (behind) shock [mass/volume].

  • c_right (float) – Concentration downstream (ahead of) shock [mass/volume].

Returns:

shock_speed – Shock speed dV/dθ [m³ / m³ flow = dimensionless].

Return type:

float

c_and_total_from_retardation(r)[source]#

Return (c, C_T(c)) at a given retardation r.

Default implementation calls concentration_from_retardation(r) then total_concentration(c) — two independent root-finds for sorptions where both routes back-solve the same equation (e.g. vG-Mualem with L 0). Subclasses for which both can be computed from a single root-find should override this for ~2× speedup of the IBP fan integrators.

Return type:

tuple[float, float]

fan_converges_at_infinity()[source]#

Whether a c_apex=0 fan’s c converges as θ +∞.

True when c 0 as R (so base·c 0 faster than base ): Brooks-Corey, van Genuchten-Mualem, Langmuir, and Freundlich n > 1. The only divergent case is Freundlich n < 1 (c as R ), which overrides this to False. Used by the universal temporal fan integrator to reject a +∞ upper bound when the integral diverges.

Return type:

bool

check_entropy_condition(c_left, c_right, shock_speed)[source]#

Verify Lax entropy condition in (V, θ) coordinates.

In θ-space, characteristic speeds are λ_θ(C) = 1 / R(C), and the Lax condition for a physical shock is:

λ_θ(C_L) ≥ dV_s/dθ ≥ λ_θ(C_R)
Parameters:
  • c_left (float) – Concentration upstream of shock [mass/volume].

  • c_right (float) – Concentration downstream of shock [mass/volume].

  • shock_speed (float) – Shock speed dV/dθ.

Returns:

satisfies – True if shock satisfies entropy condition (is physical).

Return type:

bool

class gwtransport.fronttracking.math.FreundlichSorption(k_f, n, bulk_density, porosity, c_min=1e-12)[source]#

Bases: NonlinearSorption

Freundlich sorption isotherm with exact analytical methods.

The Freundlich isotherm is: s(C) = k_f * C^(1/n)

where: - s is sorbed concentration [mass/mass of solid] - C is dissolved concentration [mass/volume of water] - k_f is Freundlich coefficient [(volume/mass)^(1/n)] - n is Freundlich exponent (dimensionless)

For n > 1: Higher C travels faster For n < 1: Higher C travels slower For n = 1: linear (not supported, use ConstantRetardation instead)

Notes

The retardation factor is defined as:
R(C) = 1 + (rho_b/n_por) * ds/dC

= 1 + (rho_b*k_f)/(n_por*n) * C^((1/n)-1)

For Freundlich sorption, R depends on C, which creates nonlinear wave behavior.

For n>1 (higher C travels faster), R(C)→∞ as C→0, which can cause extremely slow wave propagation. The c_min parameter prevents this by enforcing a minimum concentration, making R(C) finite for all C≥0.

Examples

>>> sorption = FreundlichSorption(
...     k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
... )
>>> r = sorption.retardation(5.0)
>>> c_back = sorption.concentration_from_retardation(r)
>>> bool(np.isclose(c_back, 5.0))
True
k_f: float#

Freundlich coefficient [(m³/kg)^(1/n)]. Positive.

n: float#

Freundlich exponent [-]. Positive and != 1.

bulk_density: float#

Bulk density of porous medium [kg/m³]. Positive.

porosity: float#

Porosity [-]. In (0, 1).

c_min: float = 1e-12#

Dry-soil singularity floor [mass/volume]; keeps R(C) finite as C 0 for n>1.

__post_init__()[source]#

Validate parameters after initialization.

Raises:

ValueError – If any parameter is outside its valid range: k_f <= 0, n <= 0, n == 1, bulk_density <= 0, porosity outside (0, 1), or c_min < 0.

retardation(c)[source]#

R(C) = 1 + (rho_b*k_f)/(n_por*n) * C^((1/n)-1). See NonlinearSorption.retardation().

Notes

R decreases with C for n > 1 (higher C travels faster) and increases for n < 1. With n<1 and c_min=0, R(0) = 1 (no sorption at zero) because clamping to c_min=0 leaves C^((1/n)-1) = 0^positive = 0; otherwise c is clamped to c_min before evaluation.

Clamping with np.maximum before the power keeps a single general path for every (n, c_min) combination and avoids raising the base to a fractional power on negative c.

A pure-float fast path handles the (dominant) scalar case, bit-identical to the array path; it falls through to the array expression only when the clamped c is 0 (c_min == 0 and c <= 0), where numpy’s 0.0**exponent yields the documented inf (n>1) / 0 (n<1) that a pure 0.0**neg would instead raise on.

Return type:

float | NDArray[double]

total_concentration(c)[source]#

C_T = C + (rho_b/n_por)·k_f·C^(1/n). See NonlinearSorption.total_concentration().

Notes

For c = 0, c^(1/n) = 0 exactly (no singularity for any n > 0), so C_T(0) = 0 is physically correct and no c_min clamp is needed here. c_min is only required to keep retardation() finite as c -> 0 for n > 1; clamping total_concentration to c_min would bias Rankine-Hugoniot shock speeds when c_R = 0 (e.g. the canonical 0->c->0 pulse). Negative c is clamped to 0 defensively.

Return type:

float | NDArray[double]

concentration_from_retardation(r)[source]#

C = [(R-1)·n_por·n/(rho_b·k_f)]^(n/(1-n)). See NonlinearSorption.concentration_from_retardation().

Notes

The exponent n/(1-n) is undefined at n = 1, which is why linear sorption must use ConstantRetardation instead. Results below c_min are clamped to it.

Return type:

float | NDArray[double]

fan_converges_at_infinity()[source]#

Freundlich n > 1: c 0 as R (converges). n < 1: c (diverges).

Return type:

bool

__init__(k_f, n, bulk_density, porosity, c_min=1e-12)#
class gwtransport.fronttracking.math.ConstantRetardation(retardation_factor)[source]#

Bases: object

Constant (linear) retardation model.

For linear sorption: s(C) = K_d * C This gives constant retardation: R(C) = 1 + (rho_b/n_por) * K_d = constant

This is a special case where concentration-dependent behavior disappears. Used for conservative tracers or as approximation for weak sorption.

Notes

With constant retardation: - All concentrations travel at same speed in (V, θ): dV/dθ = 1/R - No rarefaction waves form (all concentrations travel together) - Shocks occur only at concentration discontinuities at inlet - Solution reduces to simple θ-shifting (and then t-shifting via the θ↔t map)

This is equivalent to a single-pore-volume advective time-shift (the deterministic limit of gwtransport.advection.infiltration_to_extraction()) in the gwtransport package.

Examples

>>> sorption = ConstantRetardation(retardation_factor=2.0)
>>> sorption.retardation(5.0)
2.0
>>> sorption.retardation(10.0)
2.0
retardation_factor: float#

Constant retardation factor [-]. At least 1.0; R = 1 is a conservative tracer.

__post_init__()[source]#

Validate parameters after initialization.

Raises:

ValueError – If retardation_factor is less than 1.0.

retardation(c)[source]#

Constant retardation factor, independent of c.

Return type:

float

total_concentration(c)[source]#

C_T = C·R for linear sorption.

Return type:

float

concentration_from_retardation(r)[source]#

Not applicable: with constant R the inversion is not meaningful.

Raises:

NotImplementedError – Always.

Return type:

float

shock_speed(c_left, c_right)[source]#

dV/dθ = 1/R for any concentration pair — identical to every characteristic speed.

Return type:

float

check_entropy_condition(c_left, c_right, shock_speed)[source]#

Check the Lax entropy condition; always satisfied, since with constant R it holds as an equality.

Returns:

satisfies – Always True.

Return type:

bool

__init__(retardation_factor)#
class gwtransport.fronttracking.math.LangmuirSorption(s_max, k_l, bulk_density, porosity)[source]#

Bases: NonlinearSorption

Langmuir sorption isotherm with exact analytical methods.

The Langmuir isotherm is: s(C) = s_max * C / (K_L + C)

where: - s is sorbed concentration [mass/mass of solid] - C is dissolved concentration [mass/volume of water] - s_max is maximum sorption capacity [mass/mass of solid] - K_L is half-saturation constant [mass/volume]

Retardation always decreases with C (favorable isotherm), and R(0) is finite — unlike Freundlich with n > 1, no minimum concentration threshold is needed.

See also

FreundlichSorption

Freundlich isotherm (unbounded sorption).

ConstantRetardation

Linear (constant R) retardation model.

Non-Linear Sorption: Exact Solutions

Background on nonlinear sorption.

Notes

The retardation factor is defined as:

R(C) = 1 + (rho_b * s_max * K_L) / (n_por * (K_L + C)^2)

Key properties:

  • R(0) = 1 + rho_b * s_max / (n_por * K_L) – finite for all parameters

  • R -> 1 as C -> infinity (all sorption sites saturated)

  • R always decreases with increasing C (higher C travels faster)

  • Shocks form on concentration increases, rarefaction fans on decreases

Examples

>>> sorption = LangmuirSorption(
...     s_max=0.1, k_l=5.0, bulk_density=1500.0, porosity=0.3
... )
>>> r = sorption.retardation(5.0)
>>> c_back = sorption.concentration_from_retardation(r)
>>> bool(np.isclose(c_back, 5.0))
True
s_max: float#

Maximum sorption capacity [mass/mass of solid]. Positive.

k_l: float#

Half-saturation constant [mass/volume] — the C at which s = s_max/2. Positive.

bulk_density: float#

Bulk density of porous medium [kg/m³]. Positive.

porosity: float#

Porosity [-]. In (0, 1).

__post_init__()[source]#

Validate parameters after initialization.

Raises:

ValueError – If any parameter is outside its valid range: s_max <= 0, k_l <= 0, bulk_density <= 0, or porosity outside (0, 1).

retardation(c)[source]#

R(C) = 1 + A/(K_L + C)² with A = rho_b·s_max·K_L/n_por.

See NonlinearSorption.retardation(). R(0) is finite, R decreases with C, and R 1 as C (all sorption sites saturated).

Return type:

float | NDArray[double]

total_concentration(c)[source]#

C_T = C + (rho_b/n_por)·s_max·C/(K_L + C). See NonlinearSorption.total_concentration().

Return type:

float | NDArray[double]

concentration_from_retardation(r)[source]#

C = sqrt(A/(R - 1)) - K_L. See NonlinearSorption.concentration_from_retardation().

Notes

Returns 0.0 for R <= 1 (unphysical) and for R >= R(0) = 1 + A/K_L² (at or below zero concentration).

Return type:

float | NDArray[double]

__init__(s_max, k_l, bulk_density, porosity)#
class gwtransport.fronttracking.math.BrooksCoreyConductivity(theta_r, theta_s, k_s, brooks_corey_lambda)[source]#

Bases: NonlinearSorption

Brooks-Corey unsaturated conductivity recast as a NonlinearSorption.

Used by gwtransport.percolation to model gravity-driven percolation through a thick unsaturated zone via the Kinematic-Wave method. The closed-form conductivity curve

\[\begin{split}K(\\theta) = K_s \\cdot \\Theta^a, \\qquad \\Theta = (\\theta - \\theta_r)/(\\theta_s - \\theta_r), \\qquad a = 3 + 2/\\lambda \\;(\\text{Burdine})\end{split}\]

is recast in the framework’s (C, C_T) variables by identifying C K (the flux variable) and C_T θ - θ_r (the conserved storage). All three abstract methods have closed forms; shock_speed and check_entropy_condition are inherited unchanged from NonlinearSorption.

See also

VanGenuchtenMualemConductivity

Van Genuchten variant with brentq inversions.

FreundlichSorption

Power-law sorption isotherm (closed form, analogous shape).

gwtransport.percolation.root_zone_to_water_table_kinematic_wave

The public wrapper.

Notes

The retardation factor and total-concentration relation are:

\[\begin{split}C_T(C) = \\Delta\\theta \\cdot (C/K_s)^{1/a}, \\qquad R(C) = (\\Delta\\theta / (a K_s)) \\cdot (C/K_s)^{1/a - 1},\end{split}\]

with Δθ = θ_s θ_r. Since 1/a 1 < 0 always (a > 3), R(C) as C 0 (dry-soil singularity). The class clamps C to a small floor in retardation and concentration_from_retardation (the same pattern as FreundlichSorption with n > 1); total_concentration and the inherited shock_speed do not clamp, so the canonical wetting-front shock c_R = 0 produces the correct Rankine-Hugoniot velocity.

Examples

>>> sorption = BrooksCoreyConductivity(
...     theta_r=0.01, theta_s=0.337, k_s=0.174, brooks_corey_lambda=0.25
... )
>>> r = sorption.retardation(0.05)
>>> c = sorption.concentration_from_retardation(r)
>>> bool(np.isclose(c, 0.05, rtol=1e-13))
True
theta_r: float#

Residual volumetric moisture content [-]; 0 <= theta_r < theta_s.

theta_s: float#

Saturated volumetric moisture content [-]; theta_r < theta_s < 1. The porosity for typical soils.

k_s: float#

Saturated hydraulic conductivity [length/time]. Positive.

brooks_corey_lambda: float#

Pore-size distribution index λ [-]. Positive.

The exponent a = 3 + 2/λ is the Burdine pore-connectivity result. The Mualem variant (L = 0.5) gives a = 2.5 + 2/λ and is not implemented; a user wanting it can re-derive λ so the Burdine a matches the desired Mualem exponent.

a: float#

Exponent a = 3 + 2/λ (Burdine); set in __post_init__.

delta_theta: float#

θ_s θ_r; set in __post_init__.

__post_init__()[source]#

Validate parameters and derive a, delta_theta.

Raises:

ValueError – If any parameter is outside its valid range.

Return type:

None

__init__(theta_r, theta_s, k_s, brooks_corey_lambda)#
total_concentration(c)[source]#

C_T(C) = Δθ · (C/K_s)^(1/a). Returns 0 at C=0 (no clamp).

Return type:

float | NDArray[double]

retardation(c)[source]#

R(C) = (Δθ / (a·K_s)) · (C/K_s)^(1/a 1). Clamped at _C_MIN.

Return type:

float | NDArray[double]

concentration_from_retardation(r)[source]#

C = K_s · (R · a · K_s / Δθ)^{−a/(a−1)}. Result clamped at _C_MIN.

Return type:

float | NDArray[double]

class gwtransport.fronttracking.math.VanGenuchtenMualemConductivity(theta_r, theta_s, k_s, van_genuchten_n, mualem_l=0.5)[source]#

Bases: NonlinearSorption

Mualem prediction for the van Genuchten retention curve, recast as NonlinearSorption.

Used by gwtransport.percolation for Kinematic-Wave percolation with the standard Mualem-van Genuchten conductivity curve

\[\begin{split}K(\\theta) = K_s \\cdot S_e^L \\cdot \\left[1 - \\left(1 - S_e^{1/m}\\right)^m\\right]^2, \\qquad S_e = (\\theta - \\theta_r)/(\\theta_s - \\theta_r), \\qquad m = 1 - 1/n_\\text{vG}.\end{split}\]

The retention parameter α_vG is not needed for K(θ) — the Kinematic-Wave approximation drops capillary suction, so only the K(S_e) curve matters. The two inversions S_e(C) and S_e(R) have no closed form; both use scipy.optimize.brentq with xtol = BRENTQ_XTOL = 1e-14.

See also

BrooksCoreyConductivity

Brooks-Corey closed-form variant.

gwtransport.percolation.root_zone_to_water_table_kinematic_wave

The public wrapper.

Notes

The closed-form derivative is

\[\begin{split}\\frac{dK_M}{dS_e} = K_s \\cdot S_e^{L-1} \\cdot U \\cdot \\left[L \\cdot U + 2 \\cdot S_e^{1/m} \\cdot T^{m-1}\\right],\end{split}\]

with T = 1 - S_e^{1/m} and U = 1 - T^m. It is evaluated by _dk_dse and used for retardation(C) (after solving S_e(C)) and as the brentq objective in _se_from_retardation.

dK_M/dS_e is strictly increasing for every n_vG > 1 and L 0 (the conductivity flux is convex, d²K/dS_e² > 0; proved at 200-digit precision in docs/theory/front_tracking_interactions.md §2), so the brentq inversions are always well-posed — no monotonicity guard is needed. A convex flux also means the transport admits only shocks and rarefactions, never compound waves.

Examples

>>> sorption = VanGenuchtenMualemConductivity(
...     theta_r=0.01, theta_s=0.337, k_s=0.174, van_genuchten_n=2.28
... )
>>> r = sorption.retardation(0.05)
>>> c = sorption.concentration_from_retardation(r)
>>> bool(np.isclose(c, 0.05, rtol=1e-12))
True
__init__(theta_r, theta_s, k_s, van_genuchten_n, mualem_l=0.5)#
theta_r: float#

Residual volumetric moisture content [-]; 0 <= theta_r < theta_s.

theta_s: float#

Saturated volumetric moisture content [-]; theta_r < theta_s < 1.

k_s: float#

Saturated hydraulic conductivity [length/time]. Positive.

van_genuchten_n: float#

vG shape parameter n_vG > 1; m = 1 1/n_vG is derived from it.

mualem_l: float = 0.5#

Mualem pore-connectivity L >= 0. Default 0.5 (standard Mualem).

L = 0 (Burdine variant) gives a closed-form S_e(C) inverse; L != 0 requires brentq.

m: float#

Derived m = 1 1/n_vG; set in __post_init__.

delta_theta: float#

θ_s θ_r; set in __post_init__.

__post_init__()[source]#

Validate parameters and derive m, delta_theta.

No convexity/monotonicity guard is needed: dK_M/dS_e is strictly increasing (the flux K(S_e) is convex, d²K/dS_e² > 0) for every n_vG > 1 and L 0 — proved at 200-digit precision in docs/theory/front_tracking_interactions.md §2 — so the brentq inversions are always well-posed.

Raises:

ValueError – If any parameter is outside its valid range.

Return type:

None

total_concentration(c)[source]#

C_T = Δθ · S_e(C). Returns 0 at C=0 (no clamp).

Return type:

float | NDArray[double]

retardation(c)[source]#

R = Δθ / (dK_M/dS_e)|_{S_e(C)} via _dk_dse; clamps C at _C_MIN.

Return type:

float | NDArray[double]

concentration_from_retardation(r)[source]#

Invert R(C) = r. Solve dK_M/dS_e(S_e) = Δθ/r via brentq, then C = K_M(S_e).

Return type:

float | NDArray[double]

c_and_total_from_retardation(r)[source]#

Return (c, C_T) at retardation r from a SINGLE brentq call.

Overrides the default base-class implementation (which calls concentration_from_retardation and total_concentration separately and ends up doing two independent brentq solves on the same underlying equation). Halves the iterative-solver cost in the IBP fan integrators.

Return type:

tuple[float, float]

gwtransport.fronttracking.math.SorptionModel = gwtransport.fronttracking.math.NonlinearSorption | gwtransport.fronttracking.math.ConstantRetardation#

Type alias for all sorption models accepted by the front-tracking solver.

gwtransport.fronttracking.math.characteristic_speed(c, sorption)[source]#

Compute characteristic speed dV/dθ = 1/R(C).

In (V, θ) coordinates, every characteristic propagates at a flow-free speed determined solely by the local concentration and the sorption isotherm.

Parameters:
Returns:

speed – Characteristic speed dV/dθ.

Return type:

float

Examples

>>> sorption = FreundlichSorption(
...     k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
... )
>>> s = characteristic_speed(c=5.0, sorption=sorption)
>>> s > 0
True
gwtransport.fronttracking.math.characteristic_position(c, sorption, theta_start, v_start, theta)[source]#

Compute position of a characteristic at cumulative flow θ.

Characteristics propagate linearly in θ:

V(θ) = v_start + characteristic_speed(C) * (θ - θ_start)
Parameters:
  • c (float) – Concentration carried by characteristic [mass/volume].

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model.

  • theta_start (float) – Cumulative flow at which the characteristic starts [m³].

  • v_start (float) – Starting position [m³].

  • theta (float) – Cumulative flow at which to evaluate position [m³].

Returns:

position – Position at θ [m³], or None if θ < θ_start.

Return type:

float | None

Examples

>>> sorption = ConstantRetardation(retardation_factor=2.0)
>>> v = characteristic_position(
...     c=5.0, sorption=sorption, theta_start=0.0, v_start=0.0, theta=1000.0
... )
>>> bool(np.isclose(v, 500.0))  # v = (1/2) * 1000 = 500
True
gwtransport.fronttracking.math.compute_first_front_arrival_theta(cin, theta_edges, aquifer_pore_volume, sorption)[source]#

Cumulative-flow θ at which c_first arrives at the outlet (end of spin-up).

“Arrival” means the θ at which the c_first level is fully present at the outlet, θ_emit + V·R(c_first) for n<1 and θ_emit + V·C_T(c_first)/c_first for n>1/constant retardation.

Warning

For n<1 with c_min > 0 (default c_min = 1e-12 in FreundlichSorption), the actual wave emitted is a RarefactionWave whose head (c = c_min 0) reaches the outlet at θ ≈ V·R(c_min) Vmuch earlier than the value this function returns (which is the tail arrival V·R(c_first)). The function returns “tail arrival” semantics: the returned θ is a conservative end-of-spin-up where c ≤ c_first everywhere before it. Consult the solver event log for the true rarefaction head crossing.

Parameters:
Returns:

theta_first_arrival – Cumulative-flow θ at which c_first is fully present at the outlet [m³]. Returns np.inf only if no water-carrying (positive θ-width) bin has nonzero cin.

Return type:

float

Examples

>>> cin = np.array([0.0, 10.0] + [10.0] * 10)
>>> theta_edges = np.arange(0.0, 1300.0, 100.0)  # constant flow=100, dt=1
>>> sorption = ConstantRetardation(retardation_factor=2.0)
>>> theta_first = compute_first_front_arrival_theta(
...     cin, theta_edges, 500.0, sorption
... )
>>> bool(np.isclose(theta_first, 100.0 + 500.0 * 2.0))  # θ_emit + V·R
True

fronttracking.output#

Concentration extraction from front-tracking solutions (V, θ coordinates).

Every public function in this module takes θ (cumulative flow, m³). Callers translate user-facing time t → θ at the API boundary via FrontTrackerState.theta_at_t.

Outlet-mass functions use the PDE conservation identity m_out(θ) = m_in(θ) m_dom(θ) (Bear & Cheng 2010, Ch. 3: mass conservation for transport with sorption). m_dom honors historical wave activity via wave.was_active_at(theta) so retrospective queries at θ before a collision event correctly attribute c at v_outlet.

Available functions:

  • concentration_at_point() - Exact concentration at a single point (v, θ): the upstream state of the nearest wave face at or downstream of v among the waves active at θ, the downstream state of the outermost face when nothing lies downstream, and 0 in a virgin domain. Exactly on a shock or fan face the two sides are averaged; a contact carries its upstream value at its own position. The sorption argument is unused — each wave carries its own sorption reference.

  • compute_breakthrough_curve() - Outlet concentration sampled at every θ of a θ-array, i.e. concentration_at_point() evaluated at v_outlet. Returns one concentration [mass/volume] per θ; these are point samples of the exact trace, not bin averages.

  • compute_bin_averaged_concentration_exact() - Outlet concentration averaged over each output θ-bin (a flow-weighted average, since θ is cumulative flow), evaluated through the conservation identity c_avg = (Δm_in Δm_dom) / Δθ rather than by integrating the outlet trace, so overlapping fans need no ownership dispatch. A residual inside the floating-point cancellation band is set to zero; a bin more negative than that band warns and is clamped to zero to preserve the cout >= 0 contract.

  • identify_outlet_segments() - Partition of a θ-interval into the segments over which a single wave controls the outlet, as a list of dicts holding the segment θ-range, its type ('constant', 'rarefaction' or 'decaying_fan'), the controlling wave and the concentrations at both segment ends. Crossings extrapolated at or past a wave’s deactivation are dropped.

  • compute_domain_mass() - Total mass ∫₀^{v_outlet} C_total(v, θ) dv held in the domain at one θ [mass], dissolved plus sorbed. The domain is partitioned at the active wave faces; a constant region integrates as C_total·Δv and a fan region in closed form via integrate_fan_spatial_exact().

  • compute_cumulative_inlet_mass() - Mass injected at the inlet from θ = 0 up to theta, ∫₀^θ cin [mass], exact for the piecewise-constant cin on theta_edges.

  • compute_cumulative_outlet_mass() - Mass that has left through the outlet from θ = 0 up to theta [mass], as m_in(θ) m_dom(θ); 0 for θ <= 0.

  • compute_total_outlet_mass() - Outlet mass over all θ, from the inlet record alone: the full injected mass Σ cin·Δθ when the record returns to cin[-1] = 0, and +inf for a sustained cin[-1] > 0 boundary, which keeps injecting forever.

  • integrate_fan_exact() - Exact c(θ) [mass] at a fixed position for any self-similar fan, given the fan apex (theta_origin, v_origin), via the universal integration-by-parts antiderivative F(θ) = c·(θ θ_origin) Δv·C_T(c). c_apex > 0 clamps the fan at its tail θ and adds the constant plateau beyond it; theta_end may be +inf where the fan integral converges, and raises otherwise.

  • integrate_rarefaction_exact() - integrate_fan_exact() with the apex and c_apex = raref.c_tail read off a RarefactionWave.

  • integrate_fan_spatial_exact() - Exact C_total(v, θ) dv [mass] over a v-segment of a self-similar fan at fixed θ, via the spatial counterpart G(u) = C_T(c)·u κ·c with κ = θ θ_origin and u = v v_origin. c_apex > 0 splits off the constant-C_total(c_apex) region near the apex.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.fronttracking.output.concentration_at_point(v, theta, waves, sorption)[source]#

Compute concentration at point (v, θ) with exact analytical value.

The function works entirely in (V, θ) coordinates: public callers must translate user-facing time t → θ at the API boundary (e.g., via FrontTrackerState.theta_at_t).

Parameters:
  • v (float) – Position [m³].

  • theta (float) – Cumulative flow [m³].

  • waves (Sequence[Wave]) – All waves in the simulation (active and inactive).

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model (unused — kept for API symmetry; wave methods carry their own sorption reference).

Returns:

concentration – Concentration at point (v, θ) [mass/volume].

Return type:

float

Notes

Nearest-downstream-face sweep. C(v, θ) is the left (upstream) state of the nearest face strictly downstream of v among the waves active at θ; if no face is downstream, the right state of the outermost face; 0 in a virgin domain. A face’s feeder is a constant or a bounded self-similar fan (clamped to its extent, so the plateau beyond a fan reads correctly). Because every face crossing fires a solver event, the front list is interaction-consistent and each region has a single owner — no ownership heuristic is needed. This is exact for a single front (one owner everywhere) and for genuinely interacting multi-front inputs alike.

gwtransport.fronttracking.output.compute_breakthrough_curve(theta_array, v_outlet, waves, sorption)[source]#

Concentration at the outlet evaluated over a θ-array (breakthrough curve).

Parameters:
  • theta_array (NDArray[floating]) – Cumulative-flow points at which to query the outlet concentration [m³]. Must be sorted in ascending order. Callers translate from user-facing time via FrontTrackerState.theta_at_t before passing.

  • v_outlet (float) – Outlet position [m³].

  • waves (Sequence[Wave]) – All waves in the simulation.

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model.

Returns:

c_out – Concentration at v_outlet for each θ in theta_array [mass/volume].

Return type:

NDArray[floating]

See also

concentration_at_point

Point-wise concentration

compute_bin_averaged_concentration_exact

Bin-averaged concentrations

Examples

theta_array = np.linspace(0.0, tracker.state.theta_edges[-1], 1000)
c_out = compute_breakthrough_curve(
    theta_array, v_outlet=500.0, waves=tracker.state.waves, sorption=sorption
)
gwtransport.fronttracking.output.identify_outlet_segments(theta_start, theta_end, v_outlet, waves, sorption)[source]#

Identify which waves control outlet concentration in θ-interval [theta_start, theta_end].

Finds all wave crossing events at the outlet and constructs segments where concentration is constant or varying (rarefaction). All times are expressed as cumulative flow θ [m³].

Parameters:
Returns:

segments – List of segment dictionaries, each containing:

  • ’theta_start’float

    Segment start θ [m³]

  • ’theta_end’float

    Segment end θ [m³]

  • ’type’str

    'constant', 'rarefaction', or 'decaying_fan'. 'decaying_fan' is owned by a DecayingShockWave after its head crosses v_outlet; c at v_outlet then follows the wave’s self-similar fan profile.

  • ’concentration’float

    For constant segments

  • ’wave’Wave

    For rarefaction and decaying_fan segments

  • ’c_start’float

    Concentration at segment start

  • ’c_end’float

    Concentration at segment end

Return type:

list[dict]

Notes

Segments are constructed by:

  1. Finding all wave crossing events at the outlet for θ in [theta_start, theta_end].

  2. Sorting events by θ.

  3. Creating constant-concentration segments between events.

  4. Handling rarefaction and decaying-fan profiles with θ-varying concentration.

The segments completely partition the interval [theta_start, theta_end].

Every crossing is clamped to theta_cross < theta_deactivation (matching was_active_at semantics): a crossing extrapolated past a wave’s deactivation is an artifact — after a collision the front belongs to the successor wave, whose own crossing covers the outlet.

gwtransport.fronttracking.output.integrate_rarefaction_exact(raref, v_outlet, theta_start, theta_end, sorption)[source]#

Exact θ-integral c(θ) of a rarefaction at the outlet.

Convenience wrapper over integrate_fan_exact() that pulls the fan apex from raref.theta_start, raref.v_start. Returns the mass-like quantity c (= c·flow dt in time coordinates).

Parameters:
  • raref (RarefactionWave) – Rarefaction wave controlling the outlet.

  • v_outlet (float) – Outlet position [m³].

  • theta_start (float) – Integration range in cumulative flow [m³]. Either can be ±np.inf.

  • theta_end (float) – Integration range in cumulative flow [m³]. Either can be ±np.inf.

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model (any NonlinearSorption subclass).

Returns:

integral c(θ) [mass — i.e. concentration × volume].

Return type:

float

gwtransport.fronttracking.output.integrate_fan_exact(theta_origin, v_origin, v_outlet, theta_start, theta_end, sorption, c_apex=0.0)[source]#

Exact θ-integral c(θ) for any self-similar fan at the outlet.

Decoupled from the wave object so the same closed-form math applies to both RarefactionWave (apex = theta_start, v_start) and DecayingShockWave (apex = theta_origin, v_origin).

Parameters:
  • theta_origin (float) – Cumulative flow and position at the fan’s apex [m³].

  • v_origin (float) – Cumulative flow and position at the fan’s apex [m³].

  • v_outlet (float) – Outlet position [m³].

  • theta_start (float) – Integration range in cumulative flow [m³]. theta_end may be +np.inf; theta_start must be finite.

  • theta_end (float) – Integration range in cumulative flow [m³]. theta_end may be +np.inf; theta_start must be finite.

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model (any NonlinearSorption subclass).

  • c_apex (float, default: 0.0) – Concentration on the constant side at the fan apex. For RarefactionWave this is raref.c_tail; for DecayingShockWave (decay_side=’left’) this is wave.c_fan_tail (the plateau the outlet holds once the fan’s far edge sweeps by). For c_apex > 0 the fan formula extrapolates past the physical fan range; the integration is clamped at θ_tail (where c(θ_tail) = c_apex) and the constant-c_apex region beyond contributes c_apex · (theta_end θ_tail). Default 0.0 preserves the c=0 apex behavior for canonical c_R=0 fans.

Returns:

Mass-like quantity c(θ) [mass — concentration × volume].

Return type:

float

Raises:

TypeError – If the sorption model does not support exact fan integration.

gwtransport.fronttracking.output.compute_bin_averaged_concentration_exact(theta_bin_edges, v_outlet, waves, sorption, *, cin, theta_edges_inlet)[source]#

θ-bin-averaged outlet concentration.

For each θ-bin [θ_i, θ_{i+1}]:

C_avg = (1 / Δθ) · ∫_{θ_i}^{θ_{i+1}} C(v_outlet, θ) dθ

evaluated through the conservation-law identity C_avg = (Δm_in Δm_dom) / Δθ per bin — analytical and explicit, no outlet-side fan dispatch, so multi-DSW and n<1 mirror geometries are handled by the same single path.

Parameters:
  • theta_bin_edges (NDArray[floating]) – Cumulative-flow OUTPUT bin edges [m³] (where C_avg is reported). Length N+1 for N bins. Callers translate t-bin edges with state.theta_at_t.

  • v_outlet (float) – Outlet position [m³].

  • waves (Sequence[Wave]) – All waves from front tracking simulation.

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model.

  • cin (ArrayLike) – Inlet concentration per inlet θ-bin.

  • theta_edges_inlet (NDArray[floating]) – θ bin edges of the INLET (state.theta_edges), length len(cin) + 1.

Returns:

c_avg – Bin-averaged outlet concentrations [mass/volume]. Length N.

Return type:

NDArray[floating]

Raises:

ValueError – If any output θ-bin has non-positive width.

See also

concentration_at_point

Point-wise concentration

compute_breakthrough_curve

Breakthrough curve

compute_cumulative_outlet_mass

Cumulative outlet mass via conservation

gwtransport.fronttracking.output.compute_domain_mass(theta, v_outlet, waves, sorption)[source]#

Compute total mass in domain [0, v_outlet] at cumulative flow θ.

Integrates concentration over space:

M(θ) = ∫₀^v_outlet C_total(v, θ) dv

Exact analytical formulas for every wave type: constant regions (C_total · Δv), RarefactionWave fan interiors and DecayingShockWave fan interiors (closed-form via integrate_fan_spatial_exact()).

Parameters:
Returns:

mass – Total mass in domain [mass]. Closed-form analytical to machine precision.

Return type:

float

See also

compute_cumulative_inlet_mass

Cumulative inlet mass

compute_cumulative_outlet_mass

Cumulative outlet mass

concentration_at_point

Point-wise concentration

integrate_fan_spatial_exact

Closed-form fan spatial integral

Examples

mass = compute_domain_mass(
    theta=2500.0, v_outlet=500.0, waves=tracker.state.waves, sorption=sorption
)
mass >= 0.0
gwtransport.fronttracking.output.integrate_fan_spatial_exact(theta_origin, v_origin, v_start, v_end, theta, sorption, c_apex=0.0)[source]#

Exact spatial integral C_total(v, θ) dv for any self-similar fan.

Decoupled from the wave object so the same closed-form math applies to RarefactionWave (apex = theta_start, v_start) and DecayingShockWave (apex = theta_origin, v_origin).

In (V, θ) the self-similar fan satisfies R(C) = - θ_origin)/(v - v_origin); define kappa = θ - θ_origin and u = v - v_origin. The dissolved and sorbed contributions reduce to power-law forms in u that admit closed forms via incomplete beta functions (Freundlich) or elementary sqrt operations (Langmuir).

Parameters:
  • theta_origin (float) – Cumulative flow and position at the fan’s apex [m³].

  • v_origin (float) – Cumulative flow and position at the fan’s apex [m³].

  • v_start (float) – Integration range in v [m³].

  • v_end (float) – Integration range in v [m³].

  • theta (float) – Cumulative flow at which to evaluate [m³].

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model (any NonlinearSorption subclass).

  • c_apex (float, default: 0.0) – Concentration on the constant side at the fan apex (typically the parent rarefaction’s c_tail or the DSW’s c_fixed for decay_side='left'). For c_apex > 0 the fan formula is unphysical for u < u_tail = kappa / R(c_apex); the integration is split into a constant-C_total(c_apex) region for u [u_start, u_tail] plus the fan integral for u [u_tail, u_end]. Default 0.0 preserves the c=0 apex behavior for canonical c_R=0 rarefactions.

Returns:

Mass in the segment [v_start, v_end].

Return type:

float

Raises:

TypeError – If the sorption model does not support exact spatial integration.

gwtransport.fronttracking.output.compute_cumulative_inlet_mass(theta, cin, theta_edges)[source]#

Cumulative inlet mass entering the domain from θ=0 to theta.

In cumulative-flow coordinates M_in(θ) = ∫₀^θ cin(τ) ; for piecewise-constant cin this is exact under summation over θ-bin widths.

Parameters:
  • theta (float) – Cumulative flow up to which to integrate [m³].

  • cin (ArrayLike) – Inlet concentration per θ-bin [mass/volume].

  • theta_edges (ArrayLike) – θ bin edges [m³], length len(cin) + 1.

Returns:

mass_in – Cumulative inlet mass [mass].

Return type:

float

Examples

mass_in = compute_cumulative_inlet_mass(
    theta=5000.0, cin=cin, theta_edges=theta_edges
)
mass_in >= 0.0
gwtransport.fronttracking.output.compute_cumulative_outlet_mass(theta, v_outlet, waves, sorption, *, cin, theta_edges)[source]#

Cumulative mass exiting through the outlet from θ=0 to theta.

Computed analytically via the conservation-law identity:

m_out(θ) = m_in(θ) − m_dom(θ)

derived from integrating the PDE ∂_θ C_T + ∂_V c = 0 over the spatial domain [0, v_outlet] (Bear & Cheng 2010, Ch. 3: mass conservation for advection with sorption). This sidesteps the multi-fan dispatch problem that the outlet-segment integration faces when several DSWs cover v_outlet simultaneously — every term on the right is purely spatial or a closed-form inlet sum, no ownership priority needed.

Parameters:
Returns:

mass_out – Cumulative outlet mass [mass].

Return type:

float

Examples

mass_out = compute_cumulative_outlet_mass(
    theta=5000.0,
    v_outlet=500.0,
    waves=tracker.state.waves,
    sorption=sorption,
    cin=cin,
    theta_edges=tracker.state.theta_edges,
)
mass_out >= 0.0
gwtransport.fronttracking.output.compute_total_outlet_mass(*, cin, theta_edges)[source]#

Total outlet mass over θ → ∞ (finite only for a returning-to-zero pulse).

The final inlet value c_∞ = cin[-1] is the sustained boundary state as θ → ∞:

  • For c_∞ = 0 (canonical c_R=0 pulse): injection ceases, the domain empties, and every injected mass unit eventually exits — m_out_total = m_in_total (the finite record integral Σ cin·Δθ). The wave list is not needed.

  • For c_∞ > 0 (sustained ambient): the inlet keeps injecting c_∞ forever, so the cumulative outlet mass grows without bound — return +inf. Pairing the FINITE record integral with the infinite-time steady-state fill C_T(c_∞)·v_outlet is not an option: that difference goes negative whenever m_in_total < C_T(c_∞)·v_outlet.

Parameters:
  • cin (ArrayLike) – Inlet concentration per θ-bin [mass/volume].

  • theta_edges (NDArray[floating]) – θ bin edges [m³], length len(cin) + 1.

Returns:

m_in_total for cin[-1] = 0; +inf for cin[-1] > 0.

Return type:

float

See also

compute_cumulative_outlet_mass

Cumulative outlet mass up to a finite θ (use this for a sustained c_∞ > 0 boundary, where the θ → ∞ total is unbounded).

compute_domain_mass

Spatial integral of C_total in the aquifer

fronttracking.plot#

Visualization functions for front tracking.

This module provides plotting utilities for visualizing front-tracking simulations:

  • V-t diagrams showing wave propagation in space-time

  • Breakthrough curves showing concentration at outlet over time

Internally the simulation uses cumulative-flow coordinates (V, θ). All plots remain in user-facing time t (days). Translation is done via the state’s t_at_theta / theta_at_t methods at the plotting boundary.

Available functions:

  • plot_vt_diagram() - Draw every wave of a completed simulation in the (time, position) plane: characteristics as thin blue lines, shocks as thick red lines, rarefactions as filled green fans, with the inlet and the outlet marked as horizontal lines. Each straight-in-θ trajectory is sampled in θ and translated to time t before plotting. Returns the axes; show_inactive adds the waves deactivated by interactions and show_events marks the interaction events.

  • plot_inlet_concentration() - Draw cin on its tedges as a step function of time [days], optionally marking a first-arrival time with a vertical line. Returns the axes.

  • plot_front_tracking_summary() - Three-panel figure for one simulation: the V-t diagram, the inlet concentration, and across the bottom the outlet concentration as the exact analytical breakthrough curve and/or the bin-averaged cout step trace, both referenced to the tracker’s own time origin so the two overlay without a shift. Returns the figure and a dict of axes keyed 'vt', 'inlet' and 'outlet'.

  • plot_sorption_comparison() - 2x3 figure contrasting a pulse inlet and a dip inlet with the exact outlet response each produces under a favorable (n>1) and an unfavorable (n<1) isotherm: column 0 holds the two inlets, columns 1-2 their responses. Returns the figure and the 2x3 array of axes.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.fronttracking.plot.plot_vt_diagram(state, ax=None, *, t_max=None, figsize=(14, 10), show_inactive=False, show_events=False)[source]#

Create V-t diagram showing all waves in space-time.

Plots characteristics (blue lines), shocks (red lines), and rarefactions (green fans) in the (time, position) plane. This visualization shows how waves propagate and interact throughout the simulation.

Internally the waves live in (V, θ); each wave’s straight-line θ-trajectory is converted back to user-facing time t via state.t_at_theta before plotting.

Parameters:
  • state (FrontTrackerState) – Complete simulation state containing all waves.

  • ax (Axes | None, default: None) – Existing axes to plot into. If None, a new figure and axes are created using figsize.

  • t_max (float | None, default: None) – Maximum time to plot [days]. If None, uses the input data time range.

  • figsize (tuple[float, float], default: (14, 10)) – Figure size in inches (width, height). Default (14, 10).

  • show_inactive (bool, default: False) – Whether to show inactive waves (deactivated by interactions). Default False.

  • show_events (bool, default: False) – Whether to show wave interaction events as markers. Default False.

Returns:

ax – Axes object containing the V-t diagram.

Return type:

Axes

See also

plot_front_tracking_summary

Multi-panel summary combining these views.

gwtransport.advection.infiltration_to_extraction_nonlinear_sorption

Produces the tracker state.

Notes

  • Characteristics appear as blue lines (constant speed in θ).

  • Shocks appear as thick red lines (jump discontinuities).

  • Rarefactions appear as green fans (smooth transition regions).

  • Outlet position is shown as a horizontal dashed line.

  • Only waves within domain [0, v_outlet] are plotted.

Examples

from gwtransport.fronttracking.solver import FrontTracker

tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption)
tracker.run()
ax = plot_vt_diagram(tracker.state)
ax.figure.savefig("vt_diagram.png")
gwtransport.fronttracking.plot.plot_inlet_concentration(tedges, cin, ax=None, *, t_first_arrival=None, color='blue', t_max=None, figsize=(8, 5))[source]#

Plot inlet concentration as a step function.

Parameters:
  • tedges (DatetimeIndex) – Time bin edges for inlet concentration. Length = len(cin) + 1.

  • cin (ArrayLike) – Inlet concentration values. Length = len(tedges) - 1.

  • ax (Axes | None, default: None) – Existing axes to plot into. If None, creates new figure.

  • t_first_arrival (float | None, default: None) – First arrival time to mark with vertical line [days].

  • color (str, default: 'blue') – Color for inlet concentration line. Default ‘blue’.

  • t_max (float | None, default: None) – Maximum time for x-axis [days]. If None, uses full range.

  • figsize (tuple[float, float], default: (8, 5)) – Figure size if creating new figure. Default (8, 5).

Returns:

ax – Axes object.

Return type:

Axes

See also

plot_front_tracking_summary

Multi-panel summary that places this inlet panel.

gwtransport.fronttracking.plot.plot_front_tracking_summary(structure, tedges, cin, cout_tedges, cout, *, figsize=(16, 10), show_exact=True, show_bin_averaged=True, show_events=True, show_inactive=False, t_max=None, title=None)[source]#

Create comprehensive 3-panel summary figure for front tracking simulation.

Creates a multi-panel visualization with: - Top-left: V-t diagram showing wave propagation - Top-right: Inlet concentration time series - Bottom: Outlet concentration (exact and/or bin-averaged)

Parameters:
  • structure (dict) – Structure returned from infiltration_to_extraction_nonlinear_sorption. Must contain keys: ‘tracker_state’, ‘theta_first_arrival’.

  • tedges (DatetimeIndex) – Time bin edges for inlet concentration. Length = len(cin) + 1.

  • cin (ArrayLike) – Inlet concentration values. Length = len(tedges) - 1.

  • cout_tedges (DatetimeIndex) – Output time bin edges for bin-averaged concentration. Length = len(cout) + 1.

  • cout (ArrayLike) – Bin-averaged output concentration values. Length = len(cout_tedges) - 1.

  • figsize (tuple[float, float], default: (16, 10)) – Figure size (width, height). Default (16, 10).

  • show_exact (bool, default: True) – Whether to show exact analytical breakthrough curve. Default True.

  • show_bin_averaged (bool, default: True) – Whether to show bin-averaged concentration. Default True.

  • show_events (bool, default: True) – Whether to show wave interaction events on V-t diagram. Default True.

  • show_inactive (bool, default: False) – Whether to show inactive waves on V-t diagram. Default False.

  • t_max (float | None, default: None) – Maximum time for plots [days]. If None, uses input data range.

  • title (str | None, default: None) – Overall figure title. If None, uses generic title.

Return type:

tuple[Figure, dict]

Returns:

  • fig (matplotlib.figure.Figure) – Figure object.

  • axes (dict) – Dictionary with keys ‘vt’, ‘inlet’, ‘outlet’ containing axes objects.

See also

plot_vt_diagram

The top-left sub-panel.

plot_inlet_concentration

The top-right sub-panel.

gwtransport.advection.infiltration_to_extraction_nonlinear_sorption

Produces structure.

gwtransport.fronttracking.plot.plot_sorption_comparison(pulse_favorable_structure, pulse_unfavorable_structure, pulse_tedges, pulse_cin, dip_favorable_structure, dip_unfavorable_structure, dip_tedges, dip_cin, *, figsize=(16, 12), t_max_pulse=None, t_max_dip=None)[source]#

Compare how each inlet produces different outputs with n>1 vs n<1 sorption.

Creates a 2x3 grid: - Row 1: Pulse inlet and its outputs with n>1 and n<1 sorption - Row 2: Dip inlet and its outputs with n>1 and n<1 sorption

This demonstrates how the SAME inlet timeseries produces DIFFERENT breakthrough curves depending on the sorption isotherm.

Parameters:
  • pulse_favorable_structure (dict) – Structure from pulse inlet with n>1 (higher C travels faster).

  • pulse_unfavorable_structure (dict) – Structure from pulse inlet with n<1 (lower C travels faster).

  • pulse_tedges (DatetimeIndex) – Time bin edges for pulse inlet. Length = len(pulse_cin) + 1.

  • pulse_cin (ArrayLike) – Pulse inlet concentration (e.g., 0->10->0). Length = len(pulse_tedges) - 1.

  • dip_favorable_structure (dict) – Structure from dip inlet with n>1 (higher C travels faster).

  • dip_unfavorable_structure (dict) – Structure from dip inlet with n<1 (lower C travels faster).

  • dip_tedges (DatetimeIndex) – Time bin edges for dip inlet. Length = len(dip_cin) + 1.

  • dip_cin (ArrayLike) – Dip inlet concentration (e.g., 10->2->10). Length = len(dip_tedges) - 1.

  • figsize (tuple[float, float], default: (16, 12)) – Figure size (width, height). Default (16, 12).

  • t_max_pulse (float | None, default: None) – Max time for pulse plots [days]. If None, auto-computed.

  • t_max_dip (float | None, default: None) – Max time for dip plots [days]. If None, auto-computed.

Return type:

tuple[Figure, NDArray]

Returns:

fronttracking.solver#

Event-driven front-tracking solver in (V, θ) coordinates.

The simulation runs entirely in cumulative-flow space θ. Every public output — wave attributes, state.events[i]['theta'], theta_first_arrival — is in θ. Translation to user-facing time t is the caller’s responsibility via state.t_at_theta. Time-varying flow is absorbed into the precomputed theta_edges array at __init__; there is no flow-change event.

Algorithm:

  1. Initialize waves from inlet boundary conditions (one per cin step at θ_edges[i]).

  2. Find next event (earliest collision or outlet crossing in θ).

  3. Advance θ to event.

  4. Handle event (create new waves, deactivate old ones).

  5. Repeat until no more events.

All calculations are exact analytical with machine precision.

Available functions:

  • FrontTrackerState - Dataclass carrying the whole simulation state: every wave ever created (active and deactivated), the event log, the current θ, the outlet position v_outlet, the sorption model, the inlet series, and the tedges_days/theta_edges grids. Its t_at_theta, theta_at_t and theta_at_t_array methods are the piecewise-linear θ↔t maps a caller needs to read any solver output in user-facing days; events at a zero-flow θ plateau map to the end of that plateau.

  • FrontTracker - The event-driven solver. Construction validates the inputs (non-negative cin and flow, len(tedges) == len(cin) + 1, positive pore volume), builds theta_edges by cumulating flow · dt, sets the resolution horizon theta_horizon (default: the last inlet edge), computes theta_first_arrival, and emits the inlet waves. find_next_event returns the earliest candidate in θ — characteristic, shock and rarefaction collisions, face merges involving a decaying or doubly-fed shock, fan exhaustion, and outlet crossings — handle_event dispatches it to the handler that retires the parents and appends the successors, and run repeats until the candidate set is empty or max_iterations is reached. verify_physics asserts that every active shock still satisfies the Lax entropy condition.

  • find_unresolved_interaction() - Invariant tripwire over a completed state. The cumulative outlet mass m_out(θ) = m_in(θ) m_dom(θ) must never decrease; this returns a short description of the first θ where it does beyond the floating-point cancellation band — the fingerprint of an interaction the solver failed to resolve — and None for a mass-conserving wave field.

class gwtransport.fronttracking.solver.FrontTrackerState(waves, events, theta_current, v_outlet, sorption, cin, flow, tedges, tedges_days, theta_edges, theta_horizon=inf)[source]#

Bases: object

Complete state of the front-tracking simulation in (V, θ).

Parameters:
  • waves (list[Wave]) – All waves created during simulation (includes inactive waves).

  • events (list[dict]) – Event history. Records use the "theta" key carrying the cumulative flow at which the event occurred [m³]. Callers translate to user-facing time via FrontTrackerState.t_at_theta.

  • theta_current (float) – Current simulation cumulative flow [m³].

  • v_outlet (float) – Outlet position [m³].

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model.

  • cin (ndarray) – Inlet concentration time series [mass/volume].

  • flow (ndarray) – Flow rate time series [m³/day], one value per bin.

  • tedges (DatetimeIndex) – Time bin edges.

  • tedges_days (NDArray[floating]) – tedges as days from tedges[0], length len(flow) + 1.

  • theta_edges (NDArray[floating]) – Cumulative flow at every bin edge. theta_edges[i] = sum_{k<i} flow[k] * (tedges_days[k+1] - tedges_days[k]). Length len(flow) + 1.

waves: list[Wave]#
events: list[dict]#
theta_current: float#
v_outlet: float#
sorption: NonlinearSorption | ConstantRetardation#
cin: ndarray#
flow: ndarray#
tedges: DatetimeIndex#
tedges_days: NDArray[floating]#
theta_edges: NDArray[floating]#
theta_horizon: float = inf#
t_at_theta(theta)[source]#

Translate cumulative flow θ back to user-facing time t [days].

Piecewise linear inversion of the (tedges_days → theta_edges) map. Implementation note on the (rare) zero-flow case: when a bin has flow[i] == 0, θ is constant across [tedges_days[i], tedges_days[i+1]); np.searchsorted(..., side='right') - 1 lands on the rightmost such bin, so this function returns tedges_days[i] for the right-most i sharing that θ. Convention: events at a zero-flow θ plateau map to the END of the zero-flow interval.

Return type:

float

theta_at_t(t)[source]#

Translate user-facing time t [days] to cumulative flow θ [m³].

Scalar form of theta_at_t_array().

Return type:

float

theta_at_t_array(t)[source]#

Map times t [days] to cumulative flow θ [m³].

Piecewise linear forward map. Outside the input range the boundary flow is extrapolated.

Parameters:

t (ArrayLike) – User-facing time points [days].

Returns:

Cumulative flow θ at each t [m³].

Return type:

NDArray[floating]

__init__(waves, events, theta_current, v_outlet, sorption, cin, flow, tedges, tedges_days, theta_edges, theta_horizon=inf)#
class gwtransport.fronttracking.solver.FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption, theta_horizon=None)[source]#

Bases: object

Event-driven front-tracking solver for nonlinear sorption transport.

Parameters:
  • cin (ArrayLike) – Inlet concentration time series [mass/volume]; length n.

  • flow (ArrayLike) – Flow rate time series [m³/day]; length n (one value per bin).

  • tedges (DatetimeIndex) – Time bin edges (length n+1).

  • aquifer_pore_volume (float) – Total pore volume [m³] — used as the outlet position.

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model.

state#

Complete simulation state.

Type:

FrontTrackerState

theta_first_arrival#

Cumulative flow θ at which the first nonzero-concentration wave reaches the outlet [m³]. Translate to user-facing time via state.t_at_theta(theta_first_arrival).

Type:

float

Notes

The solver works exclusively in cumulative flow θ; events appended to state.events carry "theta". Translation to user-facing time t is the caller’s responsibility (use state.t_at_theta).

__init__(cin, flow, tedges, aquifer_pore_volume, sorption, theta_horizon=None)[source]#
find_next_event()[source]#

Return the next event in θ-order, or None if none.

Return type:

Event | None

handle_event(event)[source]#

Dispatch an event to its handler and record it (with t translated from θ).

run(max_iterations=10000)[source]#

Process events in θ-order until the queue is empty or max_iterations is reached.

verify_physics()[source]#

Verify physical correctness: every active shock satisfies Lax entropy.

Mass conservation is intentionally NOT checked here. The closed-form identity m_out(θ) = m_in(θ) m_dom(θ) makes any m_in_domain + m_out_cumulative == m_in_cumulative test tautological (residual identically zero, regardless of any compute_domain_mass bug), so it cannot catch a conservation error. The non-tautological, integral-based conservation check (an independent breakthrough integral compared to the inlet mass) lives in gwtransport.fronttracking.validation.verify_physics() check 7.

Every shock the solver creates is entropy-checked at construction and a shock’s (c_left, c_right) never change, so this scan is an assertion for externally assembled wave lists, not a solver self-check.

Raises:

RuntimeError – If an active shock violates the Lax entropy condition.

gwtransport.fronttracking.solver.find_unresolved_interaction(state)[source]#

Tripwire for a solver-left inconsistency in the resolved wave field.

The solver resolves every wave interaction (shock↔shock, fan-entry, doubly-fed formation, same-apex annihilation, and their compositions), so the wave list is interaction-consistent and the single-owner sweep reader is exact — overlapping fans are normal and correct, not an error. This is an internal invariant check: the cumulative outlet mass m_out(θ) = m_in(θ) m_dom(θ) must be non-decreasing in θ (mass leaves the column, it never re-enters). A decrease beyond the FP-cancellation band means the reader’s domain-mass field transiently over-counts stored mass — the fingerprint of an interaction the solver failed to resolve (a bug). The public API turns a non-None return into a fail-loud RuntimeError rather than returning a silently wrong cout.

Parameters:

state (FrontTrackerState) – Completed simulation state (after FrontTracker.run()).

Returns:

A short description (θ and mechanism) of the first monotonicity violation, or None when the resolved field conserves mass (the normal case).

Return type:

str | None

Notes

The scan stays strictly inside the inlet θ-window, so the benign out-of-window saturation clamp (a run whose output bins extend past the last injected mass) does not trip it.

fronttracking.validation#

Physics validation utilities for front tracking in (V, θ) coordinates.

This module provides functions to verify physical correctness of front-tracking simulations, including entropy conditions, concentration bounds, mass conservation, and event ordering. The solver runs in cumulative-flow coordinate θ = ∫flow(t') dt'; events on state.events carry "theta" (m³). Because flow 0 is enforced, θ is monotone non-decreasing in t, so θ-ordering and chronological ordering are equivalent.

Available functions:

  • verify_physics() - Run seven checks on a completed front-tracking result and return a summary dictionary with 'all_passed', the check counts, the 'failures' list, a per-check record list and a one-line 'summary': Lax entropy for every shock, no negative concentrations, output bounded by the inlet maximum, finite first-arrival θ, no NaN after spin-up, θ-ordered events, and mass conservation comparing an independent outlet integral plus the domain mass against the injected mass at θ_max. The mass-balance check uses max(rtol, _MASS_BALANCE_RTOL) because integrating a shock-bearing breakthrough curve is only first-order accurate. Passing verbose=False suppresses printing but returns the same dictionary.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.fronttracking.validation.verify_physics(structure, cout, cout_tedges, cin, *, verbose=True, rtol=1e-10)[source]#

Run comprehensive physics verification checks on front tracking results.

Performs the following checks:

  1. Entropy condition for all shocks

  2. No negative concentrations (within tolerance)

  3. Output concentration <= input maximum

  4. Finite first arrival θ

  5. No NaN values after spin-up period

  6. Events θ-ordered (equivalent to chronological under non-negative flow)

  7. Mass conservation: independent outlet integral + domain mass == inlet mass at θ_max

Parameters:
  • structure (dict) – Structure returned from infiltration_to_extraction_nonlinear_sorption. Must contain keys: 'waves', 'theta_first_arrival', 'events', and optionally 'tracker_state'.

  • cout (ArrayLike) – Bin-averaged output concentrations.

  • cout_tedges (DatetimeIndex) – Output time edges for bins (only used for the spin-up mask).

  • cin (ArrayLike) – Input concentrations.

  • verbose (bool, default: True) – If True, print detailed results. If False, only return summary. Default True.

  • rtol (float, default: 1e-10) – Relative tolerance for numerical checks. Default 1e-10. For the mass-balance check (7) the effective tolerance is max(rtol, _MASS_BALANCE_RTOL) because that check integrates a shock-bearing breakthrough curve and is only first-order accurate (see _MASS_BALANCE_RTOL).

Returns:

results – Dictionary containing:

  • 'all_passed': bool - True if all checks passed

  • 'n_checks': int - Total number of checks performed

  • 'n_passed': int - Number of checks that passed

  • 'failures': list of str - Description of failed checks (empty if all passed)

  • 'checks': list of dict - Per-check result records; each has 'name', 'passed', 'message' keys.

  • 'summary': str - One-line summary

Return type:

dict

Examples

results = verify_physics(structure, cout, cout_tedges, cin, verbose=False)
print(results["summary"])
assert results["all_passed"]

fronttracking.waves#

Wave Representation for Front Tracking in (V, θ) coordinates.

This module implements wave classes for representing characteristics, shocks, and rarefaction waves in the front tracking algorithm. Each wave stores its formation position in cumulative-flow coordinate θ = ∫flow(t') dt' and knows how to compute its position at any later θ.

In (V, θ) every wave velocity is a property of the sorption isotherm alone — flow does not enter wave dynamics. Time-varying flow is absorbed entirely into the θ(t) mapping at the API boundary, so no wave needs recreation when the flow rate changes. Attributes named theta_* are therefore cumulative flow [m³], never time; v_* are volumetric positions [m³] measured from the inlet face V = 0. Every module in gwtransport.fronttracking follows this convention.

Available functions:

  • Wave - Abstract base of the hierarchy. Holds the formation point (v_start, theta_start), the is_active flag and the theta_deactivation history marker, and requires position_at_theta, concentration_left, concentration_right and concentration_at_point from every subclass. Use was_active_at(theta) for retrospective queries; is_active describes only the current state.

  • CharacteristicWave - A single characteristic line carrying one constant concentration at the flow-free speed 1/R(c). Physically a contact discontinuity in a smooth region: the concentration value travels unchanged, and c_ahead records the state it separates from downstream.

  • ShockWave - A sharp front across which concentration jumps from c_left to c_right, formed where faster water overtakes slower water. Its speed is the Rankine-Hugoniot secant (c_R c_L)/(C_T(c_R) C_T(c_L)), constant in θ; satisfies_entropy tests the Lax condition.

  • RarefactionWave - An expansion fan spreading between c_head and c_tail, formed where slower water follows faster water. The interior is self-similar, R(c) = θ_start)/(V v_start), so head and tail move at the characteristic speeds 1/R(c_head) and 1/R(c_tail); construction is rejected when the head is not faster than the tail (that geometry is a compression, hence a shock).

  • DecayingShockWave - A shock with a fan on one side, formed when a rarefaction and a shock collide. The fan feeds the decay_side, whose concentration relaxes from c_decay_initial toward the fan’s far bound c_fan_tail while the other side stays at c_fixed, so the front decelerates along a curved trajectory instead of a straight line. The trajectory uses a closed form where one exists (Freundlich with c_fixed = 0 or n = 2, Langmuir and Brooks-Corey with c_fixed = 0) and a cached quadrature profile otherwise; theta_at_fan_exhaustion reports the θ at which the fan is spent and the wave hands off to a plain shock.

  • DoubleFanShockWave - A shock fed by a self-similar fan on both sides, formed when a fan boundary catches a decaying shock whose surviving side is itself a fan. Both side concentrations then relax as the front advances. The trajectory is closed form for Freundlich n = 2 with a shared apex position (the case every inlet-born fan produces) and a cached RK4 spline otherwise; a side ending is detected externally, as the shock face crossing its own fan boundary line.

  • Feeder - The boundary state on one side of a front: either a constant concentration or a bounded self-similar fan with apex (v_apex, theta_apex) spanning [c_a, c_b], evaluated by inverting R = θ_apex)/(v v_apex) and clamped in R-space so a query past the fan edge reads the plateau value. Feeders are the common currency of the interaction calculus in gwtransport.fronttracking.interactions.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

class gwtransport.fronttracking.waves.Feeder(c_a, c_b, is_const, v_apex=0.0, theta_apex=0.0, sorption=None, far_boundary_free=True)[source]#

Bases: object

One side’s boundary state feeding a front: a constant, or a bounded self-similar fan.

A const feeder ignores (v, θ) and returns its value everywhere. A fan feeder evaluates the self-similar retardation R = θ_apex)/(v v_apex) and inverts it to a concentration, clamped to the fan’s physical extent [c_a, c_b]. The clamp is monotonicity-agnostic (it clamps in R-space, so it is correct for both R-decreasing isotherms — Freundlich n>1, Langmuir, Brooks-Corey, van Genuchten-Mualem — and the R-increasing Freundlich n<1 mirror), and it is exactly what makes a fan feeder read the plateau concentration beyond the fan’s edge.

Feeders are the uniform currency of the interaction calculus: every wave exposes its sides as feeders, event handlers form a successor from (rear.left, front.right), and the reader evaluates the left feeder of the nearest downstream face.

c_a: float#

One physical boundary concentration of the fan (or the constant value).

c_b: float#

The other physical boundary concentration of the fan (unused for a constant).

is_const: bool#

Whether this is a constant state (True) or a self-similar fan (False).

v_apex: float = 0.0#

Fan apex position [m³] (ignored for a constant).

theta_apex: float = 0.0#

Fan apex cumulative flow [m³] (ignored for a constant).

sorption: NonlinearSorption | ConstantRetardation | None = None#

Sorption model used to invert R (required for a fan; None for a constant).

far_boundary_free: bool = True#

Whether the fan’s far edge is a free plateau boundary (a collision line) rather than already terminated by another shock. Propagated through merges so a wave born onto a fan whose far end another wave owns does not re-expose a phantom boundary line.

classmethod constant(c)[source]#

Return a constant-concentration feeder.

Return type:

Feeder

classmethod fan(v_apex, theta_apex, c_a, c_b, sorption, *, far_boundary_free=True)[source]#

Return a bounded self-similar fan feeder with apex (v_apex, theta_apex) spanning [c_a, c_b].

Return type:

Feeder

value(v, theta)[source]#

Concentration this feeder supplies at (v, θ) (clamped to the fan extent).

Return type:

float

__init__(c_a, c_b, is_const, v_apex=0.0, theta_apex=0.0, sorption=None, far_boundary_free=True)#
class gwtransport.fronttracking.waves.Wave(theta_start, v_start, *, is_active=True, theta_deactivation=inf)[source]#

Bases: ABC

Abstract base class for all wave types in front tracking.

All waves share common attributes and must implement methods for computing position and concentration. Waves can be active or inactive (deactivated waves are preserved for history but don’t participate in future interactions).

theta_start: float#

Cumulative flow at which the wave forms [m³].

v_start: float#

Position at which the wave forms [m³].

is_active: bool = True#

Whether wave is currently active (in the solver’s event-loop sense).

theta_deactivation: float = inf#

Cumulative flow at which the wave was deactivated (default +∞).

Historical record set by collision handlers when a wave is replaced (e.g., a parent rarefaction superseded by a DecayingShockWave). is_active = False is the “current state” flag the solver uses for its event loop; theta_deactivation is the moment in θ-history when the wave stopped contributing. Retrospective queries (any θ in the past) must use was_active_at(theta) instead of is_active so that compute_domain_mass etc. correctly attribute c at v_outlet during the wave’s lifetime even after later events have deactivated the wave.

was_active_at(theta)[source]#

Whether the wave was active at cumulative flow theta (geometric truth).

Use for retrospective queries — is_active reflects only the wave’s current (post-simulation) state, which is wrong for compute_domain_mass and similar at θ before a deactivation event.

Parameters:

theta (float) – Cumulative flow at which to query historical activity [m³].

Returns:

True for theta_start <= theta < theta_deactivation. A wave constructed with is_active=False and no recorded theta_deactivation (default +∞) is treated as never-active — e.g., synthetic test fixtures that want the wave excluded from dispatch entirely.

Return type:

bool

deactivate(theta)[source]#

Mark the wave inactive at cumulative flow theta (collision handler API).

Sets both is_active = False (solver event-loop flag) and theta_deactivation = theta (historical record for retrospective was_active_at queries).

Parameters:

theta (float) – Cumulative flow at which the wave is deactivated [m³].

Return type:

None

abstractmethod position_at_theta(theta)[source]#

Compute wave position at cumulative flow θ.

Parameters:

theta (float) – Cumulative flow [m³].

Returns:

position – Position [m³], or None if θ < θ_start or θ >= theta_deactivation. (Past-θ queries respect the wave’s historical lifetime; current-state queries before deactivation behave identically to the is_active check.)

Return type:

float | None

abstractmethod concentration_left()[source]#

Concentration on the left (upstream) side of the wave.

Return type:

float

abstractmethod concentration_right()[source]#

Concentration on the right (downstream) side of the wave.

Return type:

float

abstractmethod concentration_at_point(v, theta)[source]#

Compute concentration at point (v, θ) if the wave controls it.

Returns:

concentration – Concentration [mass/volume] if the wave controls this point, None otherwise.

Return type:

float | None

__init__(theta_start, v_start, *, is_active=True, theta_deactivation=inf)#
class gwtransport.fronttracking.waves.CharacteristicWave(theta_start, v_start, concentration, sorption, *, is_active=True, theta_deactivation=inf, c_ahead=0.0)[source]#

Bases: Wave

Characteristic line along which concentration is constant.

In smooth regions, concentration travels at speed 1/R(C) in (V, θ) coordinates. Along each characteristic line, the concentration value is constant. This is the fundamental solution element for hyperbolic conservation laws.

Examples

>>> sorption = FreundlichSorption(
...     k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
... )
>>> char = CharacteristicWave(
...     theta_start=0.0, v_start=0.0, concentration=5.0, sorption=sorption
... )
>>> speed = char.speed()
>>> bool(np.isclose(char.position_at_theta(1000.0), speed * 1000.0))
True
concentration: float#

Constant concentration carried on the upstream (behind) side [mass/volume].

sorption: NonlinearSorption | ConstantRetardation#

Sorption model determining the speed.

c_ahead: float = 0.0#

Concentration on the downstream (ahead) side — the state the contact advances into.

A contact separates the carried concentration (behind, upstream) from c_ahead (ahead, downstream, the pre-existing state). The solver sets it to the previous inlet value; it defaults to 0 (the virgin initial condition) for a lone contact. The reader sweep uses it as the contact’s downstream feeder.

__post_init__()[source]#

Cache the (immutable) characteristic speed once.

Return type:

None

speed()[source]#

Characteristic speed dV/dθ = 1/R(C) (+∞ at a saturated state, R = 0).

Return type:

float

position_at_theta(theta)[source]#

Position at cumulative flow θ.

V(θ) = v_start + speed * - θ_start).

Return type:

float | None

concentration_left()[source]#

Concentration on the left (upstream) side; equals the carried value.

Return type:

float

concentration_right()[source]#

Concentration on the right (downstream) side; equals the carried value.

Return type:

float

concentration_at_point(v, theta)[source]#

Return the carried concentration if the characteristic has reached v by θ.

Return type:

float | None

__init__(theta_start, v_start, concentration, sorption, *, is_active=True, theta_deactivation=inf, c_ahead=0.0)#
class gwtransport.fronttracking.waves.ShockWave(theta_start, v_start, c_left, c_right, sorption, *, is_active=True, theta_deactivation=inf)[source]#

Bases: Wave

Shock wave (discontinuity) with jump in concentration.

Shocks form when faster water overtakes slower water, creating a sharp front. In (V, θ) the shock speed is given by the Rankine-Hugoniot condition and is independent of flow:

dV_s/ = (C_R - C_L) / (C_T(C_R) - C_T(C_L))

Examples

>>> sorption = FreundlichSorption(
...     k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
... )
>>> shock = ShockWave(
...     theta_start=0.0,
...     v_start=0.0,
...     c_left=10.0,
...     c_right=2.0,
...     sorption=sorption,
... )
>>> shock.speed > 0
True
>>> shock.satisfies_entropy()
True
c_left: float#

Concentration upstream (behind) shock [mass/volume].

c_right: float#

Concentration downstream (ahead of) shock [mass/volume].

sorption: NonlinearSorption | ConstantRetardation#

Sorption model.

speed: float#

Shock speed dV/dθ; set in __post_init__.

__post_init__()[source]#

Compute shock speed from Rankine-Hugoniot in (V, θ).

Return type:

None

position_at_theta(theta)[source]#

Position at cumulative flow θ. Shock propagates linearly in θ.

Return type:

float | None

concentration_left()[source]#

Upstream concentration of the shock.

Return type:

float

concentration_right()[source]#

Downstream concentration of the shock.

Return type:

float

concentration_at_point(v, theta)[source]#

Return c_left if upstream of the shock at θ, c_right if downstream.

At the exact shock position the average is returned (convention; the shock is infinitesimally thin in practice).

Return type:

float | None

satisfies_entropy()[source]#

Check Lax entropy condition in (V, θ): λ_θ(C_L) s λ_θ(C_R).

Return type:

bool

__init__(theta_start, v_start, c_left, c_right, sorption, *, is_active=True, theta_deactivation=inf)#
class gwtransport.fronttracking.waves.RarefactionWave(theta_start, v_start, c_head, c_tail, sorption, *, is_active=True, theta_deactivation=inf)[source]#

Bases: Wave

Rarefaction (expansion fan) with smooth concentration gradient.

Rarefactions form when slower water follows faster water, creating an expanding region where concentration varies smoothly. In (V, θ) the solution is self-similar in (V - v_start) vs - θ_start):

R(C) = (θ - θ_start) / (V - v_start)

Head and tail propagate at flow-free speeds 1/R(C_head) and 1/R(C_tail).

Raises:

ValueError – If head speed <= tail speed (would be a compression, not a rarefaction).

Examples

>>> sorption = FreundlichSorption(
...     k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
... )
>>> raref = RarefactionWave(
...     theta_start=0.0,
...     v_start=0.0,
...     c_head=10.0,
...     c_tail=2.0,
...     sorption=sorption,
... )
>>> raref.head_speed() > raref.tail_speed()
True
>>> raref.contains_point(v=150.0, theta=2000.0)
True
c_head: float#

Concentration at leading edge (faster) [mass/volume].

c_tail: float#

Concentration at trailing edge (slower) [mass/volume].

sorption: NonlinearSorption | ConstantRetardation#

Sorption model (must be concentration-dependent).

__post_init__()[source]#

Cache head/tail celerities and verify this is a rarefaction (head faster than tail).

head_speed()[source]#

Speed of rarefaction head dV/dθ = 1/R(C_head) (+∞ at a saturated state, R = 0).

Return type:

float

tail_speed()[source]#

Speed of rarefaction tail dV/dθ = 1/R(C_tail) (+∞ at a saturated state, R = 0).

Return type:

float

head_position_at_theta(theta)[source]#

Position of rarefaction head at cumulative flow θ.

Return type:

float | None

tail_position_at_theta(theta)[source]#

Position of rarefaction tail at cumulative flow θ.

Return type:

float | None

position_at_theta(theta)[source]#

Head position (leading edge of rarefaction). Implements abstract Wave method.

Return type:

float | None

contains_point(v, theta)[source]#

Return True if (v, θ) lies between the fan’s tail and head.

Return type:

bool

concentration_left()[source]#

Upstream concentration is the trailing-edge value c_tail.

Return type:

float

concentration_right()[source]#

Downstream concentration is the leading-edge value c_head.

Return type:

float

concentration_at_point(v, theta)[source]#

Self-similar concentration inside the fan: R(C) = - θ_start)/(v - v_start).

Outside the fan returns None. For ConstantRetardation, rarefactions don’t form (all concentrations travel at the same speed), so this also returns None.

Return type:

float | None

Examples

>>> sorption = FreundlichSorption(
...     k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
... )
>>> raref = RarefactionWave(0.0, 0.0, 10.0, 2.0, sorption)
>>> c = raref.concentration_at_point(v=150.0, theta=2000.0)
>>> c is not None
True
>>> 2.0 <= c <= 10.0
True
__init__(theta_start, v_start, c_head, c_tail, sorption, *, is_active=True, theta_deactivation=inf)#
class gwtransport.fronttracking.waves.DecayingShockWave(theta_start, v_start, c_decay_initial, c_fixed, c_fan_tail, decay_side, v_origin, theta_origin, sorption, *, is_active=True, theta_deactivation=inf, fan_boundary_consumed=False, theta_fan_boundary_consumed=inf)[source]#

Bases: Wave

Merging shock with closed-form (or quadrature) trajectory in θ-space.

Formed when a rarefaction fan and a shock collide. The shock then has one side fed by the fan’s self-similar profile (the “decay” side) and the other side at the original outer state (the “fixed” side). Valid for any NonlinearSorption.

Two collision regimes are supported via decay_side:

  • 'left' (favorable head-collision): the rarefaction’s head (faster) catches a leading shock. After collision, the shock’s c_left decays from the rarefaction head value toward c_fan_tail (the unchanged downstream c_right is c_fixed).

  • 'right' (unfavorable tail-collision, n<1 mirrored): a trailing shock catches the rarefaction’s tail. After collision, the shock’s c_right decays from the rarefaction tail value toward c_fan_tail (the unchanged upstream c_left is c_fixed).

The wave is valid only while c_decay (c_fan_tail, c_decay_initial]; once c_decay reaches c_fan_tail the fan is exhausted (see the solver’s DSW_FAN_EXHAUSTED event).

Dispatch. _c_decay_at_theta_local is the single dispatch site (position, fan-exhaustion and outlet-crossing all route through it): a closed form is used where one exists, otherwise the per-wave cached numerical profile (_build_decay_profile()). No combination raises — any NonlinearSorption is valid. With θ_local := θ theta_origin measured from the rarefaction apex, α := ρ_b · k_f / n_por for Freundlich, and u_d := c_decay^(1/n):

  • Freundlich, c_fixed = 0 (general n > 0, n 1) — closed form: invariant θ_local · u_d^n = K · (n · u_d^(n-1) + α), position V_s(θ) = v_origin + n · K / u_d(θ).

  • Freundlich, c_fixed > 0, n = 2 (either decay orientation) — closed form: invariant (u_d - u_R)² · θ_local = K · (2 u_d + α) with u_R := c_fixed^(1/2), position V_s(θ) = v_origin + 2 K · u_d(θ) / (u_d - u_R)². The root of the quadratic in u_d is selected by the decay orientation (u_d > u_R shrinking, u_d < u_R growing); both are exact (verified to ~1e-14 vs a DOP853 integration).

  • Langmuir, c_fixed = 0 — closed form: invariant θ_local · c_d² = K · ((K_L + c_d)² + a) with a := ρ_b · s_max · K_L / n_por, position V_s(θ) = v_origin + K · (K_L + c_d)² / c_d².

  • Brooks-Corey, c_fixed = 0 — closed form: invariant θ_local R(c_decay)^{a/(a−1)} (R·S = 1/a constant), so R(c_d) = R(c0)·(θ_local/θ_local_coll)^{(a−1)/a}.

  • Every other (isotherm, c_fixed) combination (Freundlich c_fixed>0, n≠2, Langmuir/Brooks-Corey c_fixed>0, any van Genuchten) — cached numerical profile (_build_decay_profile()): the decay-agnostic invariant θ_local(c_d) = θ_local_coll · exp(∫ R'/[(1 R·S)·R] dc) with the symmetric secant speed S = (c c_fixed)/(C_T(c) C_T(c_fixed)), built once by composite quadrature and inverted for c_d(θ) by monotone spline interpolation.

Every path shares the fan-continuity identity V_s = v_origin + θ_local / R(c_decay), which position_at_theta and outlet_crossing_theta use uniformly across all isotherms.

The invariant constant K (closed-form Freundlich/Langmuir only) is set in __post_init__ from the collision IC (theta_start, c_decay_initial). theta_start/v_start are the collision coordinates; a fan-consistent construction has v_start = v_origin + (theta_start theta_origin)/R(c_decay_initial).

See also

ShockWave

Linear-θ shock (no decaying side).

RarefactionWave

Self-similar expansion fan.

c_decay_initial: float#

Concentration on the decaying side at θ=theta_start [mass/volume]. Non-negative.

A fully-drained collision value of 0 is floored to the shared dry-soil singularity floor _C_MIN so the retardation and secant-speed evaluations stay finite.

c_fixed: float#

Concentration on the non-decaying side [mass/volume]. Non-negative, constant in θ.

c_fan_tail: float#

Concentration at the fan’s far boundary [mass/volume]; bounds the decay. Non-negative.

The wave is valid only while c_decay (c_fan_tail, c_decay_initial]; at c_fan_tail the fan is exhausted.

decay_side: str#

'left' (favorable head-collision) or 'right' (n<1 mirrored).

v_origin: float#

Position of the rarefaction apex [m³].

theta_origin: float#

Cumulative flow at the rarefaction apex [m³]; strictly less than theta_start.

sorption: NonlinearSorption#

Sorption model (any concentration-dependent isotherm).

K: float#

Invariant constant set in __post_init__ (closed-form Freundlich c_fixed=0/n≈2 and Langmuir c_fixed=0 cases; nan for every numerical case).

fan_boundary_consumed: bool = False#

Whether the fan’s far-boundary line is owned from birth (a fan-entry successor rides a fan another wave already terminates downstream, so its boundary is never a free face).

theta_fan_boundary_consumed: float = inf#

Cumulative flow at which a free boundary line was later consumed by a wave entering the fan. Retrospective reader/event queries treat the boundary as free only for θ < theta_fan_boundary_consumed (historical truth, mirroring was_active_at).

__post_init__()[source]#

Validate inputs and compute the closed-form invariant K when applicable.

Return type:

None

c_decay_at_theta(theta)[source]#

Concentration on the decaying side at cumulative flow θ.

Returns None for θ < theta_start or when the wave is inactive; otherwise delegates to the single per-isotherm dispatch in _c_decay_at_theta_local.

Return type:

float | None

position_at_theta(theta)[source]#

Shock position V_s(θ) via the fan-continuity identity.

V_s = v_origin + θ_local / R(c_decay) for every isotherm. Returns None for θ < theta_start or when inactive.

Return type:

float | None

theta_at_fan_exhaustion()[source]#

Cumulative flow θ at which c_decay reaches c_fan_tail.

c_decay(θ) is strictly monotone from c_decay_initial toward c_fan_tail, so the exhaustion θ is well-defined. The crossing test is orientation-agnostic: it holds for both the shrinking decay (c_decay_initial > c_fan_tail) and the growing decay (c_decay_initial < c_fan_tail). Returns None when c_fan_tail is not strictly between c_fixed and c_decay_initial — e.g. full drying (c_fan_tail == c_fixed), where the decay asymptotically merges with the fixed state and no finite exhaustion event occurs.

Returns:

Cumulative flow θ at exhaustion, or None if not reached.

Return type:

float | None

__init__(theta_start, v_start, c_decay_initial, c_fixed, c_fan_tail, decay_side, v_origin, theta_origin, sorption, *, is_active=True, theta_deactivation=inf, fan_boundary_consumed=False, theta_fan_boundary_consumed=inf)#
outlet_crossing_theta(v_outlet)[source]#

Cumulative flow at which V_s = v_outlet.

Returns None if the outlet is upstream of the wave’s birth position or no crossing exists in (theta_start, +∞). The wave’s current activity flag is not consulted — callers asking retrospectively about a historical crossing need the answer regardless of subsequent deactivation.

The closed-form Freundlich/Langmuir cases invert the fan-continuity identity V_s v_origin = θ_local / R(c_decay) analytically (valid only when _c_decay_at_theta_local itself uses the closed form, so the same conditions are mirrored here); every other case inverts the monotone V_s(θ) via brentq.

Return type:

float | None

concentration_left()[source]#

Concentration on the left (upstream) side at θ=theta_start.

For decay_side='left' returns the decaying c at the collision moment; for decay_side='right' returns the fixed side.

Return type:

float

concentration_right()[source]#

Concentration on the right (downstream) side at θ=theta_start.

For decay_side='right' returns the decaying c at the collision moment; for decay_side='left' returns the fixed side.

Return type:

float

concentration_at_point(v, theta)[source]#

Concentration at (v, θ) if controlled by this decaying shock.

Three regions:

  1. v == V_s(θ) (within FP): average of decay-side and fixed-side c.

  2. v > V_s(θ) (downstream): fixed-side c if decay_side='left'; decay-side c at θ if decay_side='right'.

  3. v < V_s(θ) (upstream, inside the fan): the fan’s self-similar concentration R(c) = theta_origin)/(v v_origin). Outside the fan — i.e. the decay-side characteristic from the apex hasn’t reached v yet, OR the point lies beyond the c_fan_tail boundary (the fan’s far edge) — returns None.

Returns None for θ < theta_start or inactive waves.

Return type:

float | None

class gwtransport.fronttracking.waves.DoubleFanShockWave(theta_start, v_start, left_feeder, right_feeder, sorption, *, is_active=True, theta_deactivation=inf, left_boundary_consumed=False, right_boundary_consumed=False, theta_left_boundary_consumed=inf, theta_right_boundary_consumed=inf)[source]#

Bases: Wave

Shock fed by a self-similar fan on BOTH sides (a doubly-fed front).

Formed when a fan boundary (rarefaction head/tail, or another fan-fed shock) catches a DecayingShockWave whose surviving side is itself a fan — the merged front then has a fan feeder on each side. The shock trajectory solves

\[\frac{dV}{d\theta} = S\bigl(c_L(V,\theta),\, c_R(V,\theta)\bigr), \qquad R(c_i) = \frac{\theta - \theta_{{\rm apex},i}}{V - v_{{\rm apex},i}},\]

with S the Rankine-Hugoniot secant and each c_i the self-similar value of its fan.

Closed form (Freundlich ``n = 2``, shared apex position ``v_L = v_R = v_o``). With u_i = \sqrt{c_i} = A V'/(2(\tau_i - V')) (V' = V - v_o, \tau_i = \theta - \theta_{{\rm apex},i}, A = \rho_b k_f/n_{por}), the product K = u_L u_R is a first integral of the ODE, and the trajectory is the physical root of

\[(A^2 - 4K)\,V'^2 + 4K(\tau_L + \tau_R)\,V' - 4K\,\tau_L\,\tau_R = 0, \qquad 0 < V' < \min(\tau_L, \tau_R).\]

Every fan born at the inlet shares v_o = 0, so inlet-driven inputs use the closed form. General fallback (distinct apex positions, or a non-n=2 isotherm): the ODE is integrated once by fixed-step RK4 (\Delta\theta = \text{fan age}/DFSW_RK_SUBSTEPS, speed-independent since the fans vary on the scale of their age) into a cached monotone spline. Both paths answer position, side concentrations and outlet crossing uniformly (side exhaustion is detected externally, as the shock face crossing its own fan boundary).

See also

DecayingShockWave

One fan side; the doubly-fed front degrades to this on side exhaustion.

Feeder

The bounded-fan side descriptor this wave carries.

__init__(theta_start, v_start, left_feeder, right_feeder, sorption, *, is_active=True, theta_deactivation=inf, left_boundary_consumed=False, right_boundary_consumed=False, theta_left_boundary_consumed=inf, theta_right_boundary_consumed=inf)#
left_feeder: Feeder#

Left (upstream) side feeder — a fan.

right_feeder: Feeder#

Right (downstream) side feeder — a fan.

sorption: NonlinearSorption#

Sorption model (concentration-dependent).

left_boundary_consumed: bool = False#

Whether the left fan’s far-boundary line is owned from birth (not a free collision face).

right_boundary_consumed: bool = False#

Whether the right fan’s far-boundary line is owned from birth (not a free collision face).

theta_left_boundary_consumed: float = inf#

Cumulative flow at which a free left boundary was later consumed (historical, see DSW).

theta_right_boundary_consumed: float = inf#

Cumulative flow at which a free right boundary was later consumed (historical, see DSW).

__post_init__()[source]#

Classify closed-form vs numerical and set the first integral K.

Return type:

None

property theta_origin_left: float#

Cumulative flow at the left fan’s apex [m³].

property theta_origin_right: float#

Cumulative flow at the right fan’s apex [m³].

position_at_theta(theta)[source]#

Shock position V_s(θ); None for θ < theta_start or when inactive.

Return type:

float | None

concentration_left()[source]#

Left-side concentration at the collision moment.

Return type:

float

concentration_right()[source]#

Right-side concentration at the collision moment.

Return type:

float

outlet_crossing_theta(v_outlet)[source]#

Cumulative flow at which V_s = v_outlet (monotone, inverted by brentq).

θ_local is measured from the older of the two fan apexes, so the shared bracket-then-brentq inversion sees a positive seed.

Return type:

float | None

concentration_at_point(v, theta)[source]#

Concentration at (v, θ) if controlled by this doubly-fed shock.

Left of the shock face the left fan controls; right of it the right fan; at the face the average. Outside both fans’ physical extents returns None (another wave owns the point).

Return type:

float | None

gamma#

Gamma Distribution Utilities for Aquifer Pore Volume Heterogeneity.

This module provides utilities for working with gamma distributions to model heterogeneous aquifer pore volumes in groundwater transport analysis. The gamma distribution offers a flexible three-parameter model (shape, scale, location) for representing the natural variability in flow path lengths and residence times within aquifer systems. In heterogeneous aquifers, water travels through multiple flow paths with different pore volumes; the location parameter additionally represents a guaranteed minimum pore volume (for example, immobile porosity or a geometric minimum travel distance).

Parameterizations#

Two equivalent parameterizations are supported, each optionally with a location shift:

  • (mean, std, loc) — physically intuitive. mean is the total expected value, std is the spread (invariant under shift), and loc is the lower bound of support. Constraint: 0 <= loc < mean.

  • (alpha, beta, loc) — scipy-style. alpha is shape, beta is scale, and loc is the lower bound of support. Constraint: alpha > 0, beta > 0, loc >= 0.

Conversion formulas (with constraint mean > loc):

alpha = ((mean - loc) / std) ** 2 beta = std ** 2 / (mean - loc) mean = alpha * beta + loc std = sqrt(alpha) * beta

When loc == 0 the three-parameter model reduces to the standard two-parameter gamma distribution.

Streamtube discretization by bins() is always into bins of equal probability mass.

Available functions:

  • parse_parameters() - Resolve either (mean, std) or (alpha, beta), together with an optional loc, into the validated (alpha, beta, loc) triple the rest of the package works with. Exactly one of the two pairs must be supplied; positivity, 0 <= loc < mean and finiteness are enforced, otherwise ValueError is raised.

  • mean_std_loc_to_alpha_beta() - Convert the physically intuitive (mean, std, loc) parameters to gamma shape and scale from the excess-over-loc moments, alpha = ((mean - loc) / std) ** 2 and beta = std ** 2 / (mean - loc).

  • bins() - Split the (shifted) gamma distribution at the n_bins + 1 uniform quantile edges, so every bin (streamtube) carries probability mass 1 / n_bins. Returns a dict of arrays: the bin edges (lower_bound, upper_bound, edges; the first lower bound is loc and the last upper bound is infinite), the per-bin conditional mean pore volume (expected_values) that serves as the streamtube pore volume in transport calculations, and the per-bin probability_mass.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.gamma.parse_parameters(*, mean=None, std=None, loc=0.0, alpha=None, beta=None)[source]#

Parse parameters for gamma distribution.

Either (mean, std) or (alpha, beta) must be provided. loc is optional and defaults to 0, which recovers the standard two-parameter gamma distribution.

Parameters:
  • mean (float | None, default: None) – Mean of the gamma distribution. Must be strictly greater than loc.

  • std (float | None, default: None) – Standard deviation of the gamma distribution. Must be positive. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for what std represents depending on APVD source. std is invariant under the loc shift.

  • loc (float, default: 0.0) – Location (horizontal shift) of the gamma distribution; the lower bound of support. Must satisfy loc >= 0 and, when mean is supplied, loc < mean. Default is 0.0.

  • alpha (float | None, default: None) – Shape parameter of gamma distribution (must be > 0).

  • beta (float | None, default: None) – Scale parameter of gamma distribution (must be > 0).

Return type:

tuple[float, float, float]

Returns:

  • alpha (float) – Shape parameter of gamma distribution.

  • beta (float) – Scale parameter of gamma distribution.

  • loc (float) – Location parameter of gamma distribution.

Raises:

ValueError – If neither (mean, std) nor (alpha, beta) is provided, if both pairs are provided, if only one of a pair is provided, if alpha or beta are not positive, if loc is negative, if the resolved alpha, beta, or loc is not finite, or if mean <= loc.

gwtransport.gamma.mean_std_loc_to_alpha_beta(*, mean, std, loc=0.0)[source]#

Convert mean, standard deviation, and location of gamma distribution to shape/scale.

The two-parameter shape/scale representation (alpha, beta) is derived from the excess-over-loc moments: mean_excess = mean - loc, std_excess = std.

Parameters:
  • mean (float) – Mean of the gamma distribution. Must be strictly greater than loc.

  • std (float) – Standard deviation of the gamma distribution. Must be positive. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for what std represents depending on APVD source. std is invariant under the loc shift.

  • loc (float, default: 0.0) – Location (horizontal shift) of the gamma distribution. Must satisfy 0 <= loc < mean. Default is 0.0.

Return type:

tuple[float, float]

Returns:

  • alpha (float) – Shape parameter of gamma distribution.

  • beta (float) – Scale parameter of gamma distribution.

Raises:

ValueError – If std is not positive, if loc is negative, or if mean <= loc.

See also

parse_parameters

Parse and validate gamma distribution parameters.

Examples

>>> from gwtransport.gamma import mean_std_loc_to_alpha_beta
>>> mean_pore_volume = 30000.0  # m³
>>> std_pore_volume = 8100.0  # m³
>>> alpha, beta = mean_std_loc_to_alpha_beta(
...     mean=mean_pore_volume, std=std_pore_volume
... )
>>> print(f"Shape parameter (alpha): {alpha:.2f}")
Shape parameter (alpha): 13.72
>>> print(f"Scale parameter (beta): {beta:.2f}")
Scale parameter (beta): 2187.00

With a 5000 m³ minimum pore volume:

>>> alpha, beta = mean_std_loc_to_alpha_beta(mean=30000.0, std=8100.0, loc=5000.0)
>>> print(f"Shape parameter (alpha): {alpha:.2f}")
Shape parameter (alpha): 9.53
>>> print(f"Scale parameter (beta): {beta:.2f}")
Scale parameter (beta): 2624.40
gwtransport.gamma.bins(*, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100)[source]#

Divide a (shifted) gamma distribution into equal-probability-mass bins and compute bin properties.

The distribution is split at the n_bins + 1 uniform quantile edges, so every bin (streamtube) carries probability mass 1 / n_bins.

Parameters:
  • mean (float | None, default: None) – Mean of the gamma distribution. Must be strictly greater than loc.

  • std (float | None, default: None) – Standard deviation of the gamma distribution. Must be positive.

  • loc (float, default: 0.0) – Location (horizontal shift) of the gamma distribution; the lower bound of support. Must satisfy 0 <= loc < mean (or loc >= 0 when using alpha/beta). Default is 0.0.

  • alpha (float | None, default: None) – Shape parameter of gamma distribution (must be > 0).

  • beta (float | None, default: None) – Scale parameter of gamma distribution (must be > 0).

  • n_bins (int, default: 100) – Number of bins to divide the gamma distribution (must be >= 2). Default is 100.

Returns:

Dictionary with keys of type str and values of type numpy.ndarray:

  • lower_bound: lower bounds of bins (first one equals loc)

  • upper_bound: upper bounds of bins (last one is inf)

  • edges: bin edges (lower_bound[0], upper_bound[0], …, upper_bound[-1])

  • expected_values: expected values in bins. Is what you would expect to observe if you repeatedly sampled from the probability distribution, but only considered samples that fall within that particular bin.

  • probability_mass: probability mass in bins (invariant under loc shift).

Return type:

dict[str, NDArray[floating]]

Raises:

ValueError – If n_bins is not greater than 1, or if parameter validation in parse_parameters() fails. Also raised for numerically-degenerate requests that would otherwise return silently-wrong structure: an alpha so large that alpha + 1 == alpha in float64 relative to the 1 / n_bins quantile gap (the distribution is numerically a point mass), or a bin whose expected value underflows to loc.

See also

mean_std_loc_to_alpha_beta

Convert mean/std/loc to alpha/beta parameters.

gwtransport.advection.gamma_infiltration_to_extraction

Use bins for transport modeling.

Gamma Distribution Model

Two-parameter pore volume model.

Shifted Gamma Distribution (Minimum Pore Volume)

Shifted gamma with minimum pore volume.

Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity

What std represents (macrodispersion vs total spreading).

8. Gamma Distribution Adequacy

When gamma distribution is adequate.

Examples

Create equal-mass bins for a gamma distribution:

>>> from gwtransport.gamma import bins
>>> result = bins(mean=30000.0, std=8100.0, n_bins=5)
>>> print(f"Number of bins: {len(result['probability_mass'])}")
Number of bins: 5

With a location parameter representing a minimum pore volume:

>>> result = bins(mean=30000.0, std=8100.0, loc=5000.0, n_bins=5)
>>> float(result["edges"][0])
5000.0

logremoval#

Log Removal Calculations for First-Order Decay Processes.

This module provides utilities to calculate log removal values from first-order decay processes, including pathogen inactivation and radioactive decay. The module supports basic log removal calculations and parallel flow arrangements where multiple flow paths operate simultaneously.

First-Order Decay Model#

The log removal from any first-order decay process is:

Log Removal = log10_decay_rate * residence_time

where log10_decay_rate has units [log10/day] and residence_time has units [days]. This is equivalent to exponential decay C_out/C_in = 10^(-mu * t), where mu is the log10 decay rate and t is residence time. The natural-log decay rate constant lambda [1/day] is related to mu by lambda = mu * ln(10).

This model applies to any process that follows first-order kinetics:

  • Pathogen inactivation: viruses, bacteria, and protozoa lose infectivity over time

  • Radioactive decay: isotopes used for groundwater dating (tritium, CFC, SF6)

  • Chemical degradation: first-order breakdown of contaminants

Pathogen Removal in Bank Filtration#

For pathogen removal during soil passage, total removal consists of two distinct mechanisms (Schijven and Hassanizadeh, 2000):

  1. Inactivation (time-dependent): Pathogens lose infectivity over time through biological decay. This follows first-order kinetics and is modeled by this module as LR_decay = log10_decay_rate * residence_time. The inactivation rate depends strongly on temperature and pathogen type.

  2. Attachment (geometry-dependent): Pathogens are physically removed by adsorption to soil grains and straining. This depends on aquifer geometry, distance, soil properties, and pH, and is NOT modeled by this module. Users should add this component separately based on site-specific data.

Total log removal = LR_decay (this module) + LR_attachment (user-specified).

At the Castricum dune recharge site, Schijven et al. (1999) found that attachment contributed approximately 97% of total MS2 removal, with inactivation contributing only 3%. Inactivation rates for common model viruses at 10 degrees C are typically 0.02-0.11 log10/day (Schijven and Hassanizadeh, 2000, Table 7).

Gamma-distribution parameter notation#

Several functions are parameterized by a gamma distribution. The parameter prefix marks which physical quantity is gamma-distributed, because two distinct quantities appear here:

  • rt_alpha / rt_beta / rt_loc (or the equivalent rt_mean / rt_std / rt_loc) parameterize the gamma distribution of the residence time (used by gamma_pdf(), gamma_cdf(), gamma_mean()).

  • apv_alpha / apv_beta / apv_loc (or the equivalent apv_mean / apv_std / apv_loc) parameterize the gamma distribution of the aquifer pore volume (used by gamma_find_flow_for_target_mean()).

These prefixes are intentional and load-bearing: residence time and pore volume are different quantities, so a bare alpha / beta / loc would be ambiguous in this module. Both the shape/scale and the mean/std pairs are validated through gwtransport.gamma.parse_parameters(), so invalid parameters (e.g. a negative shape) raise ValueError rather than silently returning an unphysical result.

Available functions:

  • residence_time_to_log_removal() - Multiply residence times [days] by a log10 decay rate [log10/day] to get log removal, returning an array of the same shape as the input. Negative residence times or a negative rate give negative log removal; the caller interprets the sign.

  • decay_rate_to_log10_decay_rate() - Convert a natural-log first-order decay rate constant lambda [1/day] to a log10 decay rate mu [log10/day] via mu = lambda / ln(10).

  • log10_decay_rate_to_decay_rate() - Convert a log10 decay rate mu [log10/day] to a natural-log first-order decay rate constant lambda [1/day] via lambda = mu * ln(10).

  • parallel_mean() - Combine the log removals of parallel flow paths into the single log removal of their blended outflow, -log10(sum(F_i * 10^(-LR_i))). Flow fractions default to an equal split and must sum to 1.0 along the reduction axis; axis=None reduces over the flattened input and returns a scalar.

  • gamma_pdf() - Probability density of log removal when the residence time is (shifted) gamma-distributed: a gamma density with shape rt_alpha, scale log10_decay_rate * rt_beta, and location log10_decay_rate * rt_loc, evaluated at the requested log removal values.

  • gamma_cdf() - Cumulative distribution of log removal for the same shifted-gamma residence time, i.e. the fraction of the extracted water whose log removal does not exceed r.

  • gamma_mean() - Effective (flow-weighted, concentration-mixing) mean log removal for gamma-distributed residence time, log10_decay_rate * rt_loc + rt_alpha * log10(1 + rt_beta * log10_decay_rate * ln(10)). This is the continuous counterpart of parallel_mean() and lies below the arithmetic mean log10_decay_rate * (rt_alpha * rt_beta + rt_loc), because short-residence-time paths dominate the mixed outflow.

  • gamma_find_flow_for_target_mean() - Invert gamma_mean() for the flow rate that attains a target effective mean log removal, given a gamma-distributed aquifer pore volume (hence the apv_ prefix; dividing the pore volume by the flow gives the residence time). Closed form when apv_loc == 0, otherwise a bracketed scipy.optimize.brentq() root in 1 / flow. Requires a positive target_mean and a positive log10_decay_rate.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.logremoval.residence_time_to_log_removal(*, residence_times, log10_decay_rate)[source]#

Compute log removal given residence times and a log10 decay rate.

Log Removal = log10_decay_rate * residence_time, equivalent to the exponential decay C_out/C_in = 10^(-log10_decay_rate * residence_time).

Parameters:
  • residence_times (ArrayLike) – Residence times (days), of any shape. Negative values produce negative log removal (mathematical amplification); the caller interprets the sign.

  • log10_decay_rate (float) – Log10 decay rate mu (log10/day). Negative values correspond to first-order production rather than decay.

Returns:

log_removals – Log removal values, same shape as residence_times. Log 1 is a 90% reduction, log 2 a 99% reduction, log 3 a 99.9% reduction.

Return type:

NDArray[floating]

See also

decay_rate_to_log10_decay_rate

Convert natural-log decay rate to log10 decay rate

gamma_mean

Effective mean log removal for gamma-distributed residence times

gwtransport.residence_time.full

Compute residence times from flow and pore volume

Residence Time

Time in aquifer determines pathogen contact time

Examples

>>> from gwtransport.logremoval import residence_time_to_log_removal
>>> residence_time_to_log_removal(
...     residence_times=[10.0, 20.0, 50.0], log10_decay_rate=0.2
... )
array([ 2.,  4., 10.])
gwtransport.logremoval.decay_rate_to_log10_decay_rate(decay_rate)[source]#

Convert a natural-log decay rate constant to a log10 decay rate: mu = lambda / ln(10).

Parameters:

decay_rate (float) – Natural-log first-order decay rate constant lambda (1/day), e.g. np.log(2) / half_life.

Returns:

log10_decay_rate – Log10 decay rate mu (log10/day).

Return type:

float

See also

log10_decay_rate_to_decay_rate

Inverse conversion

Examples

>>> import numpy as np
>>> from gwtransport.logremoval import decay_rate_to_log10_decay_rate
>>> decay_rate_to_log10_decay_rate(np.log(2) / 30)
np.float64(0.01003...)
gwtransport.logremoval.log10_decay_rate_to_decay_rate(log10_decay_rate)[source]#

Convert a log10 decay rate to a natural-log decay rate constant: lambda = mu * ln(10).

Parameters:

log10_decay_rate (float) – Log10 decay rate mu (log10/day).

Returns:

decay_rate – Natural-log first-order decay rate constant lambda (1/day).

Return type:

float

See also

decay_rate_to_log10_decay_rate

Inverse conversion

Examples

>>> from gwtransport.logremoval import log10_decay_rate_to_decay_rate
>>> log10_decay_rate_to_decay_rate(0.2)
np.float64(0.4605...)
gwtransport.logremoval.parallel_mean(*, log_removals, flow_fractions=None, axis=None)[source]#

Calculate the weighted average log removal for a system with parallel flows.

This function computes the overall log removal efficiency of a parallel filtration system. If flow_fractions is not provided, it assumes equal distribution of flow across all paths.

The calculation uses the formula:

Total Log Removal = -log10(sum(F_i * 10^(-LR_i)))

Where: - F_i = fraction of flow through system i (decimal, sum to 1.0) - LR_i = log removal of system i

Parameters:
  • log_removals (ArrayLike) – Array of log removal values for each parallel flow. Each value represents the log10 reduction of pathogens. For multi-dimensional arrays, the parallel mean is computed along the specified axis.

  • flow_fractions (ArrayLike | None, default: None) – Array of flow fractions for each parallel flow. Must sum to 1.0 along the specified axis and have compatible shape with log_removals. If None, equal flow distribution is assumed (default is None).

  • axis (int | None, default: None) – Axis along which to compute the parallel mean for multi-dimensional arrays. If None, the reduction matches the way np.mean / np.sum treat axis=None: the parallel mean is computed over the flattened input (default is None).

Returns:

The combined log removal value for the parallel system. Returns a scalar when axis=None, otherwise an array with the specified axis removed.

Return type:

floating | NDArray[floating]

Raises:

ValueError – If flow_fractions does not sum to 1.0 along the specified axis.

See also

residence_time_to_log_removal

Compute log removal from residence times

Notes

Log removal is a logarithmic measure of pathogen reduction:

  • Log 1 = 90% reduction

  • Log 2 = 99% reduction

  • Log 3 = 99.9% reduction

For parallel flows, the combined removal is typically less effective than the best individual removal but better than the worst. For systems in series, log removals would be summed directly.

Examples

>>> import numpy as np
>>> from gwtransport.logremoval import parallel_mean
>>> # Three parallel streams with equal flow and log removals of 3, 4, and 5
>>> log_removals = np.array([3, 4, 5])
>>> parallel_mean(log_removals=log_removals)
np.float64(3.431798275933005)
>>> # Two parallel streams with weighted flow
>>> log_removals = np.array([3, 5])
>>> flow_fractions = np.array([0.7, 0.3])
>>> parallel_mean(log_removals=log_removals, flow_fractions=flow_fractions)
np.float64(3.153044674980176)
>>> # Multi-dimensional array: parallel mean along axis 1
>>> log_removals_2d = np.array([[3, 4, 5], [2, 3, 4]])
>>> parallel_mean(log_removals=log_removals_2d, axis=1)
array([3.43179828, 2.43179828])
gwtransport.logremoval.gamma_pdf(*, r, rt_alpha=None, rt_beta=None, rt_loc=0.0, rt_mean=None, rt_std=None, log10_decay_rate)[source]#

Compute the PDF of log removal given (shifted) gamma-distributed residence time.

With residence time T = T0 + rt_loc where T0 ~ Gamma(rt_alpha, rt_beta), the log removal R = mu * T follows a shifted gamma distribution with shape rt_alpha, scale mu * rt_beta, and location mu * rt_loc.

The residence-time distribution is specified with either (rt_alpha, rt_beta) or (rt_mean, rt_std) (optionally shifted by rt_loc); both are routed through gwtransport.gamma.parse_parameters().

Parameters:
  • r (ArrayLike) – Log removal values at which to compute the PDF.

  • rt_alpha (float | None, default: None) – Shape parameter of the gamma distribution for residence time. Must be positive.

  • rt_beta (float | None, default: None) – Scale parameter of the gamma distribution for residence time (days). Must be positive.

  • rt_loc (float, default: 0.0) – Location (minimum residence time, days) of the residence time distribution. Must be non-negative. Default is 0.0.

  • rt_mean (float | None, default: None) – Mean residence time (days). Alternative to rt_alpha; supply with rt_std. Must be strictly greater than rt_loc.

  • rt_std (float | None, default: None) – Standard deviation of the residence time (days). Alternative to rt_beta; supply with rt_mean. Must be positive.

  • log10_decay_rate (float) – Log10 decay rate mu (log10/day). Relates residence time to log removal via R = mu * T.

Returns:

pdf – PDF values corresponding to the input r values.

Return type:

NDArray[floating]

Raises:

ValueError – If parameter validation in gwtransport.gamma.parse_parameters() fails (e.g. rt_loc negative, non-positive shape/scale, or neither/both parameter pairs supplied).

See also

gamma_cdf

Cumulative distribution function of log removal

gamma_mean

Mean of the log removal distribution

gwtransport.logremoval.gamma_cdf(*, r, rt_alpha=None, rt_beta=None, rt_loc=0.0, rt_mean=None, rt_std=None, log10_decay_rate)[source]#

Compute the CDF of log removal given (shifted) gamma-distributed residence time.

With residence time T = T0 + rt_loc where T0 ~ Gamma(rt_alpha, rt_beta), the CDF is P(R <= r) = P(mu*(T0 + rt_loc) <= r) = P(T0 <= (r - mu*rt_loc)/mu) which is the CDF of a shifted gamma distribution with location mu * rt_loc.

The residence-time distribution is specified with either (rt_alpha, rt_beta) or (rt_mean, rt_std) (optionally shifted by rt_loc); both are routed through gwtransport.gamma.parse_parameters().

Parameters:
  • r (ArrayLike) – Log removal values at which to compute the CDF.

  • rt_alpha (float | None, default: None) – Shape parameter of the gamma distribution for residence time. Must be positive.

  • rt_beta (float | None, default: None) – Scale parameter of the gamma distribution for residence time (days). Must be positive.

  • rt_loc (float, default: 0.0) – Location (minimum residence time, days) of the residence time distribution. Must be non-negative. Default is 0.0.

  • rt_mean (float | None, default: None) – Mean residence time (days). Alternative to rt_alpha; supply with rt_std. Must be strictly greater than rt_loc.

  • rt_std (float | None, default: None) – Standard deviation of the residence time (days). Alternative to rt_beta; supply with rt_mean. Must be positive.

  • log10_decay_rate (float) – Log10 decay rate mu (log10/day). Relates residence time to log removal via R = mu * T.

Returns:

cdf – CDF values corresponding to the input r values.

Return type:

NDArray[floating]

Raises:

ValueError – If parameter validation in gwtransport.gamma.parse_parameters() fails (e.g. rt_loc negative, non-positive shape/scale, or neither/both parameter pairs supplied).

See also

gamma_pdf

Probability density function of log removal

gamma_mean

Mean of the log removal distribution

gwtransport.logremoval.gamma_mean(*, rt_alpha=None, rt_beta=None, rt_loc=0.0, rt_mean=None, rt_std=None, log10_decay_rate)[source]#

Compute the effective (parallel) mean log removal for (shifted) gamma-distributed residence time.

When water travels through multiple flow paths with gamma-distributed residence times, the effective log removal is determined by mixing the output concentrations (not by averaging individual log removals). For a shifted gamma distribution T = T0 + rt_loc with T0 ~ Gamma(alpha, beta), factoring the moment generating function gives:

LR_eff = -log10(E[10^(-mu*T)])

= -log10(10^(-mu*rt_loc) * E[10^(-mu*T0)]) = mu * rt_loc + alpha * log10(1 + beta * mu * ln(10))

The rt_loc term shifts the whole log-removal distribution by a constant mu * rt_loc; the alpha/beta term is unchanged. This is always less than the arithmetic mean mu * (alpha * beta + rt_loc) because short residence time paths contribute disproportionately to the output concentration.

The residence-time distribution is specified with either (rt_alpha, rt_beta) or (rt_mean, rt_std) (optionally shifted by rt_loc); both are routed through gwtransport.gamma.parse_parameters().

Parameters:
  • rt_alpha (float | None, default: None) – Shape parameter of the gamma distribution for residence time. Must be positive.

  • rt_beta (float | None, default: None) – Scale parameter of the gamma distribution for residence time (days). Must be positive.

  • rt_loc (float, default: 0.0) – Location (minimum residence time, days) of the residence time distribution. Must be non-negative. Default is 0.0.

  • rt_mean (float | None, default: None) – Mean residence time (days). Alternative to rt_alpha; supply with rt_std. Must be strictly greater than rt_loc.

  • rt_std (float | None, default: None) – Standard deviation of the residence time (days). Alternative to rt_beta; supply with rt_mean. Must be positive.

  • log10_decay_rate (float) – Log10 decay rate mu (log10/day).

Returns:

mean – Effective (parallel) mean log removal value.

Return type:

float

Raises:

ValueError – If parameter validation in gwtransport.gamma.parse_parameters() fails (e.g. rt_loc negative, non-positive shape/scale, or neither/both parameter pairs supplied).

See also

gamma_find_flow_for_target_mean

Find flow for target mean log removal

parallel_mean

Discrete version of this calculation

gamma_pdf

PDF of the log removal distribution

gamma_cdf

CDF of the log removal distribution

The Central Concept: Pore Volume Distribution

Why residence times are distributed

gwtransport.logremoval.gamma_find_flow_for_target_mean(*, target_mean, apv_alpha=None, apv_beta=None, apv_loc=0.0, apv_mean=None, apv_std=None, log10_decay_rate)[source]#

Find the flow rate that produces a target effective mean log removal.

Given a (shifted) gamma-distributed aquifer pore volume with parameters (apv_alpha, apv_beta, apv_loc), the residence time distribution at flow Q is a shifted gamma with shape apv_alpha, scale apv_beta/Q, and location apv_loc/Q. From gamma_mean():

LR_eff = mu * apv_loc / Q + apv_alpha * log10(1 + (apv_beta/Q) * mu * ln(10))

For apv_loc == 0 this is closed-form:

Q = apv_beta * mu * ln(10) / (10^(target_mean / apv_alpha) - 1)

For apv_loc > 0 the equation is transcendental and solved numerically with scipy.optimize.brentq() by bracketing the root in 1/Q.

The pore-volume distribution is specified with either (apv_alpha, apv_beta) or (apv_mean, apv_std) (optionally shifted by apv_loc); both are routed through gwtransport.gamma.parse_parameters().

Parameters:
  • target_mean (float) – Target effective mean log removal value. Must be positive.

  • apv_alpha (float | None, default: None) – Shape parameter of the gamma distribution for aquifer pore volume. Must be positive.

  • apv_beta (float | None, default: None) – Scale parameter of the gamma distribution for aquifer pore volume. Must be positive.

  • apv_loc (float, default: 0.0) – Location (minimum aquifer pore volume) of the gamma distribution. Must be non-negative. Default is 0.0.

  • apv_mean (float | None, default: None) – Mean aquifer pore volume. Alternative to apv_alpha; supply with apv_std. Must be strictly greater than apv_loc.

  • apv_std (float | None, default: None) – Standard deviation of the aquifer pore volume. Alternative to apv_beta; supply with apv_mean. Must be positive.

  • log10_decay_rate (float) – Log10 decay rate mu (log10/day).

Returns:

flow – Flow rate (same units as apv_beta per day) that produces the target mean log removal.

Return type:

float

Raises:

ValueError – If target_mean is not positive, if log10_decay_rate is not positive (no decay can never produce a positive target log removal), or if parameter validation in gwtransport.gamma.parse_parameters() fails (e.g. apv_loc negative, non-positive shape/scale, or neither/both parameter pairs supplied).

See also

gamma_mean

Compute effective mean log removal for given parameters

percolation#

Percolation through thick unsaturated zones via the Kinematic Wave method.

This module solves gravity-driven percolation between the bottom of the root zone and the water table by exact front tracking, following the Kinematic-Wave method described in Olsthoorn (2026, Stromingen 32(1)).

Forward-only. Inverse mapping water_table_to_root_zone is not provided. The KW unsaturated-zone problem is fundamentally one-way under gravity: multiple q_root_zone(t) series produce indistinguishable q_water_table(t) after the column’s intrinsic low-pass response, making the inverse ill-posed. Users wanting an inverse should formulate it as a regularised inverse problem outside this package.

Cumulative pore-volume coordinate. The position axis is cumulative pore volume per unit cross-sectional area (units of length), not geometric depth. For a soil of constant porosity n_p θ_s and water-table depth z_wt, the conversion is V_out = θ_s · z_wt. The docstring of root_zone_to_water_table_kinematic_wave() spells out the recovery rule and the layered-porosity generalisation.

The full Kinematic-Wave derivation and the constitutive-curve references are documented on root_zone_to_water_table_kinematic_wave().

Available functions:

  • root_zone_to_water_table_kinematic_wave() - Solve the Kinematic-Wave conservation law ∂θ_m/∂t + ∂K(θ_m)/∂z = 0 exactly by front tracking, returning the bin-averaged percolation flux at the water table on q_water_table_tedges together with the per-column solver structures (waves, events, first-arrival time, counts, and the tracker state that maps cumulative effective time back to wall-clock time). The flux is in the same units as q_root_zone and is averaged over the columns listed in cumulative_pore_volumes_outlet, whose entries are cumulative pore volume per unit area rather than geometric depth. The constitutive curve is Brooks-Corey when brooks_corey_lambda is given and van Genuchten-Mualem when van_genuchten_n is given; exactly one of the two is required. The optional k_scaling applies a time-only multiplicative factor f(t) to the whole K(θ) curve (e.g. temperature-corrected viscosity) and then requires q_water_table_tedges to equal tedges, since the back-transform q_water_table = f · cout is exact only on the input grid.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.percolation.root_zone_to_water_table_kinematic_wave(*, q_root_zone, tedges, q_water_table_tedges, cumulative_pore_volumes_outlet, theta_r, theta_s, k_s, brooks_corey_lambda=None, van_genuchten_n=None, mualem_l=0.5, k_scaling=None, max_iterations=10000)[source]#

Percolation flux at the water table by exact Kinematic-Wave front tracking.

Solves the nonlinear scalar conservation law

\[\begin{split}\\frac{\\partial \\theta_m}{\\partial t} + \\frac{\\partial K(\\theta_m)}{\\partial z} = 0\end{split}\]

exactly via gwtransport.fronttracking.solver.FrontTracker, using either a Brooks-Corey or a van Genuchten-Mualem constitutive curve. Implements the Kinematic-Wave method (see [3] for the general theory) described in Olsthoorn (2026) [1]. The capillary term ∂ψ/∂z is dropped (gravity drainage only); real fronts are slightly smoothed by capillarity, so if smoothing matters use the Munsflow-style approach in gwtransport.diffusion instead.

Parameters:
  • q_root_zone (ArrayLike) – Root-zone leakage entering the unsaturated zone at the top boundary [length/time, e.g. m/day]. Piecewise constant over each [tedges[i], tedges[i+1]) bin. Non-negative. Length = len(tedges) - 1. At any bin, q_root_zone <= f·K_s must hold (with f = k_scaling or 1) for the inlet inversion to be well-defined; the validator raises ValueError otherwise.

  • tedges (DatetimeIndex) – Time bin edges of the input series. Length n + 1 for n bins.

  • q_water_table_tedges (DatetimeIndex) – Output time bin edges. Free monotone index when k_scaling is None; must equal tedges when k_scaling is set (the back-transform q_wt = f · cout is exact only on the input grid). Must lie within the input window [tedges[0], tedges[-1]] (the flow series defines the system only there); querying beyond it raises.

  • cumulative_pore_volumes_outlet (ArrayLike) – Cumulative pore volume per unit cross-sectional area at the water table [length]. For a soil of constant porosity (n_p θ_s) and water-table depth z_wt, this is θ_s · z_wt. For layered porosity, ∫₀^{z_wt} n_p(z') dz'. The geometric depth is recovered as z_wt = V_out / θ_s (uniform case). Array-like to support a distribution of column lengths in parallel (analogous to gwtransport.advection.gamma_infiltration_to_extraction()); each entry must be positive.

  • theta_r (float) – Residual volumetric moisture content [-]. Must satisfy 0 <= theta_r < theta_s.

  • theta_s (float) – Saturated volumetric moisture content [-]. Equal to the porosity for typical soils. Must satisfy theta_r < theta_s < 1.

  • k_s (float) – Saturated hydraulic conductivity [length/time]. Positive.

  • brooks_corey_lambda (float | None, default: None) – Brooks-Corey pore-size distribution index [-]. Set to use the Brooks-Corey branch. Mutually exclusive with van_genuchten_n. Tabulated soil values are available in the Staringreeks [2].

  • van_genuchten_n (float | None, default: None) – Van Genuchten shape parameter n_vG > 1. Set to use the van Genuchten-Mualem branch (numerical inversion via brentq). Mutually exclusive with brooks_corey_lambda.

  • mualem_l (float, default: 0.5) – Mualem pore-connectivity parameter L. Default 0.5 (standard Mualem). Honored only when van_genuchten_n is set.

  • k_scaling (ArrayLike | None, default: None) –

    Dimensionless time-only multiplicative factor f(t) applied to the entire K(θ) curve: K(θ, t) = f(t) · K_reference(θ). Length n. Default None means f 1. All entries must be strictly positive.

    The cumulative-flow trick in the underlying front-tracking solver absorbs f(t) exactly: wave dynamics in cumulative effective time remain flow-free. Typical usage is a temperature-corrected viscosity f(t) = μ_ref / μ(T(t)); μ varies ~60% between 5 °C and 25 °C, so seasonal swings of 30-50% in effective K_s are realistic for shallow soils.

  • max_iterations (int, default: 10000) – Maximum number of solver events. Default 10000.

Return type:

tuple[NDArray[floating], list[dict]]

Returns:

  • q_water_table (ndarray) – Bin-averaged percolation flux at the water table [same units as q_root_zone], length len(q_water_table_tedges) - 1, averaged across the columns in cumulative_pore_volumes_outlet.

  • structures (list of dict) – Per-column simulation structures (same schema as gwtransport.advection.infiltration_to_extraction_nonlinear_sorption(), with aquifer_pore_volume renamed to cumulative_pore_volume_outlet):

    • waves — all wave objects.

    • events — event history; each record has "theta" (cumulative effective time) and "type" keys. Translate theta to wall-clock time via tracker_state.t_at_theta(event["theta"]).

    • theta_first_arrival — cumulative effective time at which the first nonzero arrival reaches the outlet.

    • n_events, n_shocks, n_rarefactions, n_characteristics — counts.

    • theta_current — final cumulative effective time.

    • sorption — the sorption object.

    • tracker_state — complete FrontTrackerState for the column (use state.t_at_theta to translate θ t).

    • cumulative_pore_volume_outlet — the V_out for this column.

Raises:

ValueError – If inputs are inconsistent (wrong lengths, NaN, negative q_root_zone or k_scaling, non-finite or non-positive cumulative_pore_volumes_outlet or k_s), if neither or both sorption-parameter groups are supplied, if q_root_zone > f(t) * k_s at any bin (saturation/ponding limit), or if q_water_table_tedges does not equal tedges while k_scaling is provided.

Warns:

UserWarning – If output θ-bins extend beyond the inlet θ-window (i.e. the drying tail of q_root_zone reaches zero and the column has not yet equilibrated by the last output bin). Bin averages in that region are clamped to zero.

See also

gwtransport.advection.infiltration_to_extraction_nonlinear_sorption

Solute transport with nonlinear sorption (analogous front-tracking algorithm in the saturated-zone domain).

gwtransport.diffusion

Munsflow-style linearised advection-diffusion (complementary; smoothed fronts).

gwtransport.fronttracking.math.BrooksCoreyConductivity

Brooks-Corey constitutive class.

gwtransport.fronttracking.math.VanGenuchtenMualemConductivity

van Genuchten-Mualem constitutive class.

Kinematic-Wave Percolation Through Thick Unsaturated Zones

Background on the Kinematic-Wave method for unsaturated-zone percolation.

Notes

Cumulative pore-volume coordinate. The internal V axis is V(z) = int_0^z n_p(z') dz' (units of length). For a uniform soil with n_p = theta_s, V = theta_s * z; depth is recovered as z = V / theta_s. The solver-side identification flow = theta_s * f(t) (with f the optional K-scaling) follows from the chain rule d/dz = theta_s * d/dV.

Inlet boundary inversion. The solver works in a reference frame where K = K_ref(theta_m); the time-varying scaling is moved to the boundary as cin_solver(t) = q_root_zone(t) / f(t) and recovered at the outlet as q_water_table(t) = f(t) * cout(t). The requirement cin_solver <= k_s (i.e. q_root_zone <= f * k_s) is the saturation/ponding admissibility check enforced by the validator.

The KW approximation. Capillary stresses are neglected; flow is gravity-only. Wetting fronts are sharp shocks satisfying Rankine-Hugoniot V_f = (K_1 - K_2)/(theta_1 - theta_2). Drying tails are self-similar rarefaction fans. Real fronts are slightly capillary- smoothed; if that smoothing matters, use Munsflow-style advection-diffusion (the article’s Munsflow method, mapped to gwtransport.diffusion in this package).

Initial condition. The column starts at theta_m = theta_r (i.e. K = 0) everywhere. To start from field capacity or a long-term equilibrium, prepend a constant-q spin-up to the input series.

Exact mass conservation. Both Brooks-Corey and van Genuchten-Mualem fan integrals use a closed-form integration-by-parts antiderivative derived from the universal identity R = dC_T/dC: for the spatial fan integral G(u) = C_T(c) * u - kappa * c, and for the temporal fan integral F(theta) = c * (theta - theta_origin) - Delta_v * C_T(c). For Brooks-Corey both c and C_T at the endpoints are closed form; for van Genuchten-Mualem they require a single brentq call per endpoint (transcendental K(theta)). The Burdine variant (mualem_l = 0) admits a closed-form inverse and is fully free of root-finding.

References

Examples

Reproduce a 10-year step-response for the article’s soil O05 (coarse sand, Brooks-Corey):

import numpy as np
import pandas as pd
from gwtransport.percolation import (
    root_zone_to_water_table_kinematic_wave,
)

tedges = pd.date_range("1995-01-01", "2005-01-01", freq="D")
q_root = np.full(len(tedges) - 1, 1e-3)  # 1 mm/day

q_wt, structures = root_zone_to_water_table_kinematic_wave(
    q_root_zone=q_root,
    tedges=tedges,
    q_water_table_tedges=tedges,
    cumulative_pore_volumes_outlet=np.array([0.337 * 20.0]),
    theta_r=0.01,
    theta_s=0.337,
    k_s=0.174,
    brooks_corey_lambda=0.25,
)

With time-varying water viscosity:

days = ((tedges[:-1] - tedges[0]) / pd.Timedelta(days=1)).values
T = 10.0 + 5.0 * np.sin(2 * np.pi * days / 365.25)  # °C
mu_ref, dmu_dT = 1.31, -0.027  # mPa·s, linear around 10 °C
mu = mu_ref + dmu_dT * (T - 10.0)
k_scaling = mu_ref / mu

q_wt_visc, _ = root_zone_to_water_table_kinematic_wave(
    q_root_zone=q_root,
    tedges=tedges,
    q_water_table_tedges=tedges,
    cumulative_pore_volumes_outlet=np.array([0.337 * 20.0]),
    theta_r=0.01,
    theta_s=0.337,
    k_s=0.174,
    brooks_corey_lambda=0.25,
    k_scaling=k_scaling,
)

radial_asr#

Exact radial advection-dispersion transport for a single well (push-pull / ASR).

Water is injected in an infinite aquifer at a single fully-penetrating well and later recovered at the same well under a signed flow schedule (push-pull / ASR). Transport is radial advection with microdispersion, molecular diffusion, and linear sorption; the spread of velocities across the well screen provides macrodispersion. Forward and backward modeling are supported.

Computes the extracted flux concentration cout at a single fully-penetrating well driven by an arbitrary signed flow schedule (positive = injection, negative = extraction, zero = rest) and an arbitrary injected concentration cin. The physics is the exact radial advection-dispersion: volume coordinate V(r) = pi b n (r^2 - r_w^2), Scheidegger velocity-dependent dispersion D = alpha_L |u| + D_m (microdispersion alpha_L |u| plus molecular diffusion D_m), Kreft-Zuber flux boundary conditions, and the exact per-phase kernels (Airy for D_m = 0; the log-derivative Riccati ODE for D_m > 0). Nothing is reduced to a Gaussian; the exact non-Gaussian breakthrough (with the correct skewness) is carried.

The forward map is grid-free end to end – no PDE is discretized, so none of the finite-volume artefacts appear. Every signed-flow schedule (single cycle, multi-cycle ASR, intervening rests) is composed by the propagator-matrix engine (gwtransport._radial_asr_reuse), which carries the resident field across each flow reversal with the exact per-phase interior Green’s functions (Airy / Riccati / Bessel); cycles are expressed through the flow sign pattern, not an argument. Molecular diffusion during pumping (the D_m > 0 Whittaker kernel) is evaluated through the log-derivative Riccati ODE – exact to the de Hoog inversion floor at any A_0/D_m, with no special-function precision cap, and reducing continuously to the Airy branch as D_m -> 0. During a rest (Q = 0) advection and microdispersion vanish and molecular diffusion acts alone on the wall-clock clock; it is carried exactly by the order-0 modified Bessel pure-diffusion kernel, the dominant mixing for seasonal storage / ATES. The only numerical steps are Gauss-Legendre quadrature and de Hoog Laplace inversion of exact special-function kernels. An independent finite-volume solve of the same PDE (tests/src/_radial_asr_fv_oracle.py) is used only as a test oracle.

The reported cout is the flow-weighted average over each output bin – defined on extraction bins (flow < 0) and NaN on injection / rest bins (nothing is recovered there).

Macrodispersion within the well screen#

The well screen has a known height; macrodispersion is the spread of arrival times caused by velocity heterogeneity across the screen. It is modelled as parallel streamtubes (pore_heights): each streamtube is an independent radial cell carrying the full flow, with an effective pore height that sets its velocity, and the output is the weight-averaged breakthrough. A streamtube of effective height b has velocity proportional to 1/b (its pore volume to radius r is pi b n (r^2 - r_w^2)), so smaller b means faster breakthrough. gamma_infiltration_to_extraction() builds this ensemble from a gamma distribution of the layer velocity within the fixed screen height (see that function); the mean velocity is set by the screen height and the spread by a velocity coefficient of variation. The spread is a within-screen velocity distribution – velocity heterogeneity across the well screen – not an aquifer pore-volume distribution.

Regional background flow (drift)#

With a steady uniform regional Darcy flux regional_flux (U, drift seepage v_d = U/n) the well field is superimposed on a regional gradient, so the stored bubble drifts and recovery degrades. The radial symmetry is broken and the transport is solved by an azimuthal Fourier-mode expansion c(r, theta) = sum_m c_m(r) e^{i m theta} (m = 0 is the radial engine; drift couples m to m +- 1), composed through the same per-phase interior Green’s functions (gwtransport._radial_asr_drift_kernels). regional_flux = 0 (default) dispatches to the radial path bit-for-bit. The engine is for the slow-drift envelope – the plume (including its rest-phase drift displacement) must stay well inside the stagnation radius r_s = |A_0|/|v_d| (else a ValueError). Rest phases (flow == 0) are propagated by the exact free-space drift kernel (translate + anisotropic spread). The drift-induced recovery loss is validated against an independent 2-D finite-volume oracle.

Available functions:

  • infiltration_to_extraction() - Forward transport: compute the extracted flux concentration cout from an injected concentration cin, a signed flow schedule (> 0 injection, < 0 extraction, 0 rest) and the well geometry. Grid-free – the exact per-phase kernels are composed across every flow reversal, and the phases (and hence the number of push-pull / ASR cycles) follow from the flow sign pattern rather than from an argument. cout is the flow-weighted average over each output bin, in the units of cin, defined on the extraction bins and NaN on injection and rest bins; cout_tedges must equal tedges. pore_heights is a scalar (one homogeneous screen) or an array of streamtube heights – each streamtube carries the full flow, and the breakthroughs are averaged with weights (equal by default). Sorption is linear through retardation_factor; background is the ambient aquifer concentration, so the deviation cin - background is transported and background added back. Nonzero regional_flux engages the azimuthal-mode drift engine within its slow-drift envelope, and raises ValueError when the plume leaves that envelope.

  • extraction_to_infiltration() - Reverse operation: recover the injected concentration from measured extracted concentrations under the same flow schedule and geometry. The forward operator is assembled column-by-column from unit injection-pulse responses and inverted by Tikhonov least squares (regularization_strength). Returns one value per bin: the recovered cin on injection bins and NaN on extraction and rest bins. NaN in cout on an extraction bin raises ValueError; structural NaN on injection and rest bins is ignored.

  • gamma_infiltration_to_extraction() - Forward transport as in infiltration_to_extraction(), with the streamtube ensemble built from a gamma distribution of the layer velocity across a well screen of known height screen_height. The velocity ratio has mean 1 and coefficient of variation velocity_cv, a streamtube of velocity ratio rho gets effective pore height screen_height / rho, and the distribution is discretized into n_bins equal-probability bins averaged by probability mass. velocity_cv = 0 is the homogeneous screen: a single streamtube at pore height screen_height.

  • gamma_extraction_to_infiltration() - Reverse operation as in extraction_to_infiltration(), over the same gamma layer-velocity streamtube ensemble as gamma_infiltration_to_extraction().

References

The references below give the published closed-form solutions for the single-phase radial injection problem (steady divergent flow from one well) – the per-phase forward kernel this module composes. The convergent-extraction dual and the multi-cycle push-pull / ASR composition across flow reversals are built on top of those kernels here and are not in the single-injection references. All share the assumptions used here: a single fully-penetrating well in a homogeneous medium with steady divergent flow v = Q / (2 pi b n r), plus retardation.

The D_m = 0 kernel (velocity-proportional microdispersion D = alpha_L |u|, Airy functions) is the classical radial-dispersion problem: Tang & Babu (1979) under a Dirichlet (resident-concentration) well boundary, and Chen (1987) under the Cauchy / third-type (flux) boundary used here – explicitly the Kreft-Zuber flux concentration, with transfer function Ai(Y) / [Ai(Y0)/2 - p^(1/3) Ai'(Y0)] equal to the flux operator this module evaluates. The D_m > 0 kernel (D = alpha_L |u| + D_m, Kummer / confluent-hypergeometric functions) under the same flux boundary, with retardation, is Aichi & Akitaya (2018) – whose well operator U(a,b) + 2a U(a+1,b+1) is this module’s Whittaker flux boundary; they record the D_m -> 0 reduction to Chen (1987) as an open problem, which this module performs continuously – the log-derivative Riccati kernel reduces smoothly to the Airy branch as D_m -> 0. The alpha_L = 0 limit (constant diffusion, drift-dominated radial transport, Whittaker equation) is Akanji & Falade (2019). Each is an injection-only solution; none treats extraction or multi-cycle push-pull.

Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its solutions for different initial and boundary conditions. Chemical Engineering Science, 33(11), 1471-1480.

Tang, D. H., & Babu, D. K. (1979). Analytical solution of a velocity dependent dispersion problem. Water Resources Research, 15(6), 1471-1478.

Chen, C.-S. (1987). Analytical solutions for radial dispersion with Cauchy boundary at injection well. Water Resources Research, 23(7), 1217-1224.

Aichi, M., & Akitaya, K. (2018). Analytical solution for a radial advection-dispersion equation including both mechanical dispersion and molecular diffusion for a steady-state flow field in a horizontal aquifer caused by a constant rate injection from a well. Hydrological Research Letters, 12(3), 23-27.

Akanji, L. T., & Falade, G. K. (2019). Closed-form solution of radial transport of tracers in porous media influenced by linear drift. Energies, 12(1), 29.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.radial_asr.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, pore_heights, porosity, well_radius, longitudinal_dispersivity, molecular_diffusivity=0.0, retardation_factor=1.0, weights=None, background=0.0, regional_flux=0.0, n_modes=None, n_quad=240)[source]#

Compute the extracted flux concentration at a radial well for a signed flow schedule.

Parameters:
  • cin (ArrayLike) – Injected concentration per time bin (used only on injection bins, flow > 0).

  • flow (ArrayLike) – Signed flow per time bin [m^3/day]: > 0 injection, < 0 extraction, 0 rest.

  • tedges (DatetimeIndex) – Time bin edges (n + 1 for n bins).

  • cout_tedges (DatetimeIndex) – Output time bin edges; must equal tedges. Output is NaN on injection / rest bins.

  • pore_heights (ArrayLike) – Effective streamtube pore height(s) b [m] – a scalar (one homogeneous screen) or an array of streamtube heights for the velocity-heterogeneity macrodispersion ensemble (each streamtube carries the full flow; smaller b = faster). See the module docstring and gamma_infiltration_to_extraction().

  • porosity (float) – Porosity n [-].

  • well_radius (float) – Well (screen) radius r_w [m].

  • longitudinal_dispersivity (float) – Longitudinal dispersivity alpha_L [m].

  • molecular_diffusivity (float, default: 0.0) – Molecular diffusivity D_m [m^2/day]. Default 0. D_m = 0 uses the vectorized Airy branch; D_m > 0 uses the log-derivative Riccati kernel – exact to the de Hoog floor at any A_0/D_m with no precision cap, reducing continuously to the Airy branch as D_m -> 0. For D_m > 0 each one-signed pumping phase is advanced at its (constant) dt-weighted mean flow magnitude, so within-phase variable flow is a constant-|Q| approximation whose error grows with the within-phase flow variation; it is exact for constant-|Q| phases, and (at any flow schedule) for D_m = 0.

  • retardation_factor (float, default: 1.0) – Linear retardation R >= 1. Default 1.

  • weights (ArrayLike | None, default: None) – Per-streamtube averaging weights (same length as pore_heights). Default equal weights.

  • background (float, default: 0.0) – Ambient aquifer concentration c_bg. The deviation cin - c_bg is transported and c_bg is added back; constant cin = c_bg returns cout = c_bg. Default 0.

  • regional_flux (float, default: 0.0) – Steady uniform regional background Darcy flux U [m/day] in +x (drift seepage v_d = U / n). 0 (default) reproduces the radial-symmetric engine bit-for-bit. A nonzero value engages the azimuthal-mode block engine, which captures the drift-induced recovery loss (the down-gradient plume is partly swept past the well). The slow-drift envelope requires the plume – including its rest-phase drift displacement – to stay well inside the stagnation radius r_s = |A_0| / |v_d| (a ValueError is raised otherwise). Rest phases (flow == 0) are propagated by the exact free-space drift kernel (translation v_d t / R plus anisotropic Gaussian spread, with a Neumann-image closure at the shut well face). See Feasibility envelope under regional drift for a worked multi-year feasibility table.

  • n_modes (int | None, default: None) – Azimuthal truncation M for the drift engine (keeps modes -M .. M). Default None auto-sizes M from the plume-front drift ratio eps = v_d R_b / A_0 and the rest-phase displacement (clamped to [2, 8]). Ignored when regional_flux == 0.

  • n_quad (int, default: 240) – Gauss-Legendre node count for the resident-profile superposition. Default 240.

Returns:

Extracted flux concentration; NaN on injection and rest bins.

Return type:

NDArray[floating]

gwtransport.radial_asr.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, pore_heights, porosity, well_radius, longitudinal_dispersivity, molecular_diffusivity=0.0, retardation_factor=1.0, weights=None, background=0.0, regional_flux=0.0, n_modes=None, regularization_strength=1e-10, n_quad=240)[source]#

Recover the injected concentration from extracted-water measurements (Tikhonov inverse).

Inverts the forward operator built by infiltration_to_extraction(). Returns the injected concentration on injection bins (NaN on extraction / rest bins).

Parameters:
Returns:

Recovered injected concentration; NaN on extraction / rest bins.

Return type:

NDArray[floating]

Raises:

ValueError – If cout contains NaN on any extraction bin (flow < 0), which would poison the least-squares solve. Structural NaN on injection / rest bins is allowed. If regularization_strength is negative: it would reach np.sqrt in the augmented system and silently produce an all-NaN cin.

gwtransport.radial_asr.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, porosity, well_radius, longitudinal_dispersivity, screen_height, velocity_cv, n_bins=100, molecular_diffusivity=0.0, retardation_factor=1.0, background=0.0, regional_flux=0.0, n_modes=None, n_quad=240)[source]#

Radial transport with gamma-distributed screen velocity (within-screen macrodispersion).

The well screen has a known height screen_height; macrodispersion is the spread of arrival times from velocity heterogeneity across that fixed height. The layer velocity is gamma-distributed with mean equal to the homogeneous value (a streamtube at the mean velocity has effective pore height screen_height) and coefficient of variation velocity_cv. A streamtube with velocity ratio rho (gamma, mean 1) has effective pore height screen_height / rho – faster layers are thinner and break through sooner. The gamma is discretized into n_bins equal-probability bins (gwtransport.gamma.bins()) and averaged by probability mass via infiltration_to_extraction().

Parameters:
Returns:

Extracted flux concentration; NaN on injection / rest bins.

Return type:

NDArray[floating]

gwtransport.radial_asr.gamma_extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, porosity, well_radius, longitudinal_dispersivity, screen_height, velocity_cv, n_bins=100, molecular_diffusivity=0.0, retardation_factor=1.0, background=0.0, regional_flux=0.0, n_modes=None, regularization_strength=1e-10, n_quad=240)[source]#

Inverse of gamma_infiltration_to_extraction() (gamma-distributed screen velocity).

Returns:

Recovered injected concentration; NaN on extraction / rest bins.

Return type:

NDArray[floating]

recharge#

Recharge-Driven Transport for Aquifers with Areal Recharge.

Concentration at extraction has two sources. 1) Water infiltrates and is transported through an aquifer with constant thickness to extraction. 2) During transport, rainfall is mixed instantaneously over the height of the aquifer. In an unbounded aquifer all extracted water originates as recharge. Transport is advective with linear sorption; there is no microdispersion, molecular diffusion, or macrodispersion. Only forward modeling is supported. No assumption is made about whether the flow is radial or orthogonal. Two conceptual models share one entry point:

  • Unbounded aquifer (aquifer_pore_volume=None): all extracted water originates as recharge. The residence-time distribution is exponential with mean retardation_factor * aquifer_pore_depth / N — independent of the pumping rate, hydraulic conductivity, capture-zone size, and planform shape (Haitjema, 1995). In the cumulative-recharge clock u(t) = N dt / (retardation_factor * aquifer_pore_depth) (pore volumes flushed) the model is the stationary unit filter dC/du = cin_recharge - C, which this module integrates in closed form per bin. No flow rate is needed.

  • Bounded aquifer (aquifer_pore_volume set): the aquifer extent is capped at pore volume aquifer_pore_volume (strip area aquifer_pore_volume / aquifer_pore_depth). Water with concentration cin enters at the upstream side at rate q_b = flow - N * area whenever extraction exceeds the rainfall on the strip. When rainfall exceeds extraction (q_b < 0) the surplus flows out across the upstream boundary and is lost; the outside has no memory, so when extraction later dominates again the inflow carries the current cin. The exact solution is the unbounded exponential kernel acting on cin_recharge, truncated at the boundary-entry time of the extracted water, with the residual tail weight placed as an atom on cin at the entry time. With zero recharge this reduces exactly to single-pore-volume piston flow (gwtransport.advection.infiltration_to_extraction()); with the boundary never feeding the well it reduces exactly to the unbounded model.

Available functions:

  • recharge_to_extraction() - Compute the extracted concentration from the recharge concentration cin_recharge and, in the bounded model, the upstream-boundary concentration cin and the extraction rate flow. Forward only: there is no extraction-to-recharge inverse. The result is one value per cout_tedges bin, a flow-weighted bin average in the bounded model and a recharge-weighted bin average in the unbounded model, NaN outside the input time range and in bins whose weight is zero. All terms are closed-form, so the output is exact to machine precision for bin-constant inputs.

References

Haitjema, H.M. (1995). On the residence time distribution in idealized groundwatersheds. Journal of Hydrology, 172(1-4), 127-146. https://doi.org/10.1016/0022-1694(95)02732-5

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.recharge.recharge_to_extraction(*, cin=None, cin_recharge, flow=None, recharge, tedges, cout_tedges, aquifer_pore_volume=None, aquifer_pore_depth, retardation_factor=1.0)[source]#

Compute the concentration of extracted water under uniform areal recharge.

Unbounded model (aquifer_pore_volume=None): exponential residence-time distribution with mean retardation_factor * aquifer_pore_depth / N (Haitjema, 1995), exact for bin-constant inputs. Bounded model (aquifer_pore_volume set, together with cin and flow): the exponential kernel is truncated at the upstream-boundary entry time and the residual weight is an atom on cin; water pushed out across the boundary during rainfall surplus is lost.

Parameters:
  • cin (ArrayLike | None, default: None) – Concentration of the water entering at the upstream side of the bounded aquifer [concentration units]. Required when aquifer_pore_volume is set; must be None otherwise.

  • cin_recharge (ArrayLike) – Concentration of the recharge water entering via the surface [concentration units]. Length must equal len(tedges) - 1; constant over each interval [tedges[i], tedges[i+1]).

  • flow (ArrayLike | None, default: None) – Extraction rate [m3/day]. Required when aquifer_pore_volume is set; must be None otherwise, because the unbounded model is independent of the pumping rate (see Notes). Must be non-negative and NaN-free.

  • recharge (ArrayLike) – Areal recharge rate N [m/day; same length unit as aquifer_pore_depth]. Length must equal len(tedges) - 1. Must be non-negative and NaN-free.

  • tedges (DatetimeIndex) – Time bin edges for the input series.

  • cout_tedges (DatetimeIndex) – Time bin edges for the output series. Bins not fully inside the tedges range return NaN.

  • aquifer_pore_volume (float | None, default: None) – Pore volume of the bounded aquifer [m3]. The strip area between the upstream boundary and the well is aquifer_pore_volume / aquifer_pore_depth. Default None (unbounded).

  • aquifer_pore_depth (float) – Pore volume per unit surface area: porosity times saturated thickness [m]. The only static aquifer parameter of the unbounded model.

  • retardation_factor (float, default: 1.0) – Compound retardation factor (>= 1.0), by default 1.0. Dilates the solute clock; mixing fractions are unaffected.

Returns:

Extracted concentration per cout_tedges bin, length len(cout_tedges) - 1. Flow-weighted bin average (bounded model) or recharge-weighted bin average (unbounded model). NaN for bins outside the input time range, for zero-recharge bins (unbounded), and for zero-extraction bins (bounded).

Return type:

NDArray[floating]

Raises:

ValueError – If array lengths do not match the bin-edge pattern, inputs contain NaN or negative values, physical parameters are out of range, or only part of the bounded-model triple (cin, flow, aquifer_pore_volume) is provided.

See also

gwtransport.advection.infiltration_to_extraction

Zero-recharge limit of the bounded model.

gwtransport.deposition.deposition_to_extraction

Distributed source along the flow path.

Residence Time

Background on residence times.

Core Transport Equation

Flow-weighted averaging approach.

Notes

The unbounded model needs no flow rate because the capture zone self-adjusts: the well always draws exactly its pumping rate from recharge, over a capture area flow / N. Pumping harder widens the capture area proportionally, leaving the age composition of the extracted water – set by the ratio of pore storage per unit area (aquifer_pore_depth) to recharge per unit area (N) – unchanged, so the flow rate cancels exactly (Haitjema, 1995). In the bounded model the area is fixed by aquifer_pore_volume instead of adjusting to the well, so the flow rate no longer cancels and must be given.

Spin-up follows the "constant" policy: all inputs are treated as constant at their first values before tedges[0]. For the bounded model this is the steady concentration profile C(V) = cr0 + (cin0 - cr0) * (V_R - apv) / (V_R - V) when the boundary feeds the well (q_b(0) > 0, V_R = flow[0] * aquifer_pore_depth / recharge[0]), and the uniform profile cin_recharge[0] otherwise.

Under constant inputs with flow > N * area the extracted water is the mass-balance mixture cin_recharge + (cin - cin_recharge) * q_b / flow: an exponential residence-time density carrying the recharge fraction plus a piston atom of mass q_b / flow at the boundary-to-well travel time.

The exponential kernel lives on the dimensionless clock u and is parameter-free; the pumping rate enters the bounded model only through the boundary-entry times. All formulas are closed-form (exp/log of bin-local quantities), exact to machine precision for bin-constant inputs.

References

Haitjema, H.M. (1995). On the residence time distribution in idealized groundwatersheds. Journal of Hydrology, 172(1-4), 127-146. https://doi.org/10.1016/0022-1694(95)02732-5

Examples

>>> import numpy as np
>>> import pandas as pd
>>> from gwtransport.recharge import recharge_to_extraction
>>> tedges = pd.date_range("2020-01-01", periods=11, freq="D")
>>> cout = recharge_to_extraction(
...     cin_recharge=np.full(10, 2.5),
...     recharge=np.full(10, 0.002),
...     tedges=tedges,
...     cout_tedges=tedges[3:],
...     aquifer_pore_depth=3.0,
... )
>>> np.allclose(cout, 2.5)
True

residence_time#

Residence Time Calculations for Retarded Compound Transport.

This module provides functions to compute residence times for compounds traveling through aquifer systems, accounting for flow variability, pore volume, and retardation due to physical or chemical interactions with the aquifer matrix. Residence time represents the duration a compound spends traveling from infiltration to extraction points, depending on flow rate (higher flow yields shorter residence time), pore volume (larger volume yields longer residence time), and retardation factor (interaction with matrix yields longer residence time).

The residence times are resolved per pore volume (full()), collapsed over a discrete equally-weighted pore-volume distribution (mean()), or in closed form over a (shifted) gamma aquifer pore-volume distribution with no discretization (gamma()).

Spin-up period#

The spin-up region is determined entirely by the supplied flow record (tedges, which fixes the cumulative throughflow volume V from 0 at the record start to V_end at the record end) together with the retarded pore volume retardation_factor * V_p – it is not a length you set. A residence time for an output time needs the corresponding parcel to stay inside the flow record:

  • direction='extraction_to_infiltration' looks back to the infiltration event, so the spin-up sits at the start of the output record: the residence time of a pore volume V_p needs V(t) >= retardation_factor * V_p (the extracted water was infiltrated before the record began otherwise).

  • direction='infiltration_to_extraction' looks forward to the extraction event, so the spin-up sits at the end of the output record: it needs V_end - V(t) >= retardation_factor * V_p (the infiltrated water is extracted after the record ends otherwise).

The spin-up therefore lengthens with both the pore volume and the retardation factor, and is longest for the largest pore volumes of a distribution.

What happens in that region is governed by a spinup policy, following the package convention (see gwtransport.advection); full(), mean() and gamma() all share the contract spinup={'constant'} | None | float in [0, 1] and the default is "constant" everywhere:

  • "constant" (default) warm-starts by extrapolating the boundary flow (flow held constant at its first/last value), so no in-record output is NaN.

  • None is strict (no extrapolation), marking a pore volume NaN for any output bin its parcel leaves the record within. Where the pore-volume axis is collapsed – mean() over a discrete set, gamma() over the continuum – the bin mean then renormalizes over the covered streamtubes / sub-mass, emitted wherever any coverage remains.

  • a float covered-fraction threshold is the strict mode with a minimum coverage gate: the renormalized mean is emitted only where the covered streamtube fraction / sub-mass fraction is at least spinup (0.0 matches None; larger values demand more coverage). For the per-pore-volume full() there is no axis to collapse, so the float behaves exactly like None.

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

The fraction_explained_full() / fraction_explained_mean() / fraction_explained_gamma() diagnostics report, per output bin, the advective fraction of the pore-volume distribution that is out of spin-up (1.0 = advectively fully informed, 0.0 = entirely in spin-up) and are the way to locate the spin-up region when the means warm-start over it. They are purely advective – molecular diffusion and microdispersion spread each bin over a range of infiltration times that is not captured there, so no bin is fully informed once dispersion is present (for that dispersive informed fraction use the captured kernel mass of the diffusion coefficient matrix).

Available functions:

  • full() - Flow-weighted mean residence time [days] over each output bin, resolved per pore volume: the full (n_pore_volumes, n_output_bins) array, without collapsing the pore-volume axis. The bin average is uniform in cumulative throughflow volume, and direction selects whether the time is the look-back to infiltration (extraction_to_infiltration) or the look-forward to extraction (infiltration_to_extraction).

  • mean() - Mean residence time [days] per output bin for a discrete aquifer pore-volume distribution: the full() array collapsed by averaging over the equally-weighted streamtubes that are valid in each bin, shape (n_output_bins,).

  • gamma() - Mean residence time [days] per output bin for a continuous (shifted) gamma aquifer pore-volume distribution, parameterized by either (mean, std, loc) or (alpha, beta, loc). The expectation is taken in closed form from regularized incomplete-gamma partial moments, so there is no pore-volume discretization and no accuracy/cost knob; shape (n_output_bins,).

  • fraction_explained_full() - Advective coverage per pore volume: the flow-weighted fraction of each output bin, in [0, 1], whose retarded parcel lies inside the supplied flow record. Returned as the full (n_pore_volumes, n_output_bins) array, mirroring full().

  • fraction_explained_mean() - Equally-weighted mean of fraction_explained_full() over the discrete streamtubes, shape (n_output_bins,).

  • fraction_explained_gamma() - Closed-form expectation of the advective in-record indicator over a (shifted) gamma pore-volume distribution, shape (n_output_bins,) – the continuum analogue of fraction_explained_mean(), evaluated from the antiderivative of the shifted-gamma CDF.

  • freundlich_retardation() - Concentration-dependent retardation factors R = 1 + (rho_b / theta) * k_f * (1 / n) * C ** (1 / n - 1) from the Freundlich isotherm s = k_f * C ** (1 / n), one per concentration entry, for use as the retardation_factor input of the transport functions.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.residence_time.full(*, flow, tedges, cout_tedges, aquifer_pore_volumes, direction='extraction_to_infiltration', retardation_factor=1.0, spinup='constant')[source]#

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

The flow-weighted mean residence time is computed over each output interval [cout_tedges[i], cout_tedges[i + 1]) and returned as the full (n_pore_volumes, n_output_bins) array – one row per entry in aquifer_pore_volumes, without collapsing the pore-volume axis. The average is uniform in cumulative throughflow volume, matching the package’s bin-edge convention.

Parameters:
  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matches tedges minus one.

  • tedges (DatetimeIndex | ndarray) – Time edges for the flow data, as datetime64 objects, defining the flow intervals.

  • cout_tedges (DatetimeIndex | ndarray) – Output time edges as datetime64 objects; n + 1 edges define n output bins.

  • aquifer_pore_volumes (ArrayLike) – Pore volume(s) of the aquifer [m³]. A single value or an array of pore volumes representing different flow paths.

  • direction (str, default: 'extraction_to_infiltration') –

    Direction of the flow calculation:

    • ’extraction_to_infiltration’: Extraction to infiltration modeling - how many days ago was the extracted water infiltrated.

    • ’infiltration_to_extraction’: Infiltration to extraction modeling - how many days until the infiltrated water is extracted.

    Default is ‘extraction_to_infiltration’.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer [dimensionless]. A value greater than 1.0 indicates the compound moves slower than water. Default is 1.0.

  • spinup (str | float | None, default: 'constant') –

    How to treat the spin-up zone, where a pore volume’s retarded look-back/forward parcel leaves the flow record. Matches the package convention (see gwtransport.advection).

    • 'constant' (default): warm-start – extrapolate the cumulative-volume-to-time map past the record at the boundary flow rates (flow held constant at its first/last value), so the residence time stays finite. No left-edge (extraction) or right-edge (infiltration) spin-up NaN.

    • None or a float in [0, 1]: strict – a pore volume whose parcel leaves the record at any point within an output bin is NaN for that bin (all-or-nothing per bin), with no extrapolation. This function returns the full per-pore-volume array, so there is no pore-volume axis to collapse; the float covered-fraction threshold therefore behaves identically to None here and only takes effect once the axis is collapsed in mean() / gamma().

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

Returns:

Mean residence time [days], shape (n_pore_volumes, n_output_bins). The first dimension corresponds to the pore volumes and the second to the cout_tedges bins. Negative or NaN flow makes the cumulative-volume map non-monotone or undefined; the whole array is returned as NaN (the function refuses rather than raising).

Return type:

NDArray[floating]

Raises:

ValueError – If tedges does not have exactly one more element than flow. If direction is not 'extraction_to_infiltration' or 'infiltration_to_extraction'. If spinup is not 'constant', None, or a float in [0, 1].

See also

fraction_explained_full

Advective fraction of each output bin explained, per pore volume

Residence Time

Time in aquifer between infiltration and extraction

Core Transport Equation

Flow-weighted averaging convention

Notes

With the default spinup='constant' the spin-up zone is warm-started by extrapolating the boundary flow, so no in-record bin is NaN; use fraction_explained_mean() (or spinup=None) to locate the spin-up region. See the module docstring (Spin-up period) for the full rule.

The single-streamtube residence time \(\tau(V) = \mathrm{sign}\,[T(V + \mathrm{sign}\,R V_p) - T(V)]\) is piecewise-linear in cumulative throughflow volume \(V\) (\(T\) is the volume \(\to\) time map, \(\mathrm{sign} = -1\) for extraction_to_infiltration and \(+1\) for infiltration_to_extraction). Its flow-weighted bin average is therefore a closed-form difference of the antiderivative \(\Phi(x) = \int_0^x T(w)\,dw\) (piecewise- quadratic), evaluated at four points per pore volume and output bin:

\[\bar\tau = \frac{1}{\Delta V}\int_{V_\mathrm{lo}}^{V_\mathrm{hi}} \tau(V)\,dV = \frac{\mathrm{sign}}{\Delta V}\bigl[ \Phi(V_\mathrm{hi} + \mathrm{sign}\,R V_p) - \Phi(V_\mathrm{lo} + \mathrm{sign}\,R V_p) - \Phi(V_\mathrm{hi}) + \Phi(V_\mathrm{lo})\bigr],\]

where \(V\) is cumulative throughflow volume (\(dV = Q\,dt\)). This avoids materialising a per-streamtube integration grid, so memory and time scale as the output size \(O(n_\mathrm{pore\ volumes}\cdot n_\mathrm{bins})\). A zero-throughflow output bin (\(\Delta V \to 0\)) has a fixed volume while output time advances, so it degenerates to the pointwise residence time at the bin’s time midpoint.

Examples

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.residence_time import full
>>> flow_dates = pd.date_range(start="2023-01-01", end="2023-01-10", freq="D")
>>> flow_values = np.full(len(flow_dates) - 1, 100.0)  # Constant flow of 100 m³/day
>>> mean_times = full(
...     flow=flow_values,
...     tedges=flow_dates,
...     cout_tedges=flow_dates,
...     aquifer_pore_volumes=200.0,
...     direction="extraction_to_infiltration",
... )
>>> # 200 m³ / 100 m³/day = 2 days residence time; the default constant warm-start
>>> # extrapolates the boundary flow, so the left-edge spin-up bins are also 2 days
>>> print(mean_times)
[[2. 2. 2. 2. 2. 2. 2. 2. 2.]]
gwtransport.residence_time.mean(*, flow, tedges, cout_tedges, aquifer_pore_volumes, direction='extraction_to_infiltration', retardation_factor=1.0, spinup='constant')[source]#

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

The mean is taken over a discrete set of equally-weighted aquifer pore volumes – one streamtube per entry in aquifer_pore_volumes. Each streamtube’s flow-weighted bin average is computed with full() and the pore-volume axis is then collapsed to a single per-output-bin series by averaging over the streamtubes that are valid in each bin. For a continuous (shifted) gamma pore-volume distribution evaluated in closed form, use gamma().

The mean is over the valid streamtubes,

\[\bar\tau_b = \frac{1}{|V_b|}\sum_{i \in V_b} \tau_{i,b}, \qquad V_b = \{\, i : \tau_{i,b}\ \mathrm{finite} \,\}.\]

With the default spinup='constant' every streamtube is finite within the flow record (the boundary flow is extrapolated), so this is simply the mean over all pore volumes; with spinup=None it renormalizes over the streamtubes that have broken through.

Parameters:
  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matches tedges minus one.

  • tedges (DatetimeIndex | ndarray) – Time edges for the flow data, as datetime64 objects, defining the flow intervals.

  • cout_tedges (DatetimeIndex | ndarray) – Output time edges as datetime64 objects; n + 1 edges define n output bins.

  • aquifer_pore_volumes (ArrayLike) – Discrete pore volumes [m³], one per (equally-weighted) streamtube. A single value collapses to the per-streamtube mean of full().

  • direction (str, default: 'extraction_to_infiltration') – Direction of the flow calculation: * ‘extraction_to_infiltration’: how many days ago was the extracted water infiltrated * ‘infiltration_to_extraction’: how many days until the infiltrated water is extracted Default is ‘extraction_to_infiltration’.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.

  • spinup (str | float | None, default: 'constant') – Spin-up policy, sharing the contract of gamma(). 'constant' (default) warm-starts by extrapolating the boundary flow so no in-record bin is NaN; None leaves spin-up streamtubes NaN and the mean renormalizes over those that have broken through (emitted wherever at least one streamtube is valid). A float in [0, 1] is the covered-fraction threshold: the renormalized mean is emitted only where the fraction of valid streamtubes is at least spinup (0.0 matches None; 1.0 demands every streamtube; larger values demand more streamtubes to have broken through). Use fraction_explained_mean() to locate the spin-up region.

Returns:

Mean residence time [days], shape (n_output_bins,). Output bins with no valid streamtube (outside the flow record, or – with spinup=None – fully in the spin-up zone) are NaN; with a float spinup so are bins whose valid-streamtube fraction is below the threshold. Negative or NaN flow makes the cumulative-volume map non-monotone or undefined; the whole series is returned as NaN (the function refuses rather than raising).

Return type:

NDArray[floating]

See also

gamma

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

full

Per-pore-volume mean residence time over output bins

fraction_explained_mean

Advective fraction of each output bin explained by the record

gwtransport.gamma.bins

Discretize a gamma APVD into pore-volume bins

Residence Time

Time in aquifer between infiltration and extraction

Notes

With spinup=None the spin-up is all-or-nothing per streamtube: a streamtube whose look-back/forward parcel leaves the flow record part-way through an output bin has a NaN bin average (inherited from full()) and is dropped from that bin’s mean entirely, rather than contributing its partially-covered share; the bin is NaN only once every streamtube is in spin-up. In that mode the discrete mean differs from gamma(), which renormalizes over the covered sub-mass exactly. See the module docstring (Spin-up period) for the full rule.

Examples

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.residence_time import mean
>>> flow_dates = pd.date_range(start="2023-01-01", end="2023-02-10", freq="D")
>>> flow_values = np.full(len(flow_dates) - 1, 100.0)  # 100 m³/day
>>> tau_bar = mean(
...     flow=flow_values,
...     tedges=flow_dates,
...     cout_tedges=flow_dates,
...     aquifer_pore_volumes=[400.0, 600.0],  # two equally-weighted streamtubes
... )
>>> # Deep in the record: mean pore volume 500 / 100 m³/day = 5 days
>>> float(np.round(tau_bar[-1], 6))
5.0
gwtransport.residence_time.gamma(*, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, direction='extraction_to_infiltration', retardation_factor=1.0, spinup='constant', _max_tile_elements=1000000)[source]#

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

The expectation over a (shifted) gamma aquifer pore-volume distribution (APVD), parameterized by either (mean, std, loc) or (alpha, beta, loc), is taken in closed form – no pore-volume binning and no n_bins accuracy/cost knob. The bin mean is flow-weighted (uniform in cumulative volume), matching the bin-edge convention of the package, and a single per-output-bin series is returned.

The single-streamtube residence time is piecewise-linear in the pore volume \(V_p\), so its per-bin time integral \(G_b(V_p) = \int_{\mathrm{bin}} \tau\,dV\) is piecewise- quadratic in \(V_p\) and the covered length \(L_b(V_p)\) piecewise-linear. The bin mean is the ratio of two closed-form integrals against the gamma density – its zeroth, first and second partial moments (regularized incomplete gamma) – formed once after integrating. The spinup policy sets what happens where part of the APVD lacks flow history: 'constant' (default) extrapolates the boundary flow over the full distribution (the package default warm-start), while a float threshold renormalizes the mean over the covered sub-mass (0.0 reproduces the exact covered-sub-mass conditional mean).

Parameters:
  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matches tedges minus one.

  • tedges (DatetimeIndex | ndarray) – Time edges for the flow data, as datetime64 objects, defining the flow intervals.

  • cout_tedges (DatetimeIndex | ndarray) – Output time edges as datetime64 objects; n + 1 edges define n output bins.

  • mean (float | None, default: None) – Mean of the gamma APVD [m³]. Must be strictly greater than loc. Provide either (mean, std) or (alpha, beta).

  • std (float | None, default: None) – Standard deviation of the gamma APVD [m³]. Must be positive.

  • loc (float, default: 0.0) – Location (lower bound of support) of the gamma APVD [m³]; a guaranteed minimum pore volume. Must satisfy 0 <= loc < mean. Default is 0.0.

  • alpha (float | None, default: None) – Shape parameter of the gamma APVD (must be > 0).

  • beta (float | None, default: None) – Scale parameter of the gamma APVD (must be > 0).

  • direction (str, default: 'extraction_to_infiltration') – Direction of the flow calculation: * ‘extraction_to_infiltration’: how many days ago was the extracted water infiltrated * ‘infiltration_to_extraction’: how many days until the infiltrated water is extracted Default is ‘extraction_to_infiltration’.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.

  • spinup (str | float | None, default: 'constant') –

    How to treat the spin-up zone, where part of the gamma APVD lacks flow history. Matches the package convention (see gwtransport.advection).

    • 'constant' (default): warm-start – extrapolate the cumulative-volume-to-time map past the record at the boundary flow rates (flow held constant at its first/last value) and integrate the full distribution, so no in-record bin is NaN.

    • None or a float in [0, 1]: renormalize the mean over the covered sub-mass, emitting a bin only where the covered fraction of the distribution is at least the threshold. None and 0.0 both give the exact covered-sub-mass conditional mean (emit whenever any sub-mass is covered); larger values demand a larger covered fraction, and 1.0 requires the full distribution to be covered.

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

Returns:

APVD-mean residence time [days], shape (n_output_bins,). Output bins outside the flow record are NaN; with a float spinup so are bins whose covered fraction is below the threshold. Negative or NaN flow makes the cumulative-volume map non-monotone or undefined; the whole series is returned as NaN (the function refuses rather than raising).

Return type:

NDArray[floating]

Raises:

ValueError – If tedges does not have exactly one more element than flow. If direction is not 'extraction_to_infiltration' or 'infiltration_to_extraction'. If spinup is not 'constant', None, or a float in [0, 1]. Gamma parameter validation is delegated to gwtransport.gamma.parse_parameters().

See also

mean

Equally-weighted mean for a discrete set of pore volumes

full

Per-pore-volume mean residence time over output bins

fraction_explained_mean

Advective fraction of each output bin explained by the record

gwtransport.gamma.bins

Discretize a gamma APVD into pore-volume bins

Residence Time

Time in aquifer between infiltration and extraction

Gamma Distribution Model

Two-parameter pore volume model

Notes

With the default spinup='constant' the spin-up is warm-started exactly as in mean() (constant-boundary-flow extrapolation) – the same warm-start policy applies across the whole distribution (mean discretizes it, gamma integrates it in closed form). With spinup=0.0 the spin-up is instead handled by exact covered-sub-mass renormalization: each output bin integrates over only the pore-volume sub-range with sufficient flow history. See the module docstring (Spin-up period) for the full rule.

The closed form uses regularized incomplete-gamma CDFs. For an extremely narrow APVD (alpha = (mean / std) ** 2 above ~1e7, i.e. std / mean below ~3e-4) SciPy’s incomplete gamma loses precision in its far tail and the result degrades to ~1e-5 relative; such a distribution is effectively a single pore volume, so mean() with one bin is the exact alternative there.

Examples

>>> import pandas as pd
>>> import numpy as np
>>> from gwtransport.residence_time import gamma
>>> flow_dates = pd.date_range(start="2023-01-01", end="2023-02-10", freq="D")
>>> flow_values = np.full(len(flow_dates) - 1, 100.0)  # 100 m³/day
>>> tau_bar = gamma(
...     flow=flow_values,
...     tedges=flow_dates,
...     cout_tedges=flow_dates,
...     mean=500.0,
...     std=100.0,
...     direction="extraction_to_infiltration",
... )
>>> # Deep in the record the mean residence time approaches mean / flow = 5 days
>>> float(np.round(tau_bar[-1], 6))
5.0
gwtransport.residence_time.fraction_explained_full(*, flow, tedges, cout_tedges, aquifer_pore_volumes, direction='extraction_to_infiltration', retardation_factor=1.0)[source]#

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

For each streamtube (entry in aquifer_pore_volumes) and each output bin [cout_tedges[i], cout_tedges[i + 1]) this returns the flow-weighted fraction of the bin whose retarded advective parcel lies inside the supplied flow record – the share of the bin’s throughflow volume for which the look-back infiltration (extraction_to_infiltration) or look-forward extraction (infiltration_to_extraction) event is covered by cin. 1.0 means the whole bin is explained for that pore volume, 0.0 that none of it is. The full (n_pore_volumes, n_output_bins) array is returned – one row per pore volume, mirroring full().

Warning

This is a purely advective diagnostic: it uses only the cumulative-volume look-back V(t) - retardation_factor * V_p and ignores molecular diffusion and longitudinal dispersion. Those spread each output bin over a range of infiltration times whose kernel tails extend outside any finite record, so a bin that is advectively “fully explained” (1.0) is not fully informed once dispersion is present. For the dispersive informed fraction of an advection-dispersion model use the captured kernel mass (the column sum of the diffusion coefficient matrix), not this function.

Parameters:
  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matches tedges minus one.

  • tedges (DatetimeIndex | ndarray) – Time edges for the flow data; n + 1 edges for n flow values.

  • cout_tedges (DatetimeIndex | ndarray) – Output time edges; n + 1 edges define n output bins.

  • aquifer_pore_volumes (ArrayLike) – Pore volume(s) of the aquifer [m³], one per streamtube.

  • direction (str, default: 'extraction_to_infiltration') – Direction of the flow calculation. Default is ‘extraction_to_infiltration’.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.

Returns:

Advective coverage [dimensionless], shape (n_pore_volumes, n_output_bins), values in [0, 1]. Output bins lying wholly outside tedges are NaN. Negative or NaN flow makes the cumulative-volume map non-monotone or undefined; the whole array is returned as NaN (the function refuses rather than raising).

Return type:

NDArray[floating]

Raises:

ValueError – If tedges does not have exactly one more element than flow, or if direction is not 'extraction_to_infiltration' or 'infiltration_to_extraction'.

See also

fraction_explained_mean

Equal-weight mean of this over a discrete APVD

fraction_explained_gamma

Closed-form coverage for a (shifted) gamma APVD

full

Per-pore-volume mean residence time over output bins

Residence Time

Time in aquifer between infiltration and extraction

gwtransport.residence_time.fraction_explained_mean(*, flow, tedges, cout_tedges, aquifer_pore_volumes, direction='extraction_to_infiltration', retardation_factor=1.0)[source]#

Advective coverage for a discrete APVD: equal-weight mean of fraction_explained_full().

Collapses the pore-volume axis of fraction_explained_full() to a single per-output-bin series by averaging over the equally-weighted streamtubes in aquifer_pore_volumes – the coverage analogue of mean(). 1.0 means every streamtube fully explains the bin, 0.0 that none do.

Warning

Purely advective – see fraction_explained_full(). Molecular diffusion and longitudinal dispersion spreading are not captured, so a value of 1.0 is advective coverage, not full dispersive information.

Parameters:
  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matches tedges minus one.

  • tedges (DatetimeIndex | ndarray) – Time edges for the flow data; n + 1 edges for n flow values.

  • cout_tedges (DatetimeIndex | ndarray) – Output time edges; n + 1 edges define n output bins.

  • aquifer_pore_volumes (ArrayLike) – Discrete pore volumes [m³], one per (equally-weighted) streamtube.

  • direction (str, default: 'extraction_to_infiltration') – Direction of the flow calculation. Default is ‘extraction_to_infiltration’.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.

Returns:

Advective coverage [dimensionless], shape (n_output_bins,), values in [0, 1]. Output bins lying wholly outside tedges are NaN. Negative or NaN flow makes the cumulative-volume map non-monotone or undefined; the whole series is returned as NaN (the function refuses rather than raising).

Return type:

NDArray[floating]

See also

fraction_explained_full

Per-pore-volume coverage (the array this averages)

fraction_explained_gamma

Closed-form coverage for a (shifted) gamma APVD

mean

Equally-weighted mean residence time for a discrete APVD

Examples

>>> import numpy as np
>>> import pandas as pd
>>> from gwtransport.residence_time import fraction_explained_mean
>>> tedges = pd.date_range("2020-01-01", periods=11, freq="D")
>>> flow = np.full(10, 100.0)
>>> fraction_explained_mean(
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=tedges,
...     aquifer_pore_volumes=[200.0, 1500.0],
... ).tolist()
[0.0, 0.0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
gwtransport.residence_time.fraction_explained_gamma(*, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, direction='extraction_to_infiltration', retardation_factor=1.0)[source]#

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

The expectation of the advective in-record indicator over a (shifted) gamma aquifer pore-volume distribution (APVD), parameterized by either (mean, std, loc) or (alpha, beta, loc), is taken in closed form – the continuum analogue of fraction_explained_mean(), with no pore-volume binning. For each output bin it returns the flow-weighted fraction of the bin whose advective parcel lies inside the flow record.

The flow-weighted bin average \(\frac{1}{\Delta V}\int_{V_\mathrm{lo}}^{V_\mathrm{hi}} F_{V_p}(\mathrm{threshold}(V))\,dV\) (with \(\mathrm{threshold}(V) = V / R\) for extraction_to_infiltration and \((V_\mathrm{end} - V) / R\) for infiltration_to_extraction) is evaluated from the antiderivative of the shifted-gamma CDF,

\[\Phi(x) = \int_\mathrm{loc}^{x} F_{V_p}(s)\,ds = y\,P(\alpha, y/\beta) - \alpha\beta\,P(\alpha + 1, y/\beta), \qquad y = \max(x - \mathrm{loc},\, 0),\]

with \(P\) the regularized lower incomplete gamma function – two CDF evaluations per output edge, no quadrature and no pore-volume binning.

Warning

Purely advective – see fraction_explained_full(). Molecular diffusion and longitudinal dispersion are not captured; a value of 1.0 is advective coverage, not full dispersive information.

Parameters:
  • flow (ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matches tedges minus one.

  • tedges (DatetimeIndex | ndarray) – Time edges for the flow data; n + 1 edges for n flow values.

  • cout_tedges (DatetimeIndex | ndarray) – Output time edges; n + 1 edges define n output bins.

  • mean (float | None, default: None) – Mean of the gamma APVD [m³]. Must be strictly greater than loc. Provide either (mean, std) or (alpha, beta).

  • std (float | None, default: None) – Standard deviation of the gamma APVD [m³]. Must be positive.

  • loc (float, default: 0.0) – Location (lower bound of support) of the gamma APVD [m³]. Must satisfy 0 <= loc < mean. Default is 0.0.

  • alpha (float | None, default: None) – Shape parameter of the gamma APVD (must be > 0).

  • beta (float | None, default: None) – Scale parameter of the gamma APVD (must be > 0).

  • direction (str, default: 'extraction_to_infiltration') – Direction of the flow calculation. Default is ‘extraction_to_infiltration’.

  • retardation_factor (float, default: 1.0) – Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0.

Returns:

Advective coverage [dimensionless], shape (n_output_bins,), values in [0, 1]. Output bins lying wholly outside tedges are NaN. Negative or NaN flow makes the cumulative-volume map non-monotone or undefined; the whole series is returned as NaN (the function refuses rather than raising).

Return type:

NDArray[floating]

Raises:

ValueError – If tedges does not have exactly one more element than flow, or if direction is not 'extraction_to_infiltration' or 'infiltration_to_extraction'. Gamma parameter validation is delegated to gwtransport.gamma.parse_parameters().

See also

fraction_explained_mean

Discrete equal-weight APVD coverage

fraction_explained_full

Per-pore-volume coverage

gamma

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

Gamma Distribution Model

Two-parameter pore volume model

gwtransport.residence_time.freundlich_retardation(*, concentration, freundlich_k, freundlich_n, bulk_density, porosity)[source]#

Compute concentration-dependent retardation factors using Freundlich isotherm.

The Freundlich isotherm relates sorbed concentration s to aqueous concentration C using the heterogeneity-index convention (matching gwtransport.fronttracking.math.FreundlichSorption and gwtransport.advection.infiltration_to_extraction_nonlinear_sorption(), so a fitted freundlich_n is portable across the package):

s = k_f * C ^ (1 / n)

The retardation factor is computed as:

R = 1 + (rho_b/θ) * ds/dC = 1 + (rho_b/θ) * k_f * (1/n) * C^(1/n - 1)
Parameters:
  • concentration (ArrayLike) – Concentration of compound in water [mass/volume]. One value per time bin, consistent with the flow array passed to the transport function.

  • freundlich_k (float) – Freundlich coefficient [(m³/kg)^(1/n)] (under s = k_f * C^(1/n) with s dimensionless and C in [kg/m³]).

  • freundlich_n (float) – Freundlich sorption exponent [dimensionless] (heterogeneity index; n = 1 recovers a linear isotherm).

  • bulk_density (float) – Bulk density of aquifer material [mass/volume].

  • porosity (float) – Porosity of aquifer [dimensionless, 0-1].

Returns:

Retardation factors for each flow interval. Length equals len(concentration) for use as retardation_factor in the transport functions.

Return type:

NDArray[floating]

Raises:

ValueError – If porosity is not in (0, 1), if bulk_density is not positive, if freundlich_k is negative, or if any concentration is non-positive while freundlich_n > 1 (the retardation factor diverges as C -> 0).

See also

full

Compute residence times from flow and pore volume

gwtransport.advection.infiltration_to_extraction_nonlinear_sorption

Transport with nonlinear sorption

Non-Linear Sorption: Exact Solutions

Freundlich isotherm and concentration-dependent retardation

Examples

>>> concentration = np.array([0.1, 0.2, 0.3])  # same length as flow
>>> R = freundlich_retardation(
...     concentration=concentration,
...     freundlich_k=0.5,
...     freundlich_n=2.0,
...     bulk_density=1600,  # kg/m³
...     porosity=0.35,
... )
>>> # Use R as retardation_factor in the transport functions

deposition_utils#

Utility Functions for the Deposition Module.

This module provides the clipped-trapezoid integral helper _clipped_linear_integral, which the deposition module’s banded weight builder uses to integrate clip(f(x), lo, hi) over each cin bin inside an output bin’s residence window, and _positive_part_integral, the positive-part integral it is built from.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

utils#

General Utilities for 1D Groundwater Transport Modeling.

This module provides general-purpose utility functions for time series manipulation, interpolation, numerical operations, and data processing used throughout the gwtransport package. Functions include linear interpolation, cumulative flow volumes, time-edge construction, linear-system solvers, and external data retrieval.

The inverse solvers below are two intentionally coexisting families: a Tikhonov family (the dense solve_inverse_transport() and its banded equivalent solve_inverse_transport_banded(), both fed by compute_reverse_target() and built on solve_tikhonov()) for the overdetermined deconvolution in advection/diffusion, and a separate nullspace solver (solve_underdetermined_system()) for the underdetermined deposition inverse.

Available functions:

  • step_plot_coords() - Expand bin edges (n+1) and bin-averaged values (n) into paired x/y arrays of 2n points each, so that ax.plot(x, y) draws the piecewise-constant series as a step function. Edges may be numeric or datetime; each output keeps the dtype of its input.

  • cumulative_flow_volume() - Accumulate per-bin flow rates times bin widths into the cumulative volume at every bin edge (n+1 values, starting at zero). With strictly_monotone=True the plateaus left by zero-flow bins are bumped by a few ulps, which is required before inverting the sequence from volume back to time.

  • linear_interpolate() - Interpolate y_ref at x_query linearly; x_ref must be ascending and the result has the shape of x_query. Query points outside the reference range clamp to the end values unless left / right supply a fill value such as NaN.

  • simplify_bins() - Merge adjacent bins of a piecewise-constant series until the peak-to-peak range within each merged group is at most tol, returning the merged edges, values and flow. Values are volume-weighted (flow times width) when flow is given and width-weighted otherwise, while the merged flow is always width-weighted. Splitting at the largest value jump makes the result independent of scan direction.

  • compute_time_edges() - Build the n+1 bin edges as a nanosecond-precision DatetimeIndex from exactly one of explicit edges, per-bin start times, or per-bin end times, validating the length against number_of_bins. From tstart or tend the single missing outer edge is extrapolated from the adjacent interval alone, so pass tedges directly when the bins are not uniformly spaced.

  • get_soil_temperature() - Download the KNMI soil-temperature record of one of four Dutch weather stations and return it as a DataFrame in degrees Celsius on a UTC DatetimeIndex, with columns for the temperature at 5, 10, 20, 50 and 100 cm depth and the six-hourly minima and maxima at 5 and 10 cm. The download is cached on disk for the calendar day; missing values are interpolated and forward-filled unless disabled. This is the only function here that needs requests, which is therefore imported lazily.

  • solve_underdetermined_system() - Solve A x = b for a wide system (more unknowns than equations) by taking the least-squares solution and adding the nullspace component that minimizes a roughness objective; rows holding NaN are dropped first. The closed-form squared-differences optimum is always computed and also seeds the iterative optimization of the other objectives, so a nullspace containing a near-constant vector raises numpy.linalg.LinAlgError whichever objective is requested.

  • compute_reverse_target() - Transpose the forward coefficient matrix, normalize its rows and apply it to the observations, giving each input bin the contribution-weighted average of the output bins it fed. This is the reference solution the Tikhonov solvers pull poorly-determined modes toward; input bins with negligible forward weight are returned as NaN.

  • solve_tikhonov() - Solve min ||A x - b||² + λ ||x - x_target||² as a single augmented least-squares problem. Rows of the system holding NaN are excluded, and NaN entries of x_target are left unregularized.

  • solve_inverse_transport() - Recover the input signal of the dense forward model w_forward @ x = observed by building the target with compute_reverse_target() and solving it with solve_tikhonov(). Observation rows that are NaN or carry negligible weight drop out (an explicit valid_rows mask overrides the weight test), and output bins left without forward contribution come back as NaN.

  • solve_inverse_transport_banded() - Solve the same inverse problem for a forward operator stored in banded layout (row k is band_vals[k] placed at column col_start[k]), through banded Cholesky normal equations plus corrected semi-normal refinement. The factorization, solve and refinement stay at O(n_output * full_band). The regularization strength must be strictly positive, since it is what makes the banded factor positive definite.

This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.

gwtransport.utils.step_plot_coords(edges, values)[source]#

Compute step-plot coordinates from bin edges and bin-averaged values.

Converts bin edges (n+1) and bin values (n) into paired x/y arrays suitable for plotting piecewise-constant (step) functions with ax.plot(x, y).

Parameters:
  • edges (ArrayLike) – Bin edges (n+1 elements for n bins). Can be numeric, datetime, or any type accepted by numpy.repeat().

  • values (ArrayLike) – Bin-averaged values (n elements), one per bin.

Return type:

tuple[NDArray, NDArray]

Returns:

  • x (ndarray) – Step x-coordinates (2n elements). Same dtype as edges.

  • y (ndarray) – Step y-coordinates (2n elements). Same dtype as values.

Examples

>>> import numpy as np
>>> edges = np.array([0.0, 1.0, 3.0, 6.0])
>>> values = np.array([2.0, 5.0, 1.0])
>>> x, y = step_plot_coords(edges, values)
>>> x
array([0., 1., 1., 3., 3., 6.])
>>> y
array([2., 2., 5., 5., 1., 1.])
gwtransport.utils.cumulative_flow_volume(flow, dt_days, *, strictly_monotone=False)[source]#

Cumulative infiltrated/extracted volume from per-bin flow rates.

Multiplies each per-bin flow rate by its bin width and accumulates, with a leading zero prepended so the result has one entry per bin edge (n+1 values for n bins). The result is the cumulative volume V at each time edge.

Parameters:
  • flow (ArrayLike) – Flow rate per bin (m³/day), length n.

  • dt_days (ArrayLike) – Bin widths in days, length n (e.g. numpy.diff of edge days).

  • strictly_monotone (bool, default: False) – When True, bump consecutive duplicates (plateaus from Q = 0 bins) via _make_strictly_monotone so the cumulative volume is strictly increasing. Required before a V → t inversion; leave False when the plateaus must be preserved. Default is False.

Returns:

Cumulative volume at each edge (length len(flow) + 1), starting at zero.

Return type:

NDArray[floating]

See also

_make_strictly_monotone : Bump duplicates before V → t inversion.

gwtransport.utils.linear_interpolate(*, x_ref, y_ref, x_query, left=None, right=None)[source]#

Linear interpolation using numpy’s optimized interp function.

Parameters:
  • x_ref (ArrayLike) – Reference x-values, in ascending order.

  • y_ref (ArrayLike) – Reference y-values corresponding to x_ref.

  • x_query (ArrayLike) – Query x-values where interpolation is needed. Array may have any shape.

  • left (float | None, default: None) –

    Value to return for x_query < x_ref[0].

    • If left=None: clamp to y_ref[0] (default)

    • If left=float: use specified value (e.g., np.nan)

  • right (float | None, default: None) –

    Value to return for x_query > x_ref[-1].

    • If right=None: clamp to y_ref[-1] (default)

    • If right=float: use specified value (e.g., np.nan)

Returns:

Interpolated y-values with the same shape as x_query.

Return type:

NDArray[floating]

Examples

Basic interpolation with clamping (default):

>>> import numpy as np
>>> from gwtransport.utils import linear_interpolate
>>> x_ref = np.array([1.0, 2.0, 3.0, 4.0])
>>> y_ref = np.array([10.0, 20.0, 30.0, 40.0])
>>> x_query = np.array([0.5, 1.5, 2.5, 3.5, 4.5])
>>> linear_interpolate(x_ref=x_ref, y_ref=y_ref, x_query=x_query)
array([10., 15., 25., 35., 40.])

Using NaN for extrapolation:

>>> linear_interpolate(
...     x_ref=x_ref, y_ref=y_ref, x_query=x_query, left=np.nan, right=np.nan
... )
array([nan, 15., 25., 35., nan])
gwtransport.utils.simplify_bins(*, edges, values, flow=None, tol=0.0)[source]#

Simplify a piecewise-constant time series by merging adjacent bins.

Splits at the largest value jump until the peak-to-peak range within every group does not exceed tol. The result is independent of scan direction.

Parameters:
  • edges (ArrayLike) – Bin edges with shape (n+1,). May be numeric or pandas Timestamps.

  • values (ArrayLike) – Bin-averaged values with shape (n,) (e.g., concentrations).

  • flow (ArrayLike | None, default: None) – Flow rate per bin with shape (n,) (e.g., m³/day). When provided, merged-bin values are weighted by volume (flow x bin width) instead of bin width alone.

  • tol (float, default: 0.0) – Maximum peak-to-peak range within a merged group. Default is 0.0, which merges only runs of identical values.

Return type:

tuple[NDArray[floating] | DatetimeIndex, NDArray[floating], NDArray[floating] | None]

Returns:

  • new_edges (ndarray or DatetimeIndex) – Simplified bin edges with shape (m+1,), preserving the type of edges.

  • new_values (ndarray of float) – Volume-weighted (or width-weighted) average values per simplified bin, with shape (m,).

  • new_flow (ndarray of float or None) – Time-weighted (width-weighted) average flow per simplified bin, with shape (m,). None when flow is not provided.

gwtransport.utils.compute_time_edges(*, tedges, tstart, tend, number_of_bins)[source]#

Compute time edges for binning data based on provided time parameters.

This function creates a DatetimeIndex of time bin edges from one of three possible input formats: explicit edges, start times, or end times. The resulting edges define the boundaries of time intervals for data binning.

Define either explicit time edges, or start and end times for each bin and leave the others at None.

Parameters:
  • tedges (DatetimeIndex | None) – Explicit time edges for the bins. If provided, must have one more element than the number of bins (n_bins + 1). Takes precedence over tstart and tend.

  • tstart (DatetimeIndex | None) – Start times for each bin. Must have the same number of elements as the number of bins. Used when tedges is None.

  • tend (DatetimeIndex | None) – End times for each bin. Must have the same number of elements as the number of bins. Used when both tedges and tstart are None.

  • number_of_bins (int) – The expected number of time bins. Used for validation against the provided time parameters.

Returns:

Time edges defining the boundaries of the time bins. Has one more element than number_of_bins.

Return type:

DatetimeIndex

Raises:

ValueError – If tedges has incorrect length (not number_of_bins + 1). If tstart has incorrect length (not equal to number_of_bins). If tend has incorrect length (not equal to number_of_bins). If none of tedges, tstart, or tend are provided.

Notes

  • When using tstart, the function assumes uniform spacing and extrapolates the final edge based on the spacing between the last two start times.

  • When using tend, the function assumes uniform spacing and extrapolates the first edge based on the spacing between the first two end times.

  • When tstart or tend are provided with non-uniformly-spaced bins, the extrapolated edge uses only the very first or very last interval and may be physically incorrect: the missing edge is implicitly assigned a bin width equal to that single neighbouring interval, which is unrelated to any other interval in the series. In such cases, supply tedges directly so that all bin widths are explicit.

  • All input time data is converted to pandas.DatetimeIndex for consistency.

gwtransport.utils.get_soil_temperature(*, station_number=260, interpolate_missing_values=True)[source]#

Download soil temperature data from the KNMI and return it as a pandas DataFrame.

The data is available for the following KNMI weather stations: - 260: De Bilt, the Netherlands (vanaf 1981) - 273: Marknesse, the Netherlands (vanaf 1989) - 286: Nieuw Beerta, the Netherlands (vanaf 1990) - 323: Wilhelminadorp, the Netherlands (vanaf 1989)

TB1 = grondtemperatuur op 5 cm diepte (graden Celsius) tijdens de waarneming TB2 = grondtemperatuur op 10 cm diepte (graden Celsius) tijdens de waarneming TB3 = grondtemperatuur op 20 cm diepte (graden Celsius) tijdens de waarneming TB4 = grondtemperatuur op 50 cm diepte (graden Celsius) tijdens de waarneming TB5 = grondtemperatuur op 100 cm diepte (graden Celsius) tijdens de waarneming TNB2 = minimum grondtemperatuur op 10 cm diepte in de afgelopen 6 uur (graden Celsius) TNB1 = minimum grondtemperatuur op 5 cm diepte in de afgelopen 6 uur (graden Celsius) TXB1 = maximum grondtemperatuur op 5 cm diepte in de afgelopen 6 uur (graden Celsius) TXB2 = maximum grondtemperatuur op 10 cm diepte in de afgelopen 6 uur (graden Celsius)

Parameters:
  • station_number (int, default: 260) – The KNMI station number for which to download soil temperature data. Default is 260 (De Bilt).

  • interpolate_missing_values (bool, default: True) – If True, missing values are interpolated and recent NaN values are extrapolated with the previous value. If False, missing values remain as NaN. Default is True.

Returns:

DataFrame containing soil temperature data in Celsius with a DatetimeIndex. Columns include TB1, TB2, TB3, TB4, TB5, TNB1, TNB2, TXB1, TXB2.

Return type:

DataFrame

Notes

  • KNMI: Royal Netherlands Meteorological Institute

  • The timeseries may contain NaN values for missing data.

gwtransport.utils.solve_underdetermined_system(*, coefficient_matrix, rhs_vector, nullspace_objective='squared_differences', optimization_method='BFGS', rcond=None)[source]#

Solve an underdetermined linear system with nullspace regularization.

For an underdetermined system Ax = b where A has more columns than rows, multiple solutions exist. This function computes a least-squares solution and then selects a specific solution from the nullspace based on a regularization objective.

Parameters:
  • coefficient_matrix (ArrayLike) – Coefficient matrix of shape (m, n) where m < n (underdetermined). May contain NaN values in some rows, which will be excluded from the system.

  • rhs_vector (ArrayLike) – Right-hand side vector of length m. May contain NaN values corresponding to NaN rows in coefficient_matrix, which will be excluded from the system.

  • nullspace_objective (str | Callable[[NDArray[floating], NDArray[floating], NDArray[floating]], float], default: 'squared_differences') –

    Objective function to minimize in the nullspace. Options:

    • ”squared_differences” : Minimize sum of squared differences between adjacent elements: sum((x[i+1] - x[i])**2)

    • ”summed_differences” : Minimize sum of absolute differences between adjacent elements: sum(|x[i+1] - x[i]|)

    • callable : Custom objective function with signature objective(coeffs, x_ls, nullspace_basis) where:

      • coeffs : optimization variables (nullspace coefficients)

      • x_ls : least-squares solution

      • nullspace_basis : nullspace basis matrix

    Default is “squared_differences”.

  • optimization_method (str, default: 'BFGS') – Optimization method passed to scipy.optimize.minimize. Default is “BFGS”.

  • rcond (float | None, default: None) – Cutoff ratio for small singular values in both numpy.linalg.lstsq and scipy.linalg.null_space. Singular values smaller than rcond * largest_singular_value are treated as zero. Default is None, which uses the default of each function. Increasing rcond truncates more modes, expanding the nullspace available for smoothness optimization. Useful for noisy data.

Returns:

Solution vector that minimizes the specified nullspace objective. Has length n (number of columns in coefficient_matrix).

Return type:

NDArray[floating]

Raises:
  • ValueError – If optimization fails, if coefficient_matrix and rhs_vector have incompatible shapes, or if an unknown nullspace objective is specified.

  • numpy.linalg.LinAlgError – If the squared-differences normal equations (DN)^T(DN) are ill-conditioned (condition number above 1e12), which happens when the nullspace contains a near-constant vector.

Notes

The algorithm follows these steps:

  1. Remove rows with NaN values from both coefficient_matrix and rhs_vector

  2. Compute least-squares solution: x_ls = pinv(valid_matrix) @ valid_rhs

  3. Compute nullspace basis: N = null_space(valid_matrix)

  4. Find nullspace coefficients: coeffs = argmin objective(x_ls + N @ coeffs)

  5. Return final solution: x = x_ls + N @ coeffs

For the built-in objectives:

  • “squared_differences” provides smooth solutions, minimizing rapid changes

  • “summed_differences” provides sparse solutions, promoting piecewise constant behavior

Examples

Rows containing NaN are dropped; the solution satisfies the remaining equations:

>>> import numpy as np
>>> from gwtransport.utils import solve_underdetermined_system
>>> matrix = np.array([
...     [1.0, 2.0, 1.0, 0.0],
...     [np.nan, np.nan, np.nan, np.nan],
...     [0.0, 1.0, 2.0, 1.0],
... ])
>>> rhs = np.array([3.0, np.nan, 4.0])
>>> x = solve_underdetermined_system(coefficient_matrix=matrix, rhs_vector=rhs)
>>> bool(np.allclose(matrix[[0, 2]] @ x, [3.0, 4.0]))
True
gwtransport.utils.compute_reverse_target(*, coeff_matrix, rhs_vector)[source]#

Compute reverse matrix target from forward coefficient matrix.

Constructs a target solution for the inverse problem by transposing the forward coefficient matrix and normalizing rows. For W_forward[i,j] representing the fraction of cin[j] arriving in cout[i], the transpose-and-normalize approach reconstructs cin[j] as a weighted average of cout bins, weighted by how much cin[j] contributed to each cout bin.

Parameters:
  • coeff_matrix (NDArray[floating]) – Forward coefficient matrix of shape (n_cout, n_cin).

  • rhs_vector (NDArray[floating]) – Right-hand side vector of length n_cout (e.g., cout values).

Returns:

Target solution vector of length n_cin. Entries with near-zero column sums in the forward matrix are set to NaN.

Return type:

NDArray[floating]

See also

solve_tikhonov

Consumes this target as the regularization reference.

gwtransport.utils.solve_tikhonov(*, coefficient_matrix, rhs_vector, x_target, regularization_strength=1e-10)[source]#

Solve a linear system with Tikhonov regularization toward a target.

Minimizes ||A x - b||² + λ ||x - x_target||² by solving the equivalent augmented least-squares problem:

[A; √λ I_v] x = [b; √λ x_target_v]

where I_v selects only entries where x_target is not NaN.

Well-determined modes (large singular values relative to √λ) are dominated by the data; poorly-determined modes are pulled toward x_target. The solution varies continuously with λ, unlike the hard singular-value cutoff of rcond in truncated SVD.

Parameters:
  • coefficient_matrix (ArrayLike) – Coefficient matrix of shape (m, n). May contain NaN rows, which are excluded from the system.

  • rhs_vector (ArrayLike) – Right-hand side vector of length m. May contain NaN values corresponding to NaN rows in coefficient_matrix.

  • x_target (NDArray[floating]) – Target solution of length n, typically from compute_reverse_target(). NaN entries are excluded from the regularization term.

  • regularization_strength (float, default: 1e-10) –

    Tikhonov parameter λ. Controls the tradeoff between fitting the data and staying close to x_target. Larger values trust the target more; smaller values trust the data more. Default is 1e-10.

    A good starting value for noisy data is λ (noise_std / signal_amplitude)². For noiseless synthetic data, the default 1e-10 preserves machine precision.

Returns:

Solution vector of length n.

Return type:

NDArray[floating]

Raises:

ValueError – If coefficient_matrix and rhs_vector have incompatible shapes, or if all rows contain NaN values.

See also

compute_reverse_target

Compute the regularization target from the forward matrix.

solve_underdetermined_system

Alternative solver using nullspace optimization.

gwtransport.utils.solve_inverse_transport(*, w_forward, observed, n_output, regularization_strength, valid_rows=None)[source]#

Solve the inverse transport problem via Tikhonov regularization.

Given the forward model w_forward @ x = observed, recovers x by building the regularization target from the transpose of w_forward and solving the regularized least-squares problem.

Parameters:
  • w_forward (NDArray[floating]) – Forward coefficient matrix with shape (n_obs, n_output).

  • observed (NDArray[floating]) – Observed values with shape (n_obs,) (e.g., extraction concentrations). NaN entries mark measurement gaps; their rows are excluded from the solve and the regularization target.

  • n_output (int) – Length of the output vector (e.g., number of cin bins).

  • regularization_strength (float) – Tikhonov regularization parameter.

  • valid_rows (NDArray[bool] | None, default: None) – Which observation rows are valid, with shape (n_obs,). If None, rows with row_sum > 1e-10 are considered valid.

Returns:

Recovered signal with shape (n_output,). NaN for bins with no active columns.

Return type:

NDArray[floating]

See also

solve_inverse_transport_banded

Memory-light banded equivalent.

gwtransport.utils.solve_inverse_transport_banded(*, band_vals, col_start, observed, n_output, regularization_strength)[source]#

Solve the inverse transport problem from a banded forward operator.

Memory-light equivalent of solve_inverse_transport() for a forward weight matrix stored in banded layout: row k of the dense operator W is band_vals[k] placed at columns [col_start[k], col_start[k] + full_band). The Tikhonov normal equations (WᵀW + λ D) x = Wᵀ observed + λ D x_target are stored in banded formWᵀW is symmetric with half-bandwidth full_band - 1 – and Cholesky-factored with scipy.linalg.cholesky_banded(). The Gram matrix WᵀW is built with a single dense BLAS matmul (~24x a per-diagonal scatter) before its sub-diagonals are read into the banded layout. Forming WᵀW squares the condition number, so the bare Cholesky solve loses accuracy in the under-determined (spin-up nullspace) directions; corrected semi-normal equations restore it by refining with the residual evaluated through W itself rather than WᵀW (matching the dense least-squares solution to ~1e-7 at the default regularization, degrading to ~1e-6 only at very small regularization with an ill-conditioned Gram). The banded Cholesky factor, solve, and refinement stay at O(n_output * full_band); only the one-shot Gram assembly transiently materializes W and WᵀW densely.

The regularization target x_target is the transpose-and-normalize of W applied to observed (the banded form of compute_reverse_target()), matching the dense solver. Columns with no forward contribution are decoupled (unit diagonal) so the system stays symmetric positive definite, and are returned as NaN.

Parameters:
  • band_vals (NDArray[floating]) – Banded forward weights of shape (n_obs, full_band). Rows the caller considers invalid must already be zeroed (as _resolve_spinup_mask does); zero rows contribute nothing to the normal equations.

  • col_start (NDArray[int_]) – First output-column index of each row’s band, shape (n_obs,).

  • observed (NDArray[floating]) – Observed values of shape (n_obs,) (e.g. extraction concentrations). NaN entries mark measurement gaps; their rows are excluded from the normal equations (band row and observed value zeroed).

  • n_output (int) – Length of the output vector (number of cin bins).

  • regularization_strength (float) – Tikhonov parameter λ. See solve_inverse_transport(). Must be strictly positive: deconvolution is generically rank-deficient, and λ is what makes the banded Cholesky factor positive definite (unlike the dense least-squares path, this solver cannot return a λ=0 min-norm solution).

Returns:

Recovered signal of shape (n_output,). NaN for output bins with no forward contribution (zero column).

Return type:

NDArray[floating]

Raises:

ValueError – If regularization_strength is not strictly positive.

See also

solve_inverse_transport : Dense-matrix equivalent. gwtransport.advection_utils._infiltration_to_extraction_weights : Banded builder.