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.

Available functions:

  • infiltration_to_extraction() - Arbitrary pore volume distribution, flow-weighted averaging. Supports explicit distribution of aquifer pore volumes with flow-weighted averaging. Flexible output time resolution via cout_tedges. Use case: Known pore volume distribution from streamline analysis.

  • gamma_infiltration_to_extraction() - Gamma-distributed pore volumes, flow-weighted averaging. Models aquifer heterogeneity with 2-parameter gamma distribution. Parameterizable via (alpha, beta) or (mean, std). Discretizes gamma distribution into equal-probability bins. Use case: Heterogeneous aquifer with calibrated gamma parameters.

  • extraction_to_infiltration() - Arbitrary pore volume distribution, deconvolution. Inverts forward transport for arbitrary pore volume distributions. Symmetric inverse of infiltration_to_extraction. Flow-weighted averaging in reverse direction. Use case: Estimating infiltration history from extraction data.

  • gamma_extraction_to_infiltration() - Gamma-distributed pore volumes, deconvolution. Inverts forward transport for gamma-distributed pore volumes. Symmetric inverse of gamma_infiltration_to_extraction. Use case: Calibrating infiltration conditions from extraction measurements.

  • infiltration_to_extraction_nonlinear_sorption() - Exact front tracking with nonlinear sorption. Event-driven algorithm that solves 1D advective transport with Freundlich or Langmuir isotherm using analytical integration of shock and rarefaction waves. Machine-precision physics (no numerical dispersion). Returns bin-averaged concentrations together with the full piecewise analytical structure (events, segments, wave list) for downstream analysis. Use case: Sharp concentration fronts with exact mass balance required, across a distribution of aquifer pore volumes (macrodispersion). Forward modeling only; nonlinear sorption has no inverse.

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.

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 | float | 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:

GenericAlias[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,
... )

With retardation factor:

>>> cout = gamma_infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     alpha=10.0,
...     beta=10.0,
...     retardation_factor=2.0,  # Doubles residence time
... )
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 | float | 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:

GenericAlias[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,
... )

With retardation factor:

>>> cin = gamma_extraction_to_infiltration(
...     cout=cout,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     alpha=10.0,
...     beta=10.0,
...     retardation_factor=2.0,  # Doubles residence time
... )
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 | float | 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.

    • float in [0, 1] — fraction threshold: emit cout when at least spinup * n_pv streamtubes have contributed; the bundle is then a count-mean over the contributing subset. Warning: this conserves mass per row but NOT cin → cout mass; with a delta cin pulse and spinup=0.0 you reproduce the issue #161 over-attribution (Σ cout > Σ 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; a float threshold relaxes either case in exchange for non-mass-conserving count-mean output.

Return type:

GenericAlias[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,)

Using array inputs instead of pandas Series:

>>> # Convert to arrays
>>> cin_values = cin.values
>>> flow_values = flow.values
>>>
>>> cout = infiltration_to_extraction(
...     cin=cin_values,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volumes=aquifer_pore_volumes,
... )

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.

Using single pore volume:

>>> single_volume = np.array([100])  # Single 100 m³ pore volume
>>> cout = infiltration_to_extraction(
...     cin=cin,
...     flow=flow,
...     tedges=tedges,
...     cout_tedges=cout_tedges,
...     aquifer_pore_volumes=single_volume,
... )
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:

GenericAlias[floating]

Raises:

ValueError – If tedges length doesn’t match flow plus one, if cout_tedges length doesn’t match cout plus one, or if inputs contain 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 are rejected. The Tikhonov solver here does not mask NaN rows, so any NaN in cout would poison the solution. This differs from gwtransport.deposition.extraction_to_deposition(), whose regularized solver excludes NaN cout rows by construction.

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[GenericAlias[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

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() - Compute concentrations from deposition rates (convolution). Given deposition rate time series [g/m²/day], computes resulting concentration changes in extracted water [g/m³]. The areal deposition flux is mixed instantaneously over the aquifer thickness, so a parcel’s concentration gain is proportional to its residence time. Accounts for aquifer geometry (porosity, thickness) and residence time distribution.

  • extraction_to_deposition() - Compute deposition rates from concentration changes (deconvolution). Given concentration change time series in extracted water [g/m³], estimates deposition rate history [g/m²/day] that produced those changes. Uses Tikhonov regularization toward a physically motivated target (transpose-and-normalize of the forward matrix). Handles NaN values in concentration data by excluding corresponding time periods.

  • extraction_to_deposition_full() - Full-featured inverse solver exposing all options of the nullspace-based solver (solve_underdetermined_system()). Allows choosing between different nullspace objectives ('squared_differences', 'summed_differences', or custom callables) and optimization methods.

  • compute_deposition_weights() - Build the banded weight operator relating deposition rates to concentration changes in a compact banded layout. Useful for custom inverse solvers. Used by deposition_to_extraction (forward), extraction_to_deposition (reverse), and extraction_to_deposition_full. Each weight is a water parcel’s residence-time contribution to its concentration gain under areal deposition mixed over the aquifer thickness, independent of whether the flow geometry is radial or orthogonal.

  • spinup_duration() - Compute spinup duration for deposition modeling. Returns the earliest extraction time at which the extracted water was infiltrated at the start of the flow series (equivalently, the time at which cumulative flow first reaches retardation_factor * aquifer_pore_volume). Before this duration the extracted concentration lacks complete deposition history. Useful for determining the valid analysis period and identifying when boundary effects are negligible.

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[GenericAlias[floating], GenericAlias[int_], GenericAlias[bool], GenericAlias[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 retardation_factor * aquifer_pore_volume / flow[0] and treats dep and flow as constant at their first observed values over the prepended interval. 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.

Return type:

GenericAlias[floating]

Raises:
  • ValueError – If tedges does not have one more element than dep or flow, if input arrays contain NaN values, 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 a float (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 retardation_factor * aquifer_pore_volume / flow[0] 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. 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:

GenericAlias[floating]

Raises:
  • ValueError – If input dimensions are incompatible or if flow contains NaN values.

  • NotImplementedError – If spinup is a float (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 and solved in a compact banded layout (peak memory O(n_cin * band), never the dense O(n_cout * n_cin)). 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: for systems where cout lies in the column space of W this preserves the exact dep, 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 retardation_factor * aquifer_pore_volume / flow[0]; 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:

GenericAlias[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 values, 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 a float (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 computes the same physics as gwtransport.diffusion – the 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: a faster but still exact implementation.

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 with tau = R*V/(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 result reproduces gwtransport.diffusion to machine precision when the cout grid aligns with the flow grid (supply flow_out on the output grid). 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.

When to choose this module vs gwtransport.diffusion#

Both modules implement the same physics (Bear resident concentration + Kreft-Zuber flux concentration on 1D streamtubes, with retardation and the moving-frame variance D_t = D_m*tau + alpha_L*xi), and both accept per-streamtube streamline_length / molecular_diffusivity / longitudinal_dispersivity arrays. 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 (closed form, no Gauss-Legendre quadrature, no residence-time inversion), and the banded build computes only the non-zero breakthrough band – faster still at the weak-to-moderate dispersion of realistic problems. 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.

Available functions:

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', saturation_threshold=7.0)[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 (GenericAlias[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 (GenericAlias[floating] | float) – Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.

  • longitudinal_dispersivity (GenericAlias[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.

  • saturation_threshold (float, default: 7.0) – Breakthrough-band cutoff U (default 7.0). The coefficient matrix is built only on the cumulative-volume band where the breakthrough is unsaturated (|x| < U * 2*sqrt(D_t)), which is the only region with non-zero coefficients. U around 7 (any value above ~6) reproduces the full dense build to machine precision; a smaller value narrows the band – faster – at the cost of dropping breakthrough tails of order exp(-U**2).

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:

GenericAlias[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', saturation_threshold=7.0)[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 (GenericAlias[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 (GenericAlias[floating] | float) – Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.

  • longitudinal_dispersivity (GenericAlias[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".

  • saturation_threshold (float, default: 7.0) – See infiltration_to_extraction(). Default 7.0.

Returns:

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

Return type:

GenericAlias[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', saturation_threshold=7.0)[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 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".

  • saturation_threshold (float, default: 7.0) – See infiltration_to_extraction(). Default 7.0.

Returns:

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

Return type:

GenericAlias[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', saturation_threshold=7.0)[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 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".

  • saturation_threshold (float, default: 7.0) – See infiltration_to_extraction(). Default 7.0.

Returns:

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

Return type:

GenericAlias[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: where gwtransport.diffusion_fast reproduces the quadrature reference to machine precision, this module is accurate to ~3e-4 in the common regime and degrades in a documented corner (below). When you need machine precision, use gwtransport.diffusion_fast.

How it works – an operator split in two coordinates#

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). The two are split into the coordinate each is stationary in, so the dominant part is built once and is flow-independent:

  1. Advection + macrodispersion + microdispersion are the exact skewed D_m=0 Kreft-Zuber breakthrough, applied banded on the native cumulative-volume grid. The whole aquifer pore volume distribution (APVD) is pre-summed into a single 1D antiderivative Ibar(dV) – exact for any APVD shape – finely sampled once and read back by interpolation. This part is volume-stationary, hence flow-independent (constant and strongly variable flow alike).

  2. Molecular diffusion is a symmetric time-domain Gaussian applied to the outlet signal (variance 2*D_m*tau_bt*(R*Vbar/L)^2/Q^2). This is the only modelling approximation: the true Kreft-Zuber molecular breakthrough is skewed, and at realistic (sub-bin) spreading the Gaussian is nearly a no-op, so the molecular term is dropped rather than skewed.

tedges need not be regularly spaced and cout_tedges need not equal tedges (supply flow_out when they differ): step 1 runs on the native cumulative-volume grid for any spacing. Only the molecular Gaussian assumes a roughly regular grid – it convolves in bin-index space using the mean bin width – so a strongly irregular grid adds a small extra error to the (usually sub-dominant) molecular term; use gwtransport.diffusion_fast for the molecular-dominated + irregular-grid corner.

Accuracy (vs gwtransport.diffusion_fast, flow-independent unless noted)#

  • ~3e-4 whenever microdispersion is present (alpha_L > 0 – the typical groundwater regime, Peclet number >> 1), constant and variable flow, for realistic solute diffusivities (D_m ~ 1e-4) or R = 1. Here molecular diffusion is sub-dominant, so approximating it barely matters. This survives retardation for a typical APVD (measured <~1e-3 up to R = 3).

  • In the molecular-diffusion-dominated corner (alpha_L ~ 0): ~1e-4 for smooth inputs, but degrading to ~1e-2 for sharp inputs (and ~5e-2 for sharp inputs with a very wide / bimodal APVD or a large single pore volume), because the symmetric time-Gaussian cannot reproduce the skewed molecular breakthrough. Retardation enlarges this corner: the Gaussian’s variance scales as sigma_t^2 ~ D_m * R^3, so R > 1 reaches the ~1e-2 looseness at a smaller D_m – a sharp input at D_m = 0.01 degrades from ~8e-4 at R = 1 to ~1.7e-2 at R = 2 and ~3.5e-2 at R = 3. Use gwtransport.diffusion_fast for exact results in this regime – in particular for heat transport (R > 1 with a large D_m).

The inverse (extraction_to_infiltration()) deconvolves the same approximate operator the forward applies. It assembles W = G . M directly in banded form (one Ibar gather plus a sparse G . M product – 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: it recovers the input up to the deconvolution conditioning, with the only error being the forward operator’s approximation of gwtransport.diffusion_fast (use that module when the approximation is unacceptable).

Available functions:

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', saturation_threshold=7.0)[source]#

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

Fast approximate counterpart of gwtransport.diffusion_fast.infiltration_to_extraction(). The advection + macrodispersion + microdispersion (alpha_L) part is the exact skewed D_m=0 Kreft-Zuber breakthrough applied on the native cumulative-volume grid; molecular diffusion (D_m) is a symmetric time-domain Gaussian. The result is flow-independent and accurate to ~3e-4 whenever alpha_L > 0 (the typical regime) for realistic solute D_m (~1e-4) or R = 1. It loosens to ~1e-2 (sharp inputs) in the molecular-diffusion-dominated corner (alpha_L ~ 0), and retardation enlarges that corner because the Gaussian variance grows as D_m * R^3 (a sharp input at D_m = 0.01 reaches ~1.7e-2 at R = 2 and ~3.5e-2 at R = 3). For machine precision – or heat transport with R > 1 and large D_m – 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 (GenericAlias[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 (GenericAlias[floating] | float) – Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.

  • longitudinal_dispersivity (GenericAlias[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.

  • saturation_threshold (float, default: 7.0) – Breakthrough-band cutoff U (default 7.0). Sets how far into the breakthrough tail the banded build reaches; see gwtransport.diffusion_fast.infiltration_to_extraction().

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:

GenericAlias[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', saturation_threshold=7.0)[source]#

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

Inverts the same approximate operator the forward applies: it assembles W = G . M (the advection+macro+micro band M times the molecular time-Gaussian G) 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 plus a sparse G . M product – 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; the approximation lives entirely in the forward operator vs gwtransport.diffusion_fast.

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 (GenericAlias[floating] | float) – Travel distance L [m]: scalar or one value per pore volume. Must be positive.

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

  • longitudinal_dispersivity (GenericAlias[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".

  • saturation_threshold (float, default: 7.0) – See infiltration_to_extraction(). Default 7.0.

Returns:

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

Return type:

GenericAlias[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', saturation_threshold=7.0)[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".

  • saturation_threshold (float, default: 7.0) – See infiltration_to_extraction(). Default 7.0.

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:

GenericAlias[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', saturation_threshold=7.0)[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".

  • saturation_threshold (float, default: 7.0) – See infiltration_to_extraction(). Default 7.0.

Returns:

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

Return type:

GenericAlias[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.

Key functions:

  • infiltration_to_extraction() - Main transport function combining advection, microdispersion, and molecular diffusion with explicit pore volume distribution and streamline lengths.

  • extraction_to_infiltration() - Inverse operation (deconvolution with dispersion).

  • gamma_infiltration_to_extraction() - Gamma-distributed pore volumes with dispersion. Models aquifer heterogeneity with 2-parameter gamma distribution. Parameterizable via (alpha, beta) or (mean, std). Discretizes gamma distribution into equal-probability bins.

  • gamma_extraction_to_infiltration() - Gamma-distributed pore volumes, deconvolution with dispersion. Symmetric inverse of gamma_infiltration_to_extraction.

When to choose this module vs gwtransport.diffusion_fast#

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

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 (issue #180).

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

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, defined as the solute mass flux divided by the volumetric fluid flux. This is what is measured when sampling the outflowing fluid. Compared to Bear’s leading-order resident concentration, it includes the dispersive boundary flux -D_s * dC_R/dx at x = L (with the solute-front dispersion D_s = D_m + alpha_L * v_s and velocity v_s = Q L / (R V_pore)), which is what makes the column-sum invariant integral Q c_out dt = integral Q c_in dt hold exactly under variable flow.

Microdispersion and molecular diffusion enter as the moving-frame variance

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

where tau(V) is the elapsed time since infiltration and xi(V) is the distance the parcel has actually travelled. Evaluating sigma^2 at each quadrature node — and avoiding any artificial capping past breakthrough — keeps Bear’s formula an exact solution of the variable-coefficient ADE, which the Kreft-Zuber identity relies on.

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:

GenericAlias[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.

The K-Z flux-correction term in C_F = C_R - (D_s/v_s) * dC_R/dx (solute-front velocity v_s = Q L / (R V_pore), dispersion D_s = D_m + alpha_L * v_s) is what makes the column-sum invariant exact under variable Q; see the module docstring for the derivation.

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:

GenericAlias[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_tikhonov(). This ensures mathematical consistency between forward and inverse operations.

NaN values in cout are rejected. The Tikhonov solver here does not mask NaN rows, so any NaN in cout would poison the solution. This differs from gwtransport.deposition.extraction_to_deposition(), whose regularized solver excludes NaN cout rows by construction.

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:

GenericAlias[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:

GenericAlias[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() - Generate comprehensive synthetic dataset with flow and concentration time series. Creates seasonal flow patterns with optional spill events, input concentration data via synthetic sinusoidal patterns, constant values, or real KNMI soil temperature, and extracted concentration computed through gamma-distributed pore volume transport. When diffusion parameters are provided, uses the diffusion module instead of pure advection. Returns DataFrame with flow, cin, cout columns plus attrs containing generation parameters and aquifer properties, and time edges (tedges).

  • generate_temperature_example_data() - Convenience wrapper around generate_example_data() with sensible defaults for temperature transport including thermal retardation, thermal diffusivity, and longitudinal dispersivity.

  • generate_ec_example_data() - Convenience wrapper around generate_example_data() with sensible defaults for electrical conductivity (EC) transport. EC is a conservative tracer (retardation factor 1.0) with negligible molecular diffusivity compared to thermal transport.

  • generate_example_deposition_timeseries() - Generate synthetic deposition time series for pathogen/contaminant deposition analysis. Combines baseline deposition, seasonal patterns, random noise, and episodic contamination events with exponential decay. Returns Series with deposition rates [ng/m²/day] and attrs containing generation parameters, and time edges (tedges). Useful for testing extraction_to_deposition deconvolution and deposition_to_extraction convolution 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.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.

generate_ec_example_data

Wrapper with EC 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.

generate_ec_example_data

Wrapper with EC transport defaults.

Notes

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

gwtransport.examples.generate_ec_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=500.0, cin_amplitude=150.0, measurement_noise=10.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=5e-05, longitudinal_dispersivity=1.0, streamline_length=100.0, rng=None)[source]#

Generate synthetic electrical conductivity and flow data for groundwater transport examples.

Convenience wrapper around generate_example_data() with sensible defaults for electrical conductivity (EC) transport. EC is a conservative tracer: dissolved ions travel at water velocity without retardation.

Typical parameter values for EC (dissolved ion) transport in various sand types. The molecular diffusivity represents effective ionic diffusion in porous media (free-water D_0 reduced by porosity/tortuosity). It is negligible compared to microdispersion at field scale.

Parameter

Fine sand

Medium sand

Coarse sand/gravel

retardation_factor R

1.0

1.0

1.0

molecular_diffusivity (m²/day)

3e-5 – 5e-5

4e-5 – 8e-5

5e-5 – 1e-4

longitudinal_dispersivity (m)

0.1–1.0

0.5–5.0

1.0–10.0

streamline_length (m)

site-specific

Parameters:
  • cin_mean (float, default: 500.0) – Mean infiltration EC [uS/cm, typical surface water EC].

  • cin_amplitude (float, default: 150.0) – Seasonal amplitude of infiltration EC [uS/cm].

  • measurement_noise (float, default: 10.0) – Standard deviation of the Gaussian measurement noise [uS/cm].

  • retardation_factor (float, default: 1.0) – Retardation factor (1.0 for a conservative tracer).

  • molecular_diffusivity (float, default: 5e-05) – Effective ionic diffusion [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.

generate_temperature_example_data

Wrapper with thermal transport defaults.

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.

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.

RAREF_RAREF_COLLISION = 'rarefaction_rarefaction_collision'#

Rarefaction boundary intersects with another rarefaction boundary.

DSW_FAN_EXHAUSTED = 'decaying_shock_fan_exhausted'#

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

OUTLET_CROSSING = 'outlet_crossing'#

Wave crosses outlet boundary.

class gwtransport.fronttracking.events.Event(theta, event_type, waves_involved, location, boundary_type=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#
__init__(theta, event_type, waves_involved, location, boundary_type=None)#
location: float#
boundary_type: str | 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 we synthesize temporary CharacteristicWave instances and reuse the analytical helpers.

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.

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.

The safe option (b) from the front-tracking rebuild plan: when a 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.handle_outlet_crossing(wave, theta_event, v_outlet)[source]#

Record a wave crossing the outlet boundary.

The wave is NOT deactivated — it remains for concentration queries at points between its origin and the outlet. The returned event record holds the cumulative flow theta at which the crossing occurs; the solver translates this to the user-facing time when appending to state.events.

Return type:

dict

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.

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

Return type:

float | GenericAlias[double]

abstractmethod total_concentration(c)[source]#

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

Return type:

float | GenericAlias[double]

abstractmethod concentration_from_retardation(r)[source]#

Invert retardation factor to obtain concentration.

Return type:

float | GenericAlias[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)

Parameters:
  • k_f (float) – Freundlich coefficient [(m³/kg)^(1/n)]. Must be positive.

  • n (float) – Freundlich exponent [-]. Must be positive and != 1.

  • bulk_density (float) – Bulk density of porous medium [kg/m³]. Must be positive.

  • porosity (float) – Porosity [-]. Must be in (0, 1).

  • c_min (float, default: 1e-12) – Minimum concentration threshold (the dry-soil singularity floor). For n>1, prevents infinite retardation as C→0. Default 1e-12 for all n.

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

n: float#

Freundlich exponent [-].

bulk_density: float#

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

porosity: float#

Porosity [-].

c_min: float = 1e-12#

Minimum concentration threshold to prevent infinite retardation.

__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]#

Compute retardation factor R(C).

The retardation factor relates concentration speed to pore water speed in (V, θ) coordinates:

dV/ = 1 / R(C)

For Freundlich sorption:

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

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

Returns:

r – Retardation factor [-]. Always >= 1.0.

Return type:

float | GenericAlias[double]

Notes

  • For n > 1: R decreases with increasing C (higher C travels faster)

  • For n < 1: R increases with increasing C (higher C travels slower)

  • n<1 with c_min=0: R(0)=1 (no sorption at zero, physically correct) because clamping to c_min=0 leaves C^((1/n)-1) = 0^positive = 0.

  • Otherwise: c is clamped to c_min before evaluation. This pairs with total_concentration(), which also clamps to c_min.

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.

total_concentration(c)[source]#

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

Total concentration includes both dissolved and sorbed mass:
C_total = C + (rho_b/n_por) * s(C)

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

Parameters:

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

Returns:

c_total – Total concentration [mass/volume]. Always >= c.

Return type:

float | GenericAlias[double]

Notes

This is the conserved quantity in the transport equation:

∂C_total/∂t + ∂(flow*C)/∂v = 0

The flux term only includes dissolved concentration because sorbed mass is immobile.

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.

concentration_from_retardation(r)[source]#

Invert retardation factor to obtain concentration analytically.

Given R, solves R = retardation(C) for C. This is used in rarefaction waves where the self-similar solution gives R as a function of position and time.

Parameters:

r (float | GenericAlias[double]) – Retardation factor [-]. Must be >= 1.0.

Returns:

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

Return type:

float | GenericAlias[double]

Notes

This inverts the relation:

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

The analytical solution is:

C = [(R-1) * n_por*n / (rho_b*k_f)]^(n/(1-n))

For n = 1 (linear sorption), the exponent n/(1-n) is undefined, which is why linear sorption must use ConstantRetardation class instead.

Examples

>>> sorption = FreundlichSorption(
...     k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
... )
>>> r = sorption.retardation(5.0)
>>> c = sorption.concentration_from_retardation(r)
>>> bool(np.isclose(c, 5.0, rtol=1e-14))
True
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.

Parameters:

retardation_factor (float) – Constant retardation factor [-]. Must be >= 1.0. R = 1.0 means no retardation (conservative tracer).

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 [-]. Must be >= 1.0.

__post_init__()[source]#

Validate parameters after initialization.

Raises:

ValueError – If retardation_factor is less than 1.0.

retardation(c)[source]#

Return constant retardation factor (independent of concentration).

Parameters:

c (float) – Dissolved concentration (not used for constant retardation).

Returns:

r – Constant retardation factor.

Return type:

float

total_concentration(c)[source]#

Compute total concentration for linear sorption.

For constant retardation:

C_total = C * R

Parameters:

c (float) – Dissolved concentration [mass/volume].

Returns:

c_total – Total concentration [mass/volume].

Return type:

float

concentration_from_retardation(r)[source]#

Not applicable for constant retardation.

With constant R, all concentrations have the same retardation, so inversion is not meaningful. This method raises an error.

Raises:

NotImplementedError – Always raised for constant retardation.

Return type:

float

shock_speed(c_left, c_right)[source]#

Compute shock speed dV/dθ for constant retardation.

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

Parameters:
  • c_left (float) – Concentrations (unused — kept for ABC compatibility).

  • c_right (float) – Concentrations (unused — kept for ABC compatibility).

Returns:

shock_speed – Shock speed dV/dθ = 1/R.

Return type:

float

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

Entropy condition for constant retardation: trivially satisfied.

With constant R every characteristic speed equals the shock speed in θ-space, so the Lax condition holds as an equality regardless of c_left/c_right.

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.

Parameters:
  • s_max (float) – Maximum sorption capacity [mass/mass of solid]. Must be positive.

  • k_l (float) – Half-saturation constant [mass/volume]. Concentration at which s = s_max / 2. Must be positive.

  • bulk_density (float) – Bulk density of porous medium [kg/m³]. Must be positive.

  • porosity (float) – Porosity [-]. Must be in (0, 1).

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

k_l: float#

Half-saturation constant [mass/volume].

bulk_density: float#

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

porosity: float#

Porosity [-].

__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]#

Compute retardation factor R(C).

For Langmuir sorption:

R(C) = 1 + A / (K_L + C)²

where A = rho_b * s_max * K_L / n_por.

Parameters:

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

Returns:

r – Retardation factor [-]. Always >= 1.0.

Return type:

float | GenericAlias[double]

Notes

  • R(0) = 1 + rho_b * s_max / (n_por * K_L) — always finite

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

  • R → 1 as C → ∞ (all sorption sites saturated)

total_concentration(c)[source]#

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

For Langmuir sorption:

C_total = C + (rho_b / n_por) * s_max * C / (K_L + C)

Parameters:

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

Returns:

c_total – Total concentration [mass/volume]. Always >= c.

Return type:

float | GenericAlias[double]

concentration_from_retardation(r)[source]#

Invert retardation factor to obtain concentration analytically.

Given R, solves R = 1 + A / (K_L + C)² for C:

C = sqrt(A / (R - 1)) - K_L

Parameters:

r (float | GenericAlias[double]) – Retardation factor [-]. Must be >= 1.0.

Returns:

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

Return type:

float | GenericAlias[double]

Notes

For R <= 1, returns 0.0 (unphysical region). For R >= R(0) = 1 + A/K_L², returns 0.0 (at or below zero concentration).

Examples

>>> sorption = LangmuirSorption(
...     s_max=0.1, k_l=5.0, bulk_density=1500.0, porosity=0.3
... )
>>> r = sorption.retardation(5.0)
>>> c = sorption.concentration_from_retardation(r)
>>> bool(np.isclose(c, 5.0, rtol=1e-14))
True
__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.

Parameters:
  • 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) – 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.

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 [-].

theta_s: float#

Saturated volumetric moisture content [-].

k_s: float#

Saturated hydraulic conductivity [length/time].

brooks_corey_lambda: float#

Pore-size distribution index λ [-].

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

total_concentration(c)[source]#

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

Return type:

float | GenericAlias[double]

retardation(c)[source]#

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

Return type:

float | GenericAlias[double]

concentration_from_retardation(r)[source]#

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

Return type:

float | GenericAlias[double]

__init__(theta_r, theta_s, k_s, brooks_corey_lambda)#
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.

Parameters:
  • theta_r (float) – Residual volumetric moisture content [-].

  • theta_s (float) – Saturated volumetric moisture content [-].

  • k_s (float) – Saturated hydraulic conductivity [length/time].

  • van_genuchten_n (float) – Shape parameter n_vG > 1. m = 1 1/n_vG is derived.

  • mualem_l (float, default: 0.5) – Pore-connectivity parameter L. Default 0.5 (standard Mualem). Must satisfy L >= 0. Setting L = 0 (Burdine variant) gives a closed-form S_e(C) inverse; L != 0 requires brentq.

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. Used for retardation(C) (after solving S_e(C)) and for the brentq objective in concentration_from_retardation(R). The formula is inlined at both call sites, not exposed as a separate method.

The class checks monotonicity of dK_M/dS_e at a single pair of sample points in __post_init__ (cheap directional check). Truly pathological parameter combinations that yield a non-monotone curve surface as a brentq ValueError at the first inversion call.

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
theta_r: float#

Residual volumetric moisture content [-].

theta_s: float#

Saturated volumetric moisture content [-].

__init__(theta_r, theta_s, k_s, van_genuchten_n, mualem_l=0.5)#
k_s: float#

Saturated hydraulic conductivity [length/time].

van_genuchten_n: float#

vG shape parameter n_vG > 1.

mualem_l: float = 0.5#

Mualem pore-connectivity L. Default 0.5.

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 run a single-sample monotonicity check.

Raises:

ValueError – If parameters are outside their valid range, or if the cheap monotonicity sample at S_e = 0.5 vs 0.99 indicates dK_M/dS_e is non-monotone (pathological).

Return type:

None

total_concentration(c)[source]#

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

Return type:

float | GenericAlias[double]

retardation(c)[source]#

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

Return type:

float | GenericAlias[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 | GenericAlias[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:
  • cin (GenericAlias[floating]) – Inlet concentration [mass/volume].

  • theta_edges (GenericAlias[floating]) – Cumulative-flow edges; length len(cin) + 1.

  • aquifer_pore_volume (float) – Total pore volume [m³]. Must be positive.

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model.

Returns:

theta_first_arrival – Cumulative-flow θ at which c_first is fully present at the outlet [m³]. Returns np.inf only if cin is identically zero.

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.

Functions#

concentration_at_point(v, theta, waves, sorption)
compute_breakthrough_curve(theta_array, v_outlet, waves, sorption)
compute_bin_averaged_concentration_exact(theta_bin_edges, v_outlet, waves, sorption, *, cin=None, theta_edges_inlet=None)
compute_domain_mass(theta, v_outlet, waves, sorption)
compute_cumulative_inlet_mass(theta, cin, theta_edges)
compute_cumulative_outlet_mass(theta, v_outlet, waves, sorption, *, cin, theta_edges)
compute_total_outlet_mass(v_outlet, sorption, *, cin, theta_edges) -> float

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.

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

Wave priority: decaying shocks first (closed-form analytical), then rarefaction fans (spatial extent), then most recently crossing shock or rarefaction tail, then characteristics. If no active wave controls the point, returns 0.0 (initial condition).

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 (GenericAlias[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:

GenericAlias[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].

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_fixed. 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=None, theta_edges_inlet=None)[source]#

θ-bin-averaged outlet concentration.

For each θ-bin [θ_i, θ_{i+1}]:

C_avg = (1 / Δθ) · ∫_{θ_i}^{θ_{i+1}} C(v_outlet, θ) dθ

With cin + theta_edges_inlet provided (recommended for multi-DSW cases), uses the conservation-law identity C_avg = (Δm_in Δm_dom) / Δθ per bin — analytical and explicit, no outlet-side fan dispatch. Otherwise falls back to outlet-segment integration (correct for canonical single-DSW cases; may miscount multi-DSW or n<1 mirror geometries).

Parameters:
  • theta_bin_edges (GenericAlias[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 | None, default: None) – Inlet concentration per inlet θ-bin. When provided with theta_edges_inlet, the conservation form is used.

  • theta_edges_inlet (GenericAlias[floating] | None, default: None) – θ 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:

GenericAlias[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:
  • theta (float) – Cumulative flow up to which to integrate [m³].

  • v_outlet (float) – Outlet position [m³].

  • waves (Sequence[Wave]) – All waves in the simulation.

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model.

  • cin (ArrayLike) – Inlet concentration per θ-bin [mass/volume].

  • theta_edges (GenericAlias[floating]) – θ bin edges [m³], length len(cin) + 1.

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(v_outlet, sorption, *, 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. The previous formula m_in_total C_T(c_∞)·v_outlet paired the FINITE record integral with the infinite-time steady-state fill and went negative whenever m_in_total < C_T(c_∞)·v_outlet, which is not a physical outlet mass.

Parameters:
  • v_outlet (float) – Outlet position [m³] (unused for c_∞ = 0; the +inf branch does not need it).

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model (kept for API symmetry; no C_T evaluation is required).

  • cin (ArrayLike) – Inlet concentration per θ-bin [mass/volume].

  • theta_edges (GenericAlias[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.

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_breakthrough_curve

Outlet breakthrough curve for the same state.

plot_wave_interactions

Event timeline of wave interactions.

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_breakthrough_curve(state, ax=None, *, t_max=None, n_rarefaction_points=50, figsize=(12, 6), t_first_arrival=None)[source]#

Plot exact analytical concentration breakthrough curve at outlet.

Uses wave segment information to plot the exact analytical solution without discretization. Constant concentration regions are plotted as horizontal lines, and rarefaction regions are plotted using their exact self-similar solutions.

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.

  • n_rarefaction_points (int, default: 50) – Number of points to use for plotting rarefaction segments (analytical curves). Default 50 per rarefaction segment.

  • figsize (tuple[float, float], default: (12, 6)) – Figure size in inches (width, height). Default (12, 6).

  • t_first_arrival (float | None, default: None) – First arrival time for marking spin-up period [days]. If None, spin-up period is not plotted.

Returns:

ax – Axes object containing the breakthrough curve.

Return type:

Axes

See also

plot_vt_diagram

Space-time diagram of the same waves.

plot_front_tracking_summary

Multi-panel summary combining these views.

gwtransport.fronttracking.output.compute_breakthrough_curve

Underlying analytical curve.

gwtransport.advection.infiltration_to_extraction_nonlinear_sorption

Produces the tracker state.

Notes

  • Uses identify_outlet_segments to get exact analytical segment boundaries

  • Constant concentration segments plotted as horizontal lines (no discretization)

  • Rarefaction segments plotted using exact self-similar solution

  • Shocks appear as instantaneous jumps at exact crossing times

  • No bin averaging or discretization artifacts

Examples

from gwtransport.fronttracking.solver import FrontTracker

tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption)
tracker.run()
ax = plot_breakthrough_curve(tracker.state)
ax.figure.savefig("exact_breakthrough.png")
gwtransport.fronttracking.plot.plot_wave_interactions(state, ax=None, *, figsize=(14, 8))[source]#

Plot event timeline showing wave interactions.

Creates a scatter plot showing when and where different types of wave interactions occur during the simulation. Event records carry the cumulative flow at which the event occurred ("theta" key) and position ("location"); this function translates θ → user-facing days via state.t_at_theta for display.

Parameters:
  • state (FrontTrackerState) – Complete simulation state containing all events.

  • ax (Axes | None, default: None) – Existing axes to plot into. If None, a new figure and axes are created using figsize.

  • figsize (tuple[float, float], default: (14, 8)) – Figure size in inches (width, height). Default (14, 8).

Returns:

ax – Axes object containing the event timeline.

Return type:

Axes

Notes

  • Each event type is shown with a different color and marker.

  • Outlet crossings are shown separately from internal collisions.

  • Event locations are plotted in the (time, position) plane.

Examples

from gwtransport.fronttracking.solver import FrontTracker

tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption)
tracker.run()
ax = plot_wave_interactions(tracker.state)
ax.figure.savefig("wave_interactions.png")
gwtransport.fronttracking.plot.plot_inlet_concentration(tedges, cin, ax=None, *, t_first_arrival=None, event_markers=None, color='blue', t_max=None, xlabel='Time [days]', ylabel='Concentration', title='Inlet Concentration', figsize=(8, 5), **step_kwargs)[source]#

Plot inlet concentration as step function with optional markers.

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

  • event_markers (list[dict] | None, default: None) – Event markers to add. Each dict should have keys: ‘time’, ‘label’, ‘color’.

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

  • xlabel (str, default: 'Time [days]') – Label for x-axis. Default ‘Time [days]’.

  • ylabel (str, default: 'Concentration') – Label for y-axis. Default ‘Concentration’.

  • title (str, default: 'Inlet Concentration') – Plot title. Default ‘Inlet Concentration’.

  • figsize (tuple[float, float], default: (8, 5)) – Figure size if creating new figure. Default (8, 5).

  • **step_kwargs – Additional arguments passed to ax.plot().

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, inlet_color='blue', outlet_exact_color='blue', outlet_binned_color='red', first_arrival_color='green')[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.

  • inlet_color (str, default: 'blue') – Color for inlet concentration. Default ‘blue’.

  • outlet_exact_color (str, default: 'blue') – Color for exact outlet curve. Default ‘blue’.

  • outlet_binned_color (str, default: 'red') – Color for bin-averaged outlet. Default ‘red’.

  • first_arrival_color (str, default: 'green') – Color for first arrival marker. Default ‘green’.

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_breakthrough_curve

Outlet breakthrough curve for the same state.

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.

class gwtransport.fronttracking.solver.FrontTrackerState(waves, events, theta_current, v_outlet, sorption, cin, flow, tedges, tedges_days, theta_edges)[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 (GenericAlias[floating]) – tedges as days from tedges[0], length len(flow) + 1.

  • theta_edges (GenericAlias[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]#
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 θ. Events scheduled at zero-flow bin boundaries therefore align with the END of the zero-flow interval — pick one convention and call it documented.

Return type:

float

theta_at_t(t)[source]#

Translate user-facing time t [days] to cumulative flow θ [m³].

Piecewise linear forward map. Outside the input range the boundary flow is extrapolated.

Return type:

float

theta_at_t_array(t)[source]#

Vectorized theta_at_t: map an array of times t [days] to θ [m³].

Element-wise identical to theta_at_t(); replaces per-scalar loops in the plotting/output breakthrough routines.

Parameters:

t (ArrayLike) – User-facing time points [days].

Returns:

Cumulative flow θ at each t [m³].

Return type:

GenericAlias[floating]

__init__(waves, events, theta_current, v_outlet, sorption, cin, flow, tedges, tedges_days, theta_edges)#
class gwtransport.fronttracking.solver.FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption)[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)[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, *, verbose=False)[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 runtime 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 and is exercised by TestEndToEndConservation / TestIndependentDomainMass.

Raises:

RuntimeError – If an active shock violates the Lax entropy condition.

gwtransport.fronttracking.solver.find_unresolved_interaction(state)[source]#

Locate an unresolved wave–wave interaction inside the transport domain.

The event-driven solver resolves shock↔shock, shock↔characteristic and shock↔rarefaction collisions (the last into a DecayingShockWave), but it never collides anything with a decaying shock, nor composes two fans that come to occupy the same region. Such an unresolved interaction leaves the non-interacting wave objects overlapping, so the exact nonlinear multi-front field is not represented — the public cout degrades to a spurious linear superposition of a nonlinear operator (mass-fabricating once the reader clamps the resulting negative bins to zero). This detects the first offending interaction so the public API can refuse the input rather than return a wrong, non-conservative answer. Two complementary detectors run over the input θ-window (0, theta_edges[-1]]:

  1. Geometric fan overlap. When two or more fan-bearing waves (rarefactions / decaying shocks) cover a common point inside [0, v_outlet], their composite field is wrong even when total mass happens to be conserved (a positive-but-wrong cout with no negative bin — e.g. two decaying shocks with a zero fan tail, or a later pulse’s fan sweeping an earlier one). A symptom-only proxy cannot see this class, so the geometric scan is a necessary complement.

  2. Conservation symptom. The cumulative outlet mass m_out(θ) = m_in(θ) m_dom(θ) must be non-decreasing in θ (mass exits 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 a shock overtaking another shock / rarefaction / decaying-shock fan. The two fans need NOT share an in-domain point (they may only cross beyond v_outlet), so the geometric scan misses this dominant multi-pulse class; the monotonicity check catches it.

Parameters:

state (FrontTrackerState) – Completed simulation state (after FrontTracker.run()).

Returns:

A short description (position/θ and mechanism) of the first offending interaction, or None when the solution is a clean single-front / well-separated superposition that the reader represents exactly.

Return type:

str | None

Notes

Both scans stay strictly inside the inlet θ-window, so the benign out-of-window saturation clamp (a single-front run whose output bins extend past the last injected mass) does not trip the symptom check. Well-separated pulses that clear a short column before overtaking one another (their fans never share an in-domain point and their cumulative outflow stays monotone) are correctly accepted.

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.

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

The change from (V, t) to (V, θ) makes every wave velocity a property of the sorption isotherm alone — flow no longer enters into wave dynamics. Time- varying flow is absorbed entirely into the θ(t) mapping at the API boundary; no wave needs recreation when the flow rate changes.

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

Parameters:
  • theta_start (float) – Cumulative flow at which the wave forms [m³].

  • v_start (float) – Position at which the wave forms [m³].

  • is_active (bool, default: True) – Whether wave is currently active. Default True.

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

Parameters:
  • theta_start (float) – Formation cumulative flow [m³].

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

  • concentration (float) – Constant concentration carried [mass/volume].

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model determining the speed.

  • is_active (bool, default: True) – Activity status. Default True.

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 [mass/volume].

sorption: NonlinearSorption | ConstantRetardation#

Sorption model determining the speed.

__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)#
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))
Parameters:
  • theta_start (float) – Formation cumulative flow [m³].

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

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

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

  • sorption (NonlinearSorption | ConstantRetardation) – Sorption model.

  • is_active (bool, default: True) – Activity status. Default True.

  • speed (float, optional) – Shock speed dV/dθ. Computed from Rankine-Hugoniot in __post_init__.

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

Parameters:
  • theta_start (float) – Formation cumulative flow [m³].

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

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

  • is_active (bool, default: True) – Activity status. Default True.

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)[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 and c_decay_initial > c_fixed — 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 c_decay_initial < c_fixed mirror falls through to numerical.)

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

Parameters:
  • theta_start (float) – Cumulative flow at which the merged wave forms (collision θ) [m³].

  • v_start (float) – Position at which the merged wave forms [m³]. Should equal v_origin + (V_s) at θ=theta_start for a fan-consistent construction.

  • c_decay_initial (float) – Concentration on the decaying side at θ=theta_start [mass/volume]. Must be 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 (issue #222).

  • c_fixed (float) – Concentration on the non-decaying side [mass/volume]. Constant in θ. Non-negative.

  • c_fan_tail (float) – Concentration at the fan’s far boundary [mass/volume]. The wave is valid only while c_decay (c_fan_tail, c_decay_initial]; at c_fan_tail the fan is exhausted. Non-negative.

  • decay_side (str) – 'left' or 'right'. See class docstring.

  • v_origin (float) – Position of the rarefaction apex [m³].

  • theta_origin (float) – Cumulative flow at the rarefaction apex [m³]. Must satisfy theta_origin < theta_start.

  • sorption (NonlinearSorption) – Sorption model (any concentration-dependent isotherm).

  • is_active (bool, default: True) – Activity flag. Default True.

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

c_fixed: float#

Concentration on the non-decaying side [mass/volume].

c_fan_tail: float#

Concentration at the fan’s far boundary [mass/volume]; bounds the decay.

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³].

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

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

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

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.

Available functions:

  • parse_parameters() - Parse and validate gamma distribution parameters from either (mean, std, loc) or (alpha, beta, loc). Requires exactly one parameter pair and raises ValueError if both are supplied; validates positivity and ordering constraints.

  • mean_std_loc_to_alpha_beta() - Convert physically intuitive (mean, std, loc) parameters to gamma shape/scale parameters.

  • alpha_beta_loc_to_mean_std() - Convert gamma (alpha, beta, loc) parameters back to (mean, std) for physical interpretation.

  • bins() - Primary function for transport modeling. Creates discrete probability bins from the (optionally shifted) gamma distribution with equal-probability bins (default) or custom quantile edges. Returns bin edges, expected values (mean pore volume within each bin), and probability masses (weight in transport calculations).

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, 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

alpha_beta_loc_to_mean_std

Convert shape/scale/loc parameters to mean and std.

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.alpha_beta_loc_to_mean_std(*, alpha, beta, loc=0.0)[source]#

Convert shape, scale, and location of gamma distribution to mean and standard deviation.

Parameters are validated via parse_parameters(), which raises ValueError if alpha or beta are non-positive or loc is negative.

Parameters:
  • alpha (float) – Shape parameter of the gamma distribution. Must be positive.

  • beta (float) – Scale parameter of the gamma distribution. Must be positive.

  • loc (float, default: 0.0) – Location (horizontal shift) of the gamma distribution. Must be non-negative. Default is 0.0.

Return type:

tuple[float, float]

Returns:

  • mean (float) – Mean of the gamma distribution, equal to alpha * beta + loc.

  • std (float) – Standard deviation of the gamma distribution, equal to sqrt(alpha) * beta. std is invariant under the loc shift.

See also

mean_std_loc_to_alpha_beta

Convert mean/std/loc to shape and scale parameters.

parse_parameters

Parse and validate gamma distribution parameters.

Examples

>>> from gwtransport.gamma import alpha_beta_loc_to_mean_std
>>> alpha = 13.72  # shape parameter
>>> beta = 2187.0  # scale parameter
>>> mean, std = alpha_beta_loc_to_mean_std(alpha=alpha, beta=beta)
>>> print(f"Mean pore volume: {mean:.0f} m³")
Mean pore volume: 30006 m³
>>> print(f"Std pore volume: {std:.0f} m³")
Std pore volume: 8101 m³
gwtransport.gamma.bins(*, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, quantile_edges=None)[source]#

Divide a (shifted) gamma distribution into bins and compute bin properties.

If n_bins is provided, the gamma distribution is divided into n_bins equal-mass bins. If quantile_edges is provided, the distribution is divided into bins defined by those quantile edges. The quantile edges must be a strictly increasing 1-D array of at least 3 entries (>= 2 bins) in [0, 1], with the first and last entries exactly 0 and 1; n_bins is then ignored.

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.

  • quantile_edges (ArrayLike | None, default: None) – Quantile edges for binning. Must be a strictly increasing 1-D array of at least 3 entries (>= 2 bins), all in [0, 1], with the first and last entries exactly 0 and 1. If provided, n_bins is ignored.

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, GenericAlias[floating]]

Raises:

ValueError – If n_bins is not greater than 1, if quantile_edges is not a strictly increasing 1-D array in [0, 1] with endpoints exactly 0 and 1, or if parameter validation in parse_parameters() fails.

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)

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

Create bins with custom quantile edges:

>>> import numpy as np
>>> quantiles = np.array([0.0, 0.25, 0.5, 0.75, 1.0])
>>> result = bins(mean=30000.0, std=8100.0, quantile_edges=quantiles)
>>> print(f"Number of bins: {len(result['probability_mass'])}")
Number of bins: 4

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() - Calculate log removal from residence times and decay rate coefficient. Uses formula: Log Removal = log10_decay_rate * residence_time. Handles single values, 1D arrays, or multi-dimensional arrays of residence times. Returns log removal values with same shape as input.

  • decay_rate_to_log10_decay_rate() - Convert a natural-log decay rate constant lambda [1/day] to a log10 decay rate mu [log10/day].

  • log10_decay_rate_to_decay_rate() - Convert a log10 decay rate mu [log10/day] to a natural-log decay rate constant lambda [1/day].

  • parallel_mean() - Calculate weighted average log removal for parallel flow systems. Computes overall efficiency when multiple treatment paths operate in parallel with different log removal values and flow fractions. Uses formula: Total Log Removal = -log10(sum(F_i * 10^(-LR_i))) where F_i is flow fraction and LR_i is log removal for path i. Supports multi-dimensional arrays via axis parameter for batch processing. Assumes equal flow distribution if flow_fractions not provided.

  • gamma_pdf() - Compute probability density function (PDF) of log removal given gamma-distributed residence time. Since R = mu*T and T ~ Gamma(alpha, beta), R follows a Gamma(alpha, mu*beta) distribution.

  • gamma_cdf() - Compute cumulative distribution function (CDF) of log removal given gamma-distributed residence time. Returns probability that log removal is less than or equal to specified values.

  • gamma_mean() - Compute effective (parallel) mean log removal for gamma-distributed residence time. Uses the moment generating function of the gamma distribution to compute the log-weighted average: LR_eff = mu * loc + alpha * log10(1 + beta * mu * ln(10)).

  • gamma_find_flow_for_target_mean() - Find flow rate that produces specified target effective mean log removal given gamma-distributed aquifer pore volume. For loc == 0 this is the closed-form inverse: flow = beta * mu * ln(10) / (10^(target_mean / alpha) - 1); for loc > 0 the transcendental equation is solved numerically.

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.

This function calculates the log removal based on residence times and a log10 decay rate coefficient using first-order decay:

Log Removal = log10_decay_rate * residence_time

This corresponds to exponential decay of pathogen concentration: C_out/C_in = 10^(-log10_decay_rate * residence_time).

Parameters:
  • residence_times (ArrayLike) – Residence times in days. The formula evaluates log10_decay_rate * residence_times for any real input; negative values produce negative log removal (mathematical amplification) and the caller is responsible for sign interpretation.

  • log10_decay_rate (float) – Log10 decay rate coefficient (log10/day). Relates residence time to log removal efficiency via first-order decay. Negative values correspond to first-order production rather than decay.

Returns:

log_removals – Array of log removal values corresponding to the input residence times. Same shape as input residence_times.

Return type:

GenericAlias[floating]

See also

decay_rate_to_log10_decay_rate

Convert natural-log decay rate to log10 decay rate

log10_decay_rate_to_decay_rate

Convert log10 decay rate to natural-log decay rate

gamma_mean

Compute mean log removal for gamma-distributed residence times

gamma_find_flow_for_target_mean

Find flow rate to achieve target log removal

parallel_mean

Calculate weighted average for parallel flow systems

gwtransport.residence_time.full

Compute residence times from flow and pore volume

Residence Time

Time in aquifer determines pathogen contact time

Notes

Log removal is a logarithmic measure of pathogen reduction: - Log 1 = 90% reduction - Log 2 = 99% reduction - Log 3 = 99.9% reduction

The first-order decay model is mathematically identical to radioactive decay used in tracer dating. To convert a published natural-log decay rate lambda [1/day] to log10_decay_rate mu [log10/day], use decay_rate_to_log10_decay_rate().

Examples

>>> import numpy as np
>>> from gwtransport.logremoval import residence_time_to_log_removal
>>> residence_times = np.array([10.0, 20.0, 50.0])
>>> log10_decay_rate = 0.2
>>> residence_time_to_log_removal(
...     residence_times=residence_times, log10_decay_rate=log10_decay_rate
... )
array([ 2.,  4., 10.])
>>> # Single residence time
>>> residence_time_to_log_removal(residence_times=5.0, log10_decay_rate=0.3)
np.float64(1.5)
>>> # 2D array of residence times
>>> residence_times_2d = np.array([[10.0, 20.0], [30.0, 40.0]])
>>> residence_time_to_log_removal(
...     residence_times=residence_times_2d, log10_decay_rate=0.1
... )
array([[1., 2.],
       [3., 4.]])
gwtransport.logremoval.decay_rate_to_log10_decay_rate(decay_rate)[source]#

Convert a natural-log decay rate constant to a log10 decay rate.

Converts lambda [1/day] to mu [log10/day] using the relationship mu = lambda / ln(10).

Parameters:

decay_rate (float) – Natural-log first-order decay rate constant lambda (1/day). For example, from tracer dating: lambda = ln(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

residence_time_to_log_removal

Apply the log10 decay rate

Examples

>>> from gwtransport.logremoval import decay_rate_to_log10_decay_rate
>>> import numpy as np
>>> # Convert a decay rate of ln(2)/30 (half-life of 30 days)
>>> decay_rate = np.log(2) / 30
>>> decay_rate_to_log10_decay_rate(decay_rate)
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.

Converts mu [log10/day] to lambda [1/day] using the relationship 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 | GenericAlias[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:

GenericAlias[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:

GenericAlias[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 provides one public function:

  • root_zone_to_water_table_kinematic_wave() — exact front-tracking solver for gravity-driven percolation between the bottom of the root zone and the water table, following the Kinematic-Wave method described in Olsthoorn (2026, Stromingen 32(1)). Supports Brooks-Corey and van Genuchten-Mualem constitutive curves and a time-varying multiplicative scaling of K(θ) (e.g. for temperature-corrected viscosity).

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

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[GenericAlias[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 of the radial ASR knowledge base: 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. A single inject-then-extract cycle with no intervening rest uses the closed-form echo operator (gwtransport._radial_asr_compose, KB Sec. 10a) – exact for arbitrary within-phase variable flow, with the exact temporal moments. Any other signed-flow schedule (more reversals / multi-cycle ASR, or a single cycle with a rest under nonzero D_m) uses the reused-propagator-matrix engine (gwtransport._radial_asr_reuse, KB addendum Sec. A1-A7), which composes the exact per-phase kernels (Airy / Riccati / Bessel) through the interior two-point Green’s functions. Each per-reversal field hand-off f_out = P @ f is a bounded linear operator; its matrix P is built once per distinct (direction, phase volume) from a single batched de Hoog inversion and reused at every recurrence, so the special-function + inversion cost is O(distinct phase volumes) rather than O(reversals). It is bit-equivalent, to the de Hoog floor, to the per-reversal grid-free composition. Molecular diffusion during pumping (the D_m > 0 Whittaker kernel) is evaluated through the log-derivative Riccati ODE (gwtransport._radial_asr_kernels.resolvent_riccati) – 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, KB Sec. 9) is used only as a test oracle. The propagator matrices are assembled on the Bromwich contour (Re s > 0), where the field hand-off is well-conditioned at any Peclet. The engine is chosen automatically; cycles are expressed through the flow sign pattern, not an argument.

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:

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 (KB Sec. 7) 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.

  • 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:

GenericAlias[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:

GenericAlias[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.

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:

GenericAlias[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:

GenericAlias[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 extracted concentration from recharge concentration (and, in the bounded model, upstream-boundary concentration). Exact closed-form solution; output is a flow-weighted (bounded) or recharge-weighted (unbounded) bin average.

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:

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

Available functions:

  • full() - Compute the flow-weighted mean residence time over output bins, per pore volume (full (n_pore_volumes, n_bins) array). Follows the package’s bin-edge convention and is the form consumed elsewhere in the package. Supports both forward (infiltration to extraction) and reverse (extraction to infiltration) directions.

  • mean() - Compute the mean residence time over output bins for a discrete aquifer pore-volume distribution (an array of equally-weighted pore volumes). Collapses the pore-volume axis to a single per-bin series. The spinup policy (default "constant") warm-starts the spin-up by extrapolating the boundary flow.

  • gamma() - Compute the closed-form mean residence time over output bins for a (shifted) gamma aquifer pore-volume distribution, with no pore-volume discretization. The spinup policy (default "constant") warm-starts the spin-up; spinup=0.0 instead renormalizes over the covered sub-mass exactly.

  • fraction_explained_full(), fraction_explained_mean(), fraction_explained_gamma() - Compute the advective fraction of each output bin that is explained by the flow record: the flow-weighted share of the bin whose retarded advective parcel was infiltrated/extracted inside the record. full returns one row per pore volume, mean the equal-weight discrete-APVD mean, and gamma the closed-form (shifted) gamma-APVD value, mirroring full() / mean() / gamma(). These are purely advective – molecular diffusion and microdispersion spread each bin over a range of infiltration times that is not captured here, 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).

  • freundlich_retardation() - Compute concentration-dependent retardation factors from a Freundlich isotherm, for use as the retardation_factor input to the transport functions.

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.

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 (and what the diffusion modules consume to compute a per-bin retarded velocity).

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:

GenericAlias[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:

GenericAlias[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:

GenericAlias[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), so the two agree everywhere. 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.

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:

GenericAlias[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:

GenericAlias[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:

GenericAlias[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:

GenericAlias[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 helpers (_clipped_linear_integral and _positive_part_integral) used by the deposition module’s banded weight builder to integrate clip(y(x), y_lower, y_upper) over each cin bin of a streamtube’s residence window.

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/averaging, bin overlap calculations, underdetermined system solvers, and external data retrieval.

Available functions:

  • step_plot_coords() - Compute step-plot coordinates from bin edges and bin-averaged values. Returns paired x/y arrays for plotting piecewise-constant functions with ax.plot(x, y).

  • _make_strictly_monotone (private) - Bump consecutive duplicates in a non-decreasing array by k * ulp(max) so it becomes strictly monotone. Used before V → t inversions to prevent np.interp from silently picking one limit at plateau levels.

  • cumulative_flow_volume() - Cumulative infiltrated/extracted volume from per-bin flow rates and bin widths, prepended with a leading zero. Optionally bumped to strict monotonicity for V → t inversions.

  • linear_interpolate() - Linear interpolation using numpy’s optimized interp function. Automatically handles unsorted data with configurable extrapolation (None for clamping, float for constant values). Handles multi-dimensional query arrays.

  • linear_average() - Compute average values of piecewise linear time series between specified x-edges. Supports 1D or 2D edge arrays for batch processing. Handles NaN values and offers multiple extrapolation methods (‘nan’, ‘outer’, ‘raise’).

  • time_bin_overlap() - Calculate fraction of time bins overlapping with specified time ranges. Similar to partial_isin but for time-based bin overlaps with list of (start, end) tuples.

  • simplify_bins() - Simplify a piecewise-constant time series by merging adjacent bins whose values are within a tolerance. Uses volume-weighted (flow x width) averaging when flow is provided, otherwise width-weighted. Direction-independent via largest-jump splitting.

  • compute_time_edges() - Compute DatetimeIndex of time bin edges from explicit edges, start times, or end times. Validates consistency with expected number of bins and handles uniform spacing extrapolation.

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.

  • solve_tikhonov() - Solve linear system with Tikhonov regularization toward a target. Well-determined modes follow the data; poorly-determined modes are pulled toward the target.

  • compute_reverse_target() - Build the regularization target for the inverse problem by transposing and row-normalizing the forward coefficient matrix. Consumed by solve_tikhonov() and solve_inverse_transport().

  • solve_inverse_transport() - Solve the inverse transport problem (deconvolution) via Tikhonov regularization. Shared by advection, diffusion, and diffusion_fast extraction_to_infiltration functions.

  • solve_inverse_transport_banded() - Memory-light banded equivalent of solve_inverse_transport() for a forward operator stored in banded layout. Assembles the Tikhonov normal equations directly in banded form and solves them via banded Cholesky.

  • solve_underdetermined_system() - Solve underdetermined linear system (Ax = b, m < n) with nullspace regularization. Handles NaN values by row exclusion. Supports built-in objectives (‘squared_differences’, ‘summed_differences’) or custom callable objectives. Used by gwtransport.deposition.

  • get_soil_temperature() - Download soil temperature data from KNMI weather stations with automatic caching. Supports stations 260 (De Bilt), 273 (Marknesse), 286 (Nieuw Beerta), 323 (Wilhelminadorp). Returns DataFrame with columns TB1-TB5, TNB1-TNB2, TXB1-TXB2 at various depths. Daily cache prevents redundant downloads.

  • _generate_failed_coverage_badge (private) - Generate SVG badge indicating failed coverage using genbadge library. Used in CI/CD workflows.

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:

GenericAlias[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.

Automatically handles unsorted reference data by sorting it first.

Parameters:
  • x_ref (ArrayLike) – Reference x-values. If unsorted, will be automatically sorted.

  • 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:

GenericAlias[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])

Handles unsorted reference data automatically:

>>> x_unsorted = np.array([3.0, 1.0, 4.0, 2.0])
>>> y_unsorted = np.array([30.0, 10.0, 40.0, 20.0])
>>> linear_interpolate(x_ref=x_unsorted, y_ref=y_unsorted, x_query=x_query)
array([10., 15., 25., 35., 40.])
gwtransport.utils.linear_average(*, x_data, y_data, x_edges, extrapolate_method='nan')[source]#

Compute the average value of a piecewise linear time series between specified x-edges.

Parameters:
  • x_data (ArrayLike) – x-coordinates of the time series data points, must be in ascending order.

  • y_data (ArrayLike) –

    y-coordinates of the time series data points. Can be 1D or 2D.

    • If 1D: shape (n_data,) – a single series.

    • If 2D: shape (n_series_y, n_data) – multiple series sharing the same x_data. The leading axis is averaged independently per row. Cannot be combined with 2D x_edges (each row of x_edges and each row of y_data would otherwise have to broadcast against each other, which is not supported).

  • x_edges (ArrayLike) –

    x-coordinates of the integration edges.

    • If 1D: shape (n_edges,), must be in ascending order.

    • If 2D: shape (n_series_x, n_edges), each row must be in ascending order.

  • extrapolate_method (str, default: 'nan') –

    Method for handling bin edges that fall outside x_data. Default is 'nan'.

    • 'outer': average over the in-range portion of each bin (clip-then-average). The bin width used for normalisation is the clipped width, not the original width. For example, x_data = y_data = [1, 2, 3] and x_edges = [0, 5] returns 2.0 (integral over [1, 3] divided by clipped width 2), not 2.2 (which a constant-extension scheme would give).

    • 'nan': bins that extend outside x_data are returned as nan.

    • 'raise': raise an error if any bin edge falls outside x_data.

Returns:

2D array of average values between consecutive pairs of x_edges. Shape is (n_series, n_bins) where n_bins = n_edges - 1 and n_series = max(n_series_x, n_series_y). Both x_edges and y_data being 1D yields n_series = 1.

Return type:

GenericAlias[floating]

Raises:

ValueError – If x_edges is not 1D or 2D. If y_data is not 1D or 2D. If both x_edges and y_data are 2D. If x_data and y_data have incompatible shapes or are empty. If x_edges has fewer than 2 values per row. If x_data is not in ascending order. If x_edges rows are not in ascending order. If extrapolate_method is 'raise' and any edge falls outside the data range.

Notes

NaN handling is asymmetric between 1D and 2D ``y_data``.

  • 1D y_data is treated as a single series; internal NaN gaps are silently bridged by linear interpolation across the gap (via np.interp with left=nan, right=nan).

  • 2D y_data is treated row-wise; any output bin whose [edge_left, edge_right] touches a NaN segment in that row is set to NaN, while other rows are unaffected.

Callers that need NaN-bridging behaviour across multiple series must pre-fill (e.g., pd.DataFrame.interpolate) before calling.

Examples

>>> import numpy as np
>>> from gwtransport.utils import linear_average
>>> x_data = [0, 1, 2, 3]
>>> y_data = [0, 1, 1, 0]
>>> x_edges = [0, 1.5, 3]
>>> linear_average(
...     x_data=x_data, y_data=y_data, x_edges=x_edges
... )
array([[0.666..., 0.666...]])
>>> x_edges_2d = [[0, 1.5, 3], [0.5, 2, 3]]
>>> linear_average(x_data=x_data, y_data=y_data, x_edges=x_edges_2d)
array([[0.66666667, 0.66666667],
       [0.91666667, 0.5       ]])

Multiple y-series with shared x_data and x_edges:

>>> y_data_2d = [[0, 1, 1, 0], [0, 2, 2, 0]]
>>> linear_average(x_data=x_data, y_data=y_data_2d, x_edges=x_edges)
array([[0.66666667, 0.66666667],
       [1.33333333, 1.33333333]])
gwtransport.utils.time_bin_overlap(*, tedges, bin_tedges)[source]#

Calculate the fraction of each time bin that overlaps with each time range.

This function computes an array where element (i, j) represents the fraction of time bin j that overlaps with time range i. The computation uses vectorized operations to avoid loops.

Parameters:
  • tedges (ArrayLike) – 1D array of time bin edges in ascending order. For n bins, there should be n+1 edges.

  • bin_tedges (list[tuple]) – List of tuples where each tuple contains (start_time, end_time) defining a time range.

Returns:

overlap_array – Array of shape (len(bin_tedges), n_bins) where n_bins is the number of time bins. Each element (i, j) represents the fraction of time bin j that overlaps with time range i. Values range from 0 (no overlap) to 1 (complete overlap).

Return type:

GenericAlias[floating]

Raises:

ValueError – If tedges is not a 1D array, has fewer than 2 elements, or if bin_tedges is empty.

Notes

  • tedges must be sorted in ascending order

  • Uses vectorized operations to handle large arrays efficiently

  • Time ranges in bin_tedges can be in any order and can overlap

Examples

>>> import numpy as np
>>> from gwtransport.utils import time_bin_overlap
>>> tedges = np.array([0, 10, 20, 30])
>>> bin_tedges = [(5, 15), (25, 35)]
>>> time_bin_overlap(
...     tedges=tedges, bin_tedges=bin_tedges
... )
array([[0.5, 0.5, 0. ],
       [0. , 0. , 0.5]])
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[GenericAlias[floating] | DatetimeIndex, GenericAlias[floating], GenericAlias[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[[GenericAlias[floating], GenericAlias[floating], GenericAlias[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:

GenericAlias[floating]

Raises:

ValueError – If optimization fails, if coefficient_matrix and rhs_vector have incompatible shapes, or if an unknown nullspace objective is specified.

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

Basic usage with default squared differences objective:

>>> import numpy as np
>>> from gwtransport.utils import solve_underdetermined_system
>>>
>>> # Create underdetermined system (2 equations, 4 unknowns)
>>> matrix = np.array([[1, 2, 1, 0], [0, 1, 2, 1]])
>>> rhs = np.array([3, 4])
>>>
>>> # Solve with squared differences regularization
>>> x = solve_underdetermined_system(coefficient_matrix=matrix, rhs_vector=rhs)
>>> print(f"Solution: {x}")
>>> print(f"Residual: {np.linalg.norm(matrix @ x - rhs):.2e}")

With summed differences objective:

>>> x_sparse = solve_underdetermined_system(
...     coefficient_matrix=matrix,
...     rhs_vector=rhs,
...     nullspace_objective="summed_differences",
... )

With custom objective function:

>>> def custom_objective(coeffs, x_ls, nullspace_basis):
...     x = x_ls + nullspace_basis @ coeffs
...     return np.sum(x**2)  # Minimize L2 norm
>>>
>>> x_custom = solve_underdetermined_system(
...     coefficient_matrix=matrix,
...     rhs_vector=rhs,
...     nullspace_objective=custom_objective,
... )

Handling NaN values:

>>> # System with missing data
>>> matrix_nan = np.array([
...     [1, 2, 1, 0],
...     [np.nan, np.nan, np.nan, np.nan],
...     [0, 1, 2, 1],
... ])
>>> rhs_nan = np.array([3, np.nan, 4])
>>>
>>> x_nan = solve_underdetermined_system(
...     coefficient_matrix=matrix_nan, rhs_vector=rhs_nan
... )
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 (GenericAlias[floating]) – Forward coefficient matrix of shape (n_cout, n_cin).

  • rhs_vector (GenericAlias[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:

GenericAlias[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, return_resolution=False)[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 (GenericAlias[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.

  • return_resolution (bool, default: False) – If True, also return the per-element fraction of the solution that comes from data (vs from the regularization target). Default is False.

Returns:

If return_resolution is False (default), returns the solution vector of length n.

If return_resolution is True, returns (x, fraction_data) where fraction_data[j] is the diagonal of the model resolution matrix R = (A^T A + λ D)^{-1} A^T A:

  • fraction_data[j] 1: element j is data-driven

  • fraction_data[j] 0: element j is target-driven

  • Non-regularized entries (NaN in x_target): fraction_data[j] = 1.0

Return type:

GenericAlias[floating] | tuple[GenericAlias[floating], GenericAlias[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, warn_rank_deficient=False)[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 (GenericAlias[floating]) – Forward coefficient matrix with shape (n_obs, n_output).

  • observed (GenericAlias[floating]) – Observed values with shape (n_obs,) (e.g., extraction concentrations).

  • n_output (int) – Length of the output vector (e.g., number of cin bins).

  • regularization_strength (float) – Tikhonov regularization parameter.

  • valid_rows (GenericAlias[bool] | None, default: None) – Which observation rows are valid, with shape (n_obs,). If None, rows with row_sum > 1e-10 are considered valid.

  • warn_rank_deficient (bool, default: False) – If True, emit a warning when the forward matrix has rank deficiency among its active columns. Default is False.

Returns:

Recovered signal with shape (n_output,). NaN for bins with no active columns.

Return type:

GenericAlias[floating]

Warns:

UserWarning – When warn_rank_deficient=True and the matrix is rank-deficient.

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-10). 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 (GenericAlias[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 (GenericAlias[int_]) – First output-column index of each row’s band, shape (n_obs,).

  • observed (GenericAlias[floating]) – Observed values of shape (n_obs,) (e.g. extraction concentrations). Must not contain NaN.

  • 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:

GenericAlias[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.