gwtransport#
gwtransport: A Python package for solving one-dimensional groundwater transport problems.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
advection#
Advective Transport Modeling Along Aquifer Pore Volumes.
Water infiltrates and is transported in parallel along multiple aquifer pore volumes to extraction. For each aquifer pore volume, transport is 1D advection with linear or non-linear sorption; there is no microdispersion or molecular diffusion, while the spread across aquifer pore volumes provides macrodispersion. Forward and backward modeling are supported. No assumption is made about whether the flow is radial or orthogonal.
Note on dispersion: The spreading from the pore volume distribution (APVD) represents
macrodispersion—aquifer-scale velocity heterogeneity that depends on both aquifer
properties and hydrological boundary conditions. To add microdispersion and molecular
diffusion separately (when APVD comes from streamline analysis), use gwtransport.diffusion.
See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for details.
Note on cross-compound calibration: When APVD is calibrated from measurements of one
compound (e.g., temperature with D_m ~ 0.1 m²/day) and used to predict another (e.g., a
solute with D_m ~ 1e-4 m²/day), the molecular diffusion contribution is baked into the
calibrated std. The cleanest fix is to calibrate with gwtransport.diffusion_fast
instead, which keeps the three contributions separate.
The aquifer pore volume distribution (APVD) enters in one of two forms. The gamma_* pair takes
the APVD in closed form as a (shifted) gamma distribution, parameterized by either (mean, std, loc)
or (alpha, beta, loc), and discretizes it internally into n_bins equal-probability-mass
streamtubes. The plain pair takes aquifer_pore_volumes: an explicit array of pore volumes [m³],
one per streamtube, treated as equally weighted at the outlet.
Available functions:
infiltration_to_extraction()- Compute the concentration (or temperature) of the extracted water from an infiltration series and an explicit array of aquifer pore volumes. Eachcout_tedgesbin gets the flow-weighted average ofcinover each streamtube’s back-projected source window, averaged over the streamtubes; units are those ofcin. Sorption is linear, through a constantretardation_factor. Thespinuppolicy (default"constant") warm-starts the record beforetedges[0];spinup=Noneinstead returns NaN for every cout bin whose streamtube source windows are not fully inside the cin range.extraction_to_infiltration()- Reverse operation: reconstruct the infiltrating concentration from measured extraction concentrations by inverting the forward weight matrix with Tikhonov regularization (regularization_strength). Returns one value pertedgesbin. NaN incoutmarks measurement gaps; those rows are excluded from the solve, and cin bins constrained only by gapped cout bins come back as NaN.gamma_infiltration_to_extraction()- Forward transport as ininfiltration_to_extraction(), with the APVD supplied as gamma parameters rather than as an explicit array of pore volumes.gamma_extraction_to_infiltration()- Deconvolution as inextraction_to_infiltration(), with the APVD supplied as gamma parameters rather than as an explicit array of pore volumes.infiltration_to_extraction_nonlinear_sorption()- Forward transport with a concentration-dependent isotherm – Freundlich, Langmuir, or a constant retardation factor – solved by front tracking along each pore volume. Returns a tuple: the flow-weighted bin-averaged extraction concentration, and one diagnostic structure per pore volume holding the waves, events, first arrival and final solver state. Cout bins whose source window leaves the flow record are returned as0.0rather than NaN, while bins with zero throughflow are NaN. Forward only: non-linear sorption forms shocks, and there is no extraction-to-infiltration counterpart.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.advection.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, retardation_factor=1.0, spinup='constant')[source]#
Compute the concentration of the extracted water by shifting cin with its residence time.
The compound is retarded in the aquifer with a retardation factor. The residence time is computed based on the flow rate of the water in the aquifer and the pore volume of the aquifer. The aquifer pore volume is approximated by a (shifted) gamma distribution parameterized by either (mean, std, loc) or (alpha, beta, loc).
This function represents infiltration to extraction modeling by flow-weighted averaging.
Provide either (mean, std) or (alpha, beta);
locis 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 thanloc.std (
float|None, default:None) – Standard deviation of the gamma distribution of the aquifer pore volume (invariant under thelocshift).loc (
float, default:0.0) – Location (minimum pore volume) of the gamma distribution. Must satisfy0 <= loc < mean. Default is0.0.alpha (
float|None, default:None) – Shape parameter of gamma distribution of the aquifer pore volume (must be > 0).beta (
float|None, default:None) – Scale parameter of gamma distribution of the aquifer pore volume (must be > 0).n_bins (
int, default:100) – Number of bins to discretize the gamma distribution. Default 100.retardation_factor (
float, default:1.0) – Retardation factor of the compound in the aquifer (default 1.0). Values > 1.0 indicate slower transport due to sorption/interaction.spinup (
str|None, default:'constant') – Forwarded toinfiltration_to_extraction(). Default"constant"warm-starts the system beforetedges[0].
- Returns:
Concentration of the compound in the extracted water, or temperature. Same units as cin.
- Return type:
See also
infiltration_to_extractionTransport with explicit pore volume distribution
gamma_extraction_to_infiltrationReverse operation (deconvolution)
gwtransport.gamma.binsCreate gamma distribution bins
gwtransport.residence_time.fullCompute residence times
gwtransport.diffusion.infiltration_to_extractionAdd 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
stdcomes 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. Whenstdcomes from streamline analysis, it represents macrodispersion only; microdispersion and molecular diffusion can be added viagwtransport.diffusion_fastorgwtransport.diffusion.For cross-compound prediction (calibrating on temperature and predicting a solute), calibrate with
gwtransport.diffusion_fastso the three contributions are tracked separately rather than lumped into a single calibratedstd. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for background.Examples
Basic usage with alpha and beta parameters:
>>> import pandas as pd >>> import numpy as np >>> from gwtransport.utils import compute_time_edges >>> from gwtransport.advection import gamma_infiltration_to_extraction >>> >>> # Create input data with aligned time edges >>> dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") >>> tedges = compute_time_edges( ... tedges=None, tstart=None, tend=dates, number_of_bins=len(dates) ... ) >>> >>> # Create output time edges (can be different alignment) >>> cout_dates = pd.date_range(start="2020-01-05", end="2020-01-15", freq="D") >>> cout_tedges = compute_time_edges( ... tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates) ... ) >>> >>> # Input concentration and flow (same length, aligned with tedges) >>> cin = pd.Series(np.ones(len(dates)), index=dates) >>> flow = pd.Series(np.ones(len(dates)) * 100, index=dates) # 100 m³/day >>> >>> # Run gamma_infiltration_to_extraction with alpha/beta parameters >>> cout = gamma_infiltration_to_extraction( ... cin=cin, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... alpha=10.0, ... beta=10.0, ... n_bins=5, ... ) >>> cout.shape (11,)
Using mean and std parameters instead:
>>> cout = gamma_infiltration_to_extraction( ... cin=cin, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... mean=100.0, ... std=20.0, ... n_bins=5, ... )
- gwtransport.advection.gamma_extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, retardation_factor=1.0, regularization_strength=1e-10, spinup='constant')[source]#
Compute the concentration of the infiltrating water from extracted water (deconvolution).
The compound is retarded in the aquifer with a retardation factor. The residence time is computed based on the flow rate of the water in the aquifer and the pore volume of the aquifer. The aquifer pore volume is approximated by a (shifted) gamma distribution parameterized by either (mean, std, loc) or (alpha, beta, loc).
This function inverts the forward flow-weighted averaging (deconvolution). It is symmetric to gamma_infiltration_to_extraction.
Provide either (mean, std) or (alpha, beta);
locis 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 thanloc.std (
float|None, default:None) – Standard deviation of the gamma distribution of the aquifer pore volume (invariant under thelocshift).loc (
float, default:0.0) – Location (minimum pore volume) of the gamma distribution. Must satisfy0 <= loc < mean. Default is0.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 λ. Seeextraction_to_infiltration()for details. Default is 1e-10.spinup (
str|None, default:'constant') – Forwarded toextraction_to_infiltration(). Default"constant"warm-starts the system beforetedges[0].
- Returns:
Concentration of the compound in the infiltrating water, or temperature. Same units as cout.
- Return type:
See also
extraction_to_infiltrationDeconvolution with explicit pore volume distribution
gamma_infiltration_to_extractionForward operation (flow-weighted averaging)
gwtransport.gamma.binsCreate gamma distribution bins
gwtransport.diffusion.extraction_to_infiltrationDeconvolution 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
stdcomes 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. Whenstdcomes from streamline analysis, it represents macrodispersion only; microdispersion and molecular diffusion can be added viagwtransport.diffusion_fastorgwtransport.diffusion.For cross-compound prediction (calibrating on temperature and predicting a solute), calibrate with
gwtransport.diffusion_fastso the three contributions are tracked separately rather than lumped into a single calibratedstd. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for background.Examples
Basic usage with alpha and beta parameters:
>>> import pandas as pd >>> import numpy as np >>> from gwtransport.utils import compute_time_edges >>> from gwtransport.advection import gamma_extraction_to_infiltration >>> >>> # Create cin/flow time edges >>> cin_dates = pd.date_range(start="2019-12-25", end="2020-01-15", freq="D") >>> tedges = compute_time_edges( ... tedges=None, tstart=None, tend=cin_dates, number_of_bins=len(cin_dates) ... ) >>> >>> # Create cout time edges >>> cout_dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") >>> cout_tedges = compute_time_edges( ... tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates) ... ) >>> >>> # Input concentration and flow >>> cout = np.ones(len(cout_dates)) >>> flow = np.ones(len(cin_dates)) * 100 # 100 m³/day >>> >>> # Run gamma_extraction_to_infiltration with alpha/beta parameters >>> cin = gamma_extraction_to_infiltration( ... cout=cout, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... alpha=10.0, ... beta=10.0, ... n_bins=5, ... ) >>> cin.shape (22,)
Using mean and std parameters instead:
>>> cin = gamma_extraction_to_infiltration( ... cout=cout, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... mean=100.0, ... std=20.0, ... n_bins=5, ... )
- gwtransport.advection.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, aquifer_pore_volumes, retardation_factor=1.0, spinup='constant')[source]#
Compute the concentration of the extracted water using flow-weighted advection.
This function implements an infiltration to extraction advection model where cin and flow values correspond to the same aligned time bins defined by tedges.
Pure advection is volume-stationary, so the weights are built on the cumulative-throughflow-volume axis rather than by inverting residence times:
Map the cin and cout time edges to cumulative throughflow volume.
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.
Compute the flow-weighted time overlap of each window with those cin bins, normalize per streamtube (each row sums to 1), and average over the streamtubes whose source window lies fully inside the cin range.
- Parameters:
cin (
ArrayLike) – Concentration values of infiltrating water or temperature [concentration units]. Length must match the number of time bins defined by tedges. The model assumes this value is constant over each interval[tedges[i], tedges[i+1]).flow (
ArrayLike) – Flow rate values in the aquifer [m³/day]. Length must match cin and the number of time bins defined by tedges. The model assumes this value is constant over each interval[tedges[i], tedges[i+1]).tedges (
DatetimeIndex) – Time edges defining bins for both cin and flow data. Has length of len(cin) + 1 and len(flow) + 1.cout_tedges (
DatetimeIndex) – Time edges for output data bins. Has length of desired output + 1. Can have different time alignment and resolution than tedges.aquifer_pore_volumes (
ArrayLike) – Array of aquifer pore volumes [m³] representing the distribution of residence times in the aquifer system.retardation_factor (
float, default:1.0) – Retardation factor of the compound in the aquifer (default 1.0). Values > 1.0 indicate slower transport due to sorption/interaction.spinup (
str|None, default:'constant') –How to treat cout bins where one or more streamtube source windows fall outside the cin time range. Default is
"constant"."constant"— warm-start: shifttedges[0]backward byretardation_factor * max(aquifer_pore_volumes) / flow[0]and treat cin and flow as constant at their first value over the extended window. The forward strict-validity logic then has no NaN cout bins from spin-up; right-edge spin-up (cout extending past the cin range) is unchanged.None— strict mass-conservation: NaN whenever any streamtube has not fully broken through into the cin range, or extraction flow during the bin is zero. Bundle row sums to 1 across cin.
- Returns:
Flow-weighted concentration in the extracted water. Same units as cin. Length equals
len(cout_tedges) - 1. NaN values mark cout bins where the chosenspinuppolicy is not satisfied: the default"constant"leaves NaN for any cout bin extending past the end of the flow record (a cout edge beyondtedges[-1], whose back-projected source window leaves the cin range) and for zero-throughflow bins;spinup=Noneadditionally NaNs left-edge spin-up bins.- Return type:
- 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_extractionTransport with gamma-distributed pore volumes
extraction_to_infiltrationReverse operation (deconvolution)
gwtransport.residence_time.fullCompute residence times from flow and pore volume
gwtransport.residence_time.freundlich_retardationCompute concentration-dependent retardation
- The Central Concept: Pore Volume Distribution
Background on aquifer heterogeneity modeling
- Core Transport Equation
Flow-weighted averaging approach
Examples
Basic usage with pandas Series:
>>> import pandas as pd >>> import numpy as np >>> from gwtransport.utils import compute_time_edges >>> from gwtransport.advection import infiltration_to_extraction >>> >>> # Create input data >>> dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") >>> tedges = compute_time_edges( ... tedges=None, tstart=None, tend=dates, number_of_bins=len(dates) ... ) >>> >>> # Create output time edges (different alignment) >>> cout_dates = pd.date_range(start="2020-01-05", end="2020-01-15", freq="D") >>> cout_tedges = compute_time_edges( ... tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates) ... ) >>> >>> # Input concentration and flow >>> cin = pd.Series(np.ones(len(dates)), index=dates) >>> flow = pd.Series(np.ones(len(dates)) * 100, index=dates) # 100 m³/day >>> >>> # Define distribution of aquifer pore volumes >>> aquifer_pore_volumes = np.array([50, 100, 200]) # m³ >>> >>> # Run infiltration_to_extraction >>> cout = infiltration_to_extraction( ... cin=cin, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... aquifer_pore_volumes=aquifer_pore_volumes, ... ) >>> cout.shape (11,)
With constant retardation factor (linear sorption):
>>> cout = infiltration_to_extraction( ... cin=cin, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... aquifer_pore_volumes=aquifer_pore_volumes, ... retardation_factor=2.0, # Compound moves twice as slowly ... )
Note: For concentration-dependent retardation (nonlinear sorption), use infiltration_to_extraction_nonlinear_sorption instead, as this function only supports constant (float) retardation factors.
- gwtransport.advection.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, aquifer_pore_volumes, retardation_factor=1.0, regularization_strength=1e-10, spinup='constant')[source]#
Compute the concentration of the infiltrating water from extracted water (deconvolution).
Inverts the forward transport model by solving the linear system
W_forward @ cin = coutwhereW_forwardis the weight matrix frominfiltration_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 suggestsregularization_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 ininfiltration_to_extraction(); default"constant"shiftstedges[0]backward byretardation_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-providedtedges(lengthlen(tedges) - 1), not the padded grid. PassingNonekeeps 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"shiftedtedges[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 thespinuppolicy.- Return type:
- Raises:
ValueError – If tedges length doesn’t match flow plus one, if cout_tedges length doesn’t match cout plus one, or if flow contains NaN.
See also
gamma_extraction_to_infiltrationDeconvolution with gamma-distributed pore volumes
infiltration_to_extractionForward operation (flow-weighted averaging)
gwtransport.residence_time.fullCompute residence times from flow and pore volume
gwtransport.utils.solve_tikhonovSolver 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
coutmark measurement gaps (e.g. sparse lab samples). Their rows are excluded from the banded Tikhonov solve, matchinggwtransport.deposition.extraction_to_deposition(); cin bins constrained only by gappedcoutbins are returned as NaN.Examples
Basic usage with pandas Series:
>>> import pandas as pd >>> import numpy as np >>> from gwtransport.utils import compute_time_edges >>> from gwtransport.advection import extraction_to_infiltration >>> >>> # Create cin/flow time edges >>> cin_dates = pd.date_range(start="2019-12-25", end="2020-01-15", freq="D") >>> tedges = compute_time_edges( ... tedges=None, tstart=None, tend=cin_dates, number_of_bins=len(cin_dates) ... ) >>> >>> # Create cout time edges >>> cout_dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") >>> cout_tedges = compute_time_edges( ... tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates) ... ) >>> >>> # Input concentration and flow >>> cout = np.ones(len(cout_dates)) >>> flow = np.ones(len(cin_dates)) * 100 # 100 m³/day >>> >>> # Define distribution of aquifer pore volumes >>> aquifer_pore_volumes = np.array([50, 100, 200]) # m³ >>> >>> # Run extraction_to_infiltration >>> cin = extraction_to_infiltration( ... cout=cout, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... aquifer_pore_volumes=aquifer_pore_volumes, ... ) >>> cin.shape (22,)
Round-trip reconstruction (symmetric with infiltration_to_extraction). The default
spinup="constant"warm-starts the left edge; the cout window must therefore stay inside the cin window with margin matching the longest residence time on the right (forward NaN at the right edge would otherwise be rejected byextraction_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_factorfor constant (linear) retardation.freundlich_k+freundlich_n+bulk_density+porosityfor Freundlich isotherm.langmuir_s_max+langmuir_k_l+bulk_density+porosityfor 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 (isotherms = k_f * C^(1/n)) asgwtransport.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:
- 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 as0.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, matchinginfiltration_to_extraction().structures (
listofdict) – 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 viatracker_state.t_at_theta(event["theta"])if needed.’theta_first_arrival’: float - Cumulative flow at first nonzero arrival [m³]
’n_events’: int - Total number of events
’n_shocks’: int - Number of shocks created
’n_rarefactions’: int - Number of rarefactions created
’n_characteristics’: int - Number of characteristics created
’theta_current’: float - Final simulation cumulative flow [m³]
’sorption’: SorptionModel - Sorption object
’tracker_state’: FrontTrackerState - Complete simulation state
’aquifer_pore_volume’: float - Pore volume for this simulation
- Raises:
RuntimeError – If the front-tracking solver leaves an unresolved wave interaction (the domain-mass field over-counts stored mass). This is an internal-consistency tripwire, not an input limitation: the solver composes every wave interaction, so a firing indicates a bug.
See also
infiltration_to_extractionConvolution-based approach for linear retardation
gamma_infiltration_to_extractionFor distributions of pore volumes
- Non-Linear Sorption: Exact Solutions
Freundlich isotherm and front-tracking theory
- 1. Advection-Dominated Transport
When diffusion/dispersion is negligible
Examples
cout, structures = infiltration_to_extraction_nonlinear_sorption( cin=cin, flow=flow, tedges=tedges, cout_tedges=cout_tedges, aquifer_pore_volumes=np.array([500.0]), freundlich_k=0.01, freundlich_n=2.0, bulk_density=1500.0, porosity=0.3, ) # Access spin-up period for first pore volume theta_first = structures[0]["theta_first_arrival"] t_first = structures[0]["tracker_state"].t_at_theta(theta_first) print(f"First arrival: θ={theta_first:.2f} m³ (t={t_first:.2f} days)") # Analyze events for first pore volume for event in structures[0]["events"]: print(f"θ={event['theta']:.2f}: {event['type']}")
deposition#
Deposition Analysis for 1D Aquifer Systems.
Areal deposition supplies mass to the groundwater, mixed instantaneously over the height of the aquifer. The aquifer has a constant thickness with a finite pore volume; water with zero concentration infiltrates at one end and is extracted at the other, whether the flow is radial or orthogonal. Transport is 1D advection with linear sorption; there is no microdispersion, molecular diffusion, or macrodispersion. Forward and backward modeling are supported.
The model is a source term (positive deposition adds mass to the water); it does NOT model removal processes such as pathogen attachment, particle filtration, or chemical precipitation, which would remove mass from the water and require the opposite sign convention.
Available functions:
deposition_to_extraction()- Forward model: from areal deposition rates [g/m²/day] and flow, compute the concentration [g/m³] of the extracted water oncout_tedges. Each output bin is the flow-weighted average over the water extracted in that bin; a parcel’s concentration gain is proportional to the residence-time contact with the areal flux, mixed overporosity * thickness. Bins whose residence time is not yet resolved (spin-up) return NaN, while bins that extract zero volume – including bins lying outside the flow record – return 0.0.extraction_to_deposition()- Inverse model (deconvolution): from the concentration of the extracted water [g/m³], estimate the mean deposition rate [g/m²/day] in eachtedgesbin. Solves the banded forward system with Tikhonov regularization toward a physically motivated target (transpose-and-normalize of the forward operator), withregularization_strengthtrading data fit against that target. Rows without a resolved residence time, zero-flow output bins and NaN entries incoutare excluded from the solve. This is the recommended inverse.extraction_to_deposition_full()- Same inversion routed through the dense nullspace solvergwtransport.utils.solve_underdetermined_system(), exposing its options: the nullspace objective ("squared_differences"for smooth solutions,"summed_differences"for piecewise-constant ones, or a callable), the scipyoptimization_method, and the least-squaresrcondcutoff. Reach for it when the choice of nullspace component matters; otherwise preferextraction_to_deposition(), which is cheaper and needs no dense operator.compute_deposition_weights()- Build the operator relating deposition rates to extracted concentrations in a compact banded layout, returning the band values, the first cin column of each row, and the row masks for valid and spin-up output bins. Rowksums toresidence_time_k / (retardation_factor * porosity * thickness)rather than to one, because a deposition rate maps to a concentration through the residence time. Exposed for custom inverse solvers; the forward and both inverse entry points build on it.spinup_duration()- Time in days, measured fromtedges[0], at which the cumulative flow first reachesretardation_factor * aquifer_pore_volume: the earliest extraction time whose water carries a complete deposition history, and hence the start of the valid analysis period. Under constant flow this isretardation_factor * aquifer_pore_volume / flow. RaisesValueErrorwhen the flow record is too short to reach that volume.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.deposition.compute_deposition_weights(*, flow, tedges, cout_tedges, aquifer_pore_volume, porosity, thickness, retardation_factor=1.0)[source]#
Build the deposition weight operator in a compact banded layout.
Row
kof the dense(n_cout, n_cin)operator isband_vals[k]placed at columns[col_start[k], col_start[k] + full_band). The operator is genuinely banded – rowkis 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 mostfull_bandslots, bounded byR * aquifer_pore_volumein volume (independent of record lengthn_cin). The window is located bynumpy.searchsorted()on the cumulative flow volumeflow_cum; the per-cell math reusesgwtransport.deposition_utils._clipped_linear_integralrestricted to the band columns, so each row sums tor_k = residence_time_k / (retardation_factor * porosity * thickness). Reconstruct the dense(n_cout, n_cin)matrix withgwtransport.advection_utils._densify_weightswhen a dense operator is required (the nullspace inverse).- Parameters:
flow (
ArrayLike) – Flow rates in aquifer [m³/day]. Length must equallen(tedges) - 1.tedges (
DatetimeIndex) – Time bin edges for flow data.cout_tedges (
DatetimeIndex) – Time bin edges for output concentration data.aquifer_pore_volume (
float) – Aquifer pore volume [m³].porosity (
float) – Aquifer porosity [dimensionless].thickness (
float) – Aquifer thickness [m].retardation_factor (
float, default:1.0) – Compound retardation factor, by default 1.0.
- Return type:
tuple[NDArray[floating],NDArray[int_],NDArray[bool],NDArray[bool]]- Returns:
band_vals (
numpy.ndarray) – Banded weights of shape(n_cout, full_band). Slotband_vals[k, b]is the weight on cin bincol_start[k] + b. Rowksums tor_k = residence_time_k / (retardation_factor * porosity * thickness); invalid rows (NaN residence time, zero-flow cout bins) are zero.col_start (
numpy.ndarrayofint) – First cin bin index of each cout row’s band, shape(n_cout,).row_valid (
numpy.ndarrayofbool) – True for cout bins whose residence-time window is fully defined and carries flow (the finite, nonzero rows), shape(n_cout,).spinup_row (
numpy.ndarrayofbool) – 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"shiftstedges[0]backward by at leastretardation_factor * aquifer_pore_volume / flow[0](rounded up to whole bins, plus one extra bin) and treatsdepandflowas constant at their first observed values over the prepended interval. When the warm-start is undefined –flow[0] <= 0, zero/NaN pore volume, a non-positive first bin width, or a pad exceeding the memory cap –"constant"silently reverts to the strict-validity behavior.Nonekeeps the existing strict-validity behavior (NaN cout rows during spin-up). A float raisesNotImplementedError– 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, so0.0is the physically correct value rather than an undefined result. NaN is reserved for spin-up bins whose residence time is not yet resolved.Cout bins lying entirely outside the flow record (before
tedges[0]or aftertedges[-1]) also return0.0: their out-of-record edges clamp to the record boundaries, so they extract zero volume and fall under the same zero-mass convention.- Return type:
- Raises:
ValueError – If tedges does not have one more element than dep or flow, if input arrays contain NaN values, if
flowcontains negative values, ifretardation_factoris less than 1.0, or if physical parameters are out of valid range (porosity not in (0, 1), non-positive thickness or aquifer pore volume).NotImplementedError – If
spinupis anything other thanNoneor"constant"(the fraction-threshold mode is not implemented for deposition).
See also
extraction_to_depositionInverse operation (deconvolution)
spinup_durationEarliest extraction time with a fully resolved deposition history
gwtransport.advection.infiltration_to_extractionFor concentration transport without deposition
- Core Transport Equation
Flow-weighted averaging approach
Notes
This is a source term – positive
depraisescout. 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 = coutwhereWis the weight matrix fromcompute_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"shiftstedges[0]backward by at leastretardation_factor * aquifer_pore_volume / flow[0](rounded up to whole bins, plus one extra bin) and treats flow as constant at its first value over the prepended interval; the recovered deposition vector is sliced back to the originaltedgeslength so the public output shape is unchanged. When the warm-start is undefined (flow[0] <= 0, zero/NaN pore volume, non-positive first bin width, or a pad exceeding the memory cap),"constant"silently reverts to strict-validity behavior.Nonekeeps strict-validity behavior. A float raisesNotImplementedError– 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:
- Raises:
ValueError – If input dimensions are incompatible, if flow contains NaN or negative values, if
retardation_factoris less than 1.0, or if physical parameters are out of valid range (porosity not in (0, 1), non-positive thickness or aquifer pore volume).NotImplementedError – If
spinupis anything other thanNoneor"constant"(the fraction-threshold mode is not implemented for deposition).
See also
deposition_to_extractionForward operation (convolution)
extraction_to_deposition_fullFull solver with nullspace options
spinup_durationEarliest extraction time with a fully resolved deposition history
gwtransport.advection.extraction_to_infiltrationFor concentration transport without deposition
gwtransport.utils.solve_inverse_transport_bandedBanded Tikhonov solver used for inversion
- Core Transport Equation
Flow-weighted averaging approach
Notes
This is a source term – positive
depraisescout. 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 matrixWencodes the physical relationship between deposition rates and concentrations.Wis genuinely banded – rowiis nonzero only on the cin bins inside its residence-time window – and is built in a compact banded layout (peak memoryO(n_cin * band)); the Tikhonov solve (gwtransport.utils.solve_inverse_transport_banded()) transiently materializes the dense matrix and its Gram product (O(n_cout * n_cin + n_cin**2)). Unlike advection (where rows sum to ~1), deposition rows sum tor_i = residence_time_i / (retardation_factor * porosity * thickness). Rows are rescaled byr_ibefore solving: whenWhas full column rank andcoutlies in its column space this preserves the exactdep(with the default squarespinup="constant"system the warm-start padding makesWstructurally rank-deficient, so even a forward-generatedcoutis recovered only up to the nullspace, regardless ofregularization_strength), while for overdetermined systems with noise it is equivalent to weighted least squares with weights1 / r_i^2(shorter residence times get more weight; under constant flow allr_iare equal and this reduces to OLS). The rescaling puts the regularization target (transpose-and-normalize ofWapplied tocout) on the same scale asdep, 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 incoutare also excluded. The banded Tikhonov solve stays well-defined viaregularization_strengtheven whenWis rank-deficient (constant flow with integerRT/dtmakes 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, preferextraction_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"shiftstedges[0]backward by at leastretardation_factor * aquifer_pore_volume / flow[0](rounded up to whole bins, plus one extra bin), silently reverting to strict-validity when the warm-start is undefined; the recovered deposition is sliced back to the originaltedgeslength.Nonekeeps strict-validity behavior. A float raisesNotImplementedError– the fraction-threshold mode is not implemented for deposition (matching the diffusion family). Seeextraction_to_deposition()for full semantics.
- Returns:
Mean deposition rates [g/m²/day] between tedges. Length equals len(tedges) - 1.
- Return type:
- Raises:
ValueError – If cout_tedges does not have one more element than cout, if tedges does not have one more element than flow, if flow contains NaN or negative values, if
retardation_factoris less than 1.0, or if physical parameters are out of valid range (porosity not in (0, 1), non-positive thickness or aquifer pore volume).NotImplementedError – If
spinupis anything other thanNoneor"constant"(the fraction-threshold mode is not implemented for deposition).
See also
extraction_to_depositionRecommended solver using Tikhonov regularization.
spinup_durationEarliest extraction time with a fully resolved deposition history.
gwtransport.utils.solve_underdetermined_systemUnderlying solver.
Notes
This is a source term – positive
depraisescout. 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 totedges[0]) at which the extracted water was infiltrated exactly attedges[0]: equivalently, the time at which the cumulative flow first reachesretardation_factor * aquifer_pore_volume. For extraction times earlier thant*the extracted concentration lacks complete deposition history. Under constant flow this equalsaquifer_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:
- Raises:
ValueError – If the cumulative flow over the entire
tedgeswindow does not reachretardation_factor * aquifer_pore_volume, indicating the flow timeseries is too short to characterise the spin-up duration.
See also
deposition_to_extractionForward solver that uses the spin-up duration to resolve NaN cout rows.
extraction_to_depositionInverse solver.
diffusion_fast#
Fast closed-form 1D advection-dispersion transport (Kreft-Zuber flux concentration).
This module shares the conceptual model of gwtransport.diffusion – advection with
microdispersion (alpha_L) and molecular diffusion (D_m) along orthogonal (Cartesian)
flow paths, one independent streamtube per aquifer pore volume, with the spread across the
pore volume distribution providing macrodispersion and linear sorption entering through the
retardation factor. It reports the same Kreft-Zuber (1978) flux concentration C_F at the
outlet of the streamtube bundle, but evaluates the bin-averaged breakthrough in closed form
instead of by Gauss-Legendre quadrature.
For each streamtube (one aquifer pore volume) the resident concentration in moving-frame
cumulative-volume (V) coordinates is the Gaussian CDF
C_R = 0.5 * erfc((L - xi) / (2 * sqrt(D_t))), with D_t = D_m * tau + alpha_L * xi
the moving-frame dispersion product. Its bin-average over a cout bin has the closed-form
antiderivative I(x) = 0.5*x + 0.5*[x*erf(x/s) + (s/sqrt(pi))*exp(-(x/s)^2)],
s = 2*sqrt(D_t). Evaluating I once per cout edge with D_t carried per edge
and differencing yields the flux concentration C_F directly – not merely C_R –
because dD_t/dx = D_m/v_s + alpha_L = D_s/v_s is exactly the Kreft-Zuber flux coefficient
at the solute-front velocity v_s = Q*L/(R*V_pore) (using d(tau)/dx = 1/v_s =
R*V_pore/(L*Q)). The dispersive boundary-flux correction therefore emerges from the
D_t variation across the bin; no explicit correction term is added.
The elapsed time tau and travel distance xi are read directly from the time and
cumulative-volume edges (tau_ij = t_cout_i - t_cin_j, xi geometric), so no per-cell
quadrature and no residence-time inversion is needed. The coefficient matrix is built only on
the breakthrough band – the cumulative-volume band where the bin-averaged C_F is
unsaturated, the only region with non-zero coefficients – so the build cost scales with the
band width (a few percent of the matrix at realistic dispersion) rather than with the full grid.
Streamtube assumption (no cross-sectional area parameter)#
Each entry in aquifer_pore_volumes is an independent 1D streamtube; molecular diffusion
enters the V-space variance through D_m * tau and microdispersion through
alpha_L * xi. streamline_length / molecular_diffusivity /
longitudinal_dispersivity may be a scalar (shared by all streamtubes) or an array with
one value per pore volume, exactly as in gwtransport.diffusion.
Choosing among the three diffusion modules#
Whenever the cout grid is at or finer than the flow grid, this module reproduces
gwtransport.diffusion to machine precision for every parameter regime – including
retardation_factor != 1 with molecular_diffusivity > 0, where the antiderivative’s slope
dD_t/dx = D_s/v_s already carries the solute-front Kreft-Zuber flux coefficient natively –
while being ~80-90x faster even before banding. So it is the right default. The only case that
favours gwtransport.diffusion is a cout grid coarser than the flow detail: this module
treats flow_out as constant within each cout bin, whereas gwtransport.diffusion
integrates the full tedges-resolution flow within each cout bin – a ~0.1%-of-peak
difference for a rapidly-varying cin over wide cout bins under variable flow.
The third rung is gwtransport.diffusion_fast_fast, which is approximate by design: it folds
molecular diffusion into an effective dispersivity per streamtube and evaluates one banded
breakthrough on the native cumulative-volume grid, so it is faster still. It agrees with this module
to ~1e-6 (smooth inputs) to ~1e-4 (sharp inputs) at constant flow and degrades in the
molecular-diffusion-dominated corner (alpha_L ~ 0) under strongly variable flow; this module is
the one to use whenever machine precision against gwtransport.diffusion is required.
Available functions:
infiltration_to_extraction()- Forward transport from an explicitaquifer_pore_volumesdistribution: returns the bin-averaged Kreft-Zuber flux concentration oncout_tedges, evaluated from the closed-form antiderivative on the breakthrough band only and averaged with equal weight over the streamtubes.streamline_length,molecular_diffusivityandlongitudinal_dispersivityare either scalars shared by all streamtubes or one value per pore volume.flow_out(extraction flow on thecout_tedgesgrid) is required whenevercout_tedgesdiffers fromtedges: it sets the cout-bin volumes and the outlet velocity. Output bins without complete breakthrough information are NaN.extraction_to_infiltration()- Reverse direction: assembles the same banded forward operator and deconvolves it with banded Tikhonov regularization (banded Cholesky on the normal equations), returning the bin-averaged infiltration concentration ontedges. NaN entries incoutmark measurement gaps and are excluded from the solve; spin-up and unconstrained cin bins come back NaN.gamma_infiltration_to_extraction()-infiltration_to_extraction()with the pore volume distribution given as a (shifted) gamma – either (mean, std) or (alpha, beta), plusloc– discretized inton_binsequal-probability streamtubes that share onestreamline_lengthand one pair of dispersion parameters.gamma_extraction_to_infiltration()-extraction_to_infiltration()with the same gamma parameterization of the pore volume distribution: reconstructscinontedgesfromcout.
References
Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its solutions for different initial and boundary conditions. Chemical Engineering Science, 33(11), 1471-1480.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.diffusion_fast.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, flow_out=None, spinup='constant')[source]#
Compute extracted concentration with advection, microdispersion, and molecular diffusion.
Fast closed-form counterpart of
gwtransport.diffusion.infiltration_to_extraction(). Reports the Kreft-Zuber (1978) flux concentrationC_Fand reproduces the slow module to machine precision when the cout grid aligns with the flow grid (supplyflow_out).- Parameters:
cin (
ArrayLike) – Concentration of the compound in the infiltrating water. Lengthlen(tedges) - 1.flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Lengthlen(tedges) - 1.tedges (
DatetimeIndex) – Time edges for cin and flow data. Lengthlen(cin) + 1.cout_tedges (
DatetimeIndex) – Time edges for output data bins. Lengthlen(output) + 1.aquifer_pore_volumes (
ArrayLike) – Aquifer pore volumes [m³] – one independent streamtube per entry.streamline_length (
NDArray[floating] |float) – Travel distance L [m]: a scalar (shared by all streamtubes) or an array with one value per aquifer pore volume. Must be positive.molecular_diffusivity (
NDArray[floating] |float) – Effective (retarded-frame) molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.longitudinal_dispersivity (
NDArray[floating] |float) – Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume. Must be non-negative.retardation_factor (
float, default:1.0) – Retardation factor (default 1.0). Values > 1.0 indicate slower transport.flow_out (
ArrayLike|None, default:None) – Extraction flow rate [m³/day] on the output grid (aligned tocout_tedges, lengthlen(cout_tedges) - 1); constant within each cout bin, likeflowis within eachtedgesbin. It defines the cout-bin volumes and the outlet velocity. Required when ``cout_tedges`` differs from ``tedges``; may be omitted only whencout_tedgesequalstedges(then it equalsflow). Default None.spinup (
str|None, default:'constant') –"constant"(default) extendstedgesby 100 years on each side so a constant warm-start fills the left-edge spin-up region;Noneleaves spin-up cout as NaN.
- Returns:
Bin-averaged Kreft-Zuber flux concentration
C_Fin the extracted water. Lengthlen(cout_tedges) - 1. NaN where no infiltration data has broken through.- Return type:
See also
gwtransport.diffusion.infiltration_to_extractionQuadrature reference; prefer for cout grids coarser than the flow detail.
extraction_to_infiltrationInverse operation.
- Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity
Macrodispersion vs microdispersion.
- gwtransport.diffusion_fast.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, flow_out=None, spinup='constant')[source]#
Reconstruct infiltration concentration from extracted water (deconvolution).
Inverts the forward model by building the same closed-form flux-concentration matrix as
infiltration_to_extraction()and solvingW @ cin = coutvia Tikhonov regularization. Fast closed-form counterpart ofgwtransport.diffusion.extraction_to_infiltration().- Parameters:
cout (
ArrayLike) – Concentration of the compound in extracted water. Lengthlen(cout_tedges) - 1.flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Lengthlen(tedges) - 1.tedges (
DatetimeIndex) – Time edges for cin (output) and flow data. Lengthlen(flow) + 1.cout_tedges (
DatetimeIndex) – Time edges for cout data bins. Lengthlen(cout) + 1.aquifer_pore_volumes (
ArrayLike) – Aquifer pore volumes [m³] – one independent streamtube per entry.streamline_length (
NDArray[floating] |float) – Travel distance L [m]: a scalar (shared by all streamtubes) or an array with one value per aquifer pore volume. Must be positive.molecular_diffusivity (
NDArray[floating] |float) – Effective (retarded-frame) molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.longitudinal_dispersivity (
NDArray[floating] |float) – Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume. Must be non-negative.retardation_factor (
float, default:1.0) – Retardation factor (default 1.0).regularization_strength (
float, default:1e-10) – Tikhonov regularization parameter (default 1e-10).flow_out (
ArrayLike|None, default:None) – Extraction flow rate [m³/day] on the output grid (aligned tocout_tedges). Seeinfiltration_to_extraction(). Default None.spinup (
str|None, default:'constant') – Seeinfiltration_to_extraction(). Default"constant".
- Returns:
Bin-averaged concentration in the infiltrating water. Length
len(tedges) - 1. NaN where no extraction data constrains the bin.- Return type:
See also
infiltration_to_extractionForward operation.
gwtransport.diffusion.extraction_to_infiltrationQuadrature reference.
- Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity
Macrodispersion vs microdispersion.
- gwtransport.diffusion_fast.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, flow_out=None, spinup='constant')[source]#
Compute extracted concentration for a gamma-distributed pore volume distribution.
Convenience wrapper around
infiltration_to_extraction()that discretizes a (shifted) gamma aquifer pore-volume distribution inton_binsequal-probability streamtubes. Provide either (mean, std) or (alpha, beta);locdefaults 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. Lengthlen(cin) + 1.cout_tedges (
DatetimeIndex) – Time edges for output data bins.mean (
float|None, default:None) – Mean and standard deviation of the gamma pore-volume distribution [m³].std (
float|None, default:None) – Mean and standard deviation of the gamma pore-volume distribution [m³].loc (
float, default:0.0) – Location (minimum pore volume) [m³],0 <= loc < mean. Default 0.0.alpha (
float|None, default:None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).beta (
float|None, default:None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).n_bins (
int, default:100) – Number of equal-probability streamtubes. Default 100.streamline_length (
float) – Travel distance L [m], applied to all gamma streamtubes. Must be positive.molecular_diffusivity (
float) – Effective (retarded-frame) molecular diffusivity D_m [m²/day], applied to all streamtubes. Must be non-negative.longitudinal_dispersivity (
float) – Longitudinal dispersivity alpha_L [m] (microdispersion), applied to all streamtubes. Must be non-negative.retardation_factor (
float, default:1.0) – Retardation factor (default 1.0).flow_out (
ArrayLike|None, default:None) – Extraction flow rate [m³/day] on the output grid. Seeinfiltration_to_extraction(). Default None.spinup (
str|None, default:'constant') – Seeinfiltration_to_extraction(). Default"constant".
- Returns:
Bin-averaged Kreft-Zuber flux concentration
C_Fin the extracted water. Lengthlen(cout_tedges) - 1.- Return type:
See also
infiltration_to_extractionTransport with an explicit pore volume distribution.
gamma_extraction_to_infiltrationReverse operation.
gwtransport.gamma.binsCreate gamma distribution bins.
- Gamma Distribution Model
Two-parameter pore volume model.
- gwtransport.diffusion_fast.gamma_extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, flow_out=None, spinup='constant')[source]#
Reconstruct infiltration concentration for a gamma-distributed pore volume distribution.
Convenience wrapper around
extraction_to_infiltration()that discretizes a (shifted) gamma aquifer pore-volume distribution inton_binsequal-probability streamtubes. Provide either (mean, std) or (alpha, beta);locdefaults 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. Lengthlen(flow) + 1.cout_tedges (
DatetimeIndex) – Time edges for cout data bins. Lengthlen(cout) + 1.mean (
float|None, default:None) – Mean and standard deviation of the gamma pore-volume distribution [m³].std (
float|None, default:None) – Mean and standard deviation of the gamma pore-volume distribution [m³].loc (
float, default:0.0) – Location (minimum pore volume) [m³],0 <= loc < mean. Default 0.0.alpha (
float|None, default:None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).beta (
float|None, default:None) – Shape and scale parameters of the gamma distribution (alternative to mean/std).n_bins (
int, default:100) – Number of equal-probability streamtubes. Default 100.streamline_length (
float) – Travel distance L [m], applied to all gamma streamtubes. Must be positive.molecular_diffusivity (
float) – Effective (retarded-frame) molecular diffusivity D_m [m²/day], applied to all streamtubes. Must be non-negative.longitudinal_dispersivity (
float) – Longitudinal dispersivity alpha_L [m] (microdispersion), applied to all streamtubes. Must be non-negative.retardation_factor (
float, default:1.0) – Retardation factor (default 1.0).regularization_strength (
float, default:1e-10) – Tikhonov regularization parameter (default 1e-10).flow_out (
ArrayLike|None, default:None) – Extraction flow rate [m³/day] on the output grid. Seeinfiltration_to_extraction(). Default None.spinup (
str|None, default:'constant') – Seeinfiltration_to_extraction(). Default"constant".
- Returns:
Bin-averaged concentration in the infiltrating water. Length
len(tedges) - 1.- Return type:
See also
extraction_to_infiltrationDeconvolution with an explicit pore volume distribution.
gamma_infiltration_to_extractionForward operation.
gwtransport.gamma.binsCreate gamma distribution bins.
- Gamma Distribution Model
Two-parameter pore volume model.
diffusion_fast_fast#
Fast approximate 1D advection-dispersion transport (Kreft-Zuber flux concentration).
This module shares the conceptual model of gwtransport.diffusion and
gwtransport.diffusion_fast – advection with microdispersion (alpha_L) and molecular
diffusion (D_m) along orthogonal (Cartesian) flow paths, one independent streamtube per aquifer
pore volume, the spread across the pore volume distribution providing macrodispersion, and linear
sorption via the retardation factor. It targets the bin-averaged Kreft-Zuber (1978) flux
concentration C_F on the streamtube bundle, but trades exactness for a single fast (~1.5 ms)
native-grid evaluation that does not depend on the flow being constant. It is approximate –
see Accuracy below for the error budget and when to reach for gwtransport.diffusion_fast
instead.
The three modules form an accuracy/speed ladder: gwtransport.diffusion is the dense reference
implementation and the ground-truth oracle (composite Gauss-Legendre quadrature, slowest),
gwtransport.diffusion_fast is its banded closed-form equivalent (agrees to machine precision,
~80-90x faster), and this module is the approximate rung – fastest, and exact only up to the error
budget below.
How it works – one skewed breakthrough on the native volume grid#
The moving-frame dispersion product D_t = D_m*tau + alpha_L*xi mixes a time term (molecular
diffusion D_m*tau) and a volume term (microdispersion alpha_L*xi). Under constant flow the
two coincide: elapsed time and breakthrough coordinate are locked, tau = r_vpv*xi/(L*Q)
(r_vpv = R*V_pore), so D_m*tau = (D_m*r_vpv/(L*Q))*xi – the same xi-proportional form as
alpha_L*xi. Molecular diffusion is therefore an effective dispersivity
alpha_eff = alpha_L + D_m*r_vpv/(L*q_mean) per streamtube (q_mean the record-mean flow), and
the whole method is a single skewed D_t = alpha_eff*xi Kreft-Zuber breakthrough applied banded on
the native cumulative-volume grid: the whole aquifer pore volume distribution (APVD) is pre-summed
into one 1D antiderivative Ibar(dV) – exact for any APVD shape – finely sampled once and read
back by interpolation.
tedges need not be regularly spaced and cout_tedges need not equal tedges (supply
flow_out when they differ): the build runs on the native cumulative-volume grid for any spacing.
The only approximations are the Ibar interpolation (~1e-4, below) and – under variable flow
– freezing q_mean at the record mean: the tau = r_vpv*xi/(L*Q) map holds exactly only at
constant flow, so the microdispersion part stays exact but the molecular part picks up a commutator
residual.
Accuracy (vs gwtransport.diffusion_fast)#
Constant flow: exact to the
Ibarinterpolation floor – ~1e-6 for smooth inputs, degrading to ~1e-4 for sharp inputs atalpha_L = 0(the kink limit; ~1e-3 for a single large pore volume). This holds acrossD_mandRat realistic Peclet numbersPe = L/alpha_eff >> 1, including heat transport (R > 1, largeD_m): the effective-dispersivity fold reproduces the skewed molecular breakthrough exactly, not merely its second moment. Only in the extreme low-Peclet corner (alpha_effapproachingL– a short streamline with very strong molecular diffusion,Pe <~ 2) does the fold’s wide dispersive band exceed the fine-grid sample cap (_MAX_KERNEL_SAMPLES), coarsening the breakthrough shape; usegwtransport.diffusion_fastthere.Variable flow: the microdispersion part stays exact; the molecular part carries the frozen-
q_meancommutator residual. It is small when molecular diffusion is sub-dominant – the typicalalpha_L > 0regime, <~1e-3 for realistic soluteD_meven through strong flow – and grows in the molecular-diffusion-dominated corner (alpha_L~ 0) with sharp inputs under strongly variable or seasonal flow, reaching the multi-percent range where no fast approximation is reliable. Usegwtransport.diffusion_fastthere (in particular for heat transport under strongly variable flow).
The inverse (extraction_to_infiltration()) deconvolves the same approximate operator W the
forward applies. It assembles W directly in banded form (one Ibar gather – no per-pore-volume
closed-form loop, no dense (n_cout, n_cin) matrix) and solves it with banded Tikhonov
regularisation (banded Cholesky, O(n * band**2)), so it is much faster than
gwtransport.diffusion_fast’s reverse, especially for many streamtubes. Inverting exactly the
forward operator makes a round trip self-consistent (recovering the input up to the deconvolution
conditioning); on real extraction data the reverse carries the forward’s error budget above,
amplified by the deconvolution conditioning.
Available functions:
infiltration_to_extraction()- Forward transport from an explicitaquifer_pore_volumesdistribution: returns the approximate bin-averaged Kreft-Zuber flux concentration oncout_tedges, from one banded breakthrough on the native cumulative-volume grid with molecular diffusion folded into the effective dispersivityalpha_eff = alpha_L + D_m * R * V_pore / (L * q_mean)per streamtube.streamline_length,molecular_diffusivityandlongitudinal_dispersivityare either scalars shared by all streamtubes or one value per pore volume.flow_out(extraction flow on thecout_tedgesgrid) is required whenevercout_tedgesdiffers fromtedges. Output bins without complete breakthrough information are NaN.extraction_to_infiltration()- Reverse direction: assembles the same approximate banded operator and deconvolves it with banded Tikhonov regularization (banded Cholesky on the normal equations,O(n * band**2)), returning the bin-averaged infiltration concentration ontedges. NaN entries incoutmark measurement gaps and are excluded from the solve; spin-up and unconstrained cin bins come back NaN.gamma_infiltration_to_extraction()-infiltration_to_extraction()with the pore volume distribution given as a (shifted) gamma – either (mean, std) or (alpha, beta), plusloc– discretized inton_binsequal-probability streamtubes that share onestreamline_lengthand one pair of dispersion parameters.gamma_extraction_to_infiltration()-extraction_to_infiltration()with the same gamma parameterization of the pore volume distribution: reconstructscinontedgesfromcout.
References
Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its solutions for different initial and boundary conditions. Chemical Engineering Science, 33(11), 1471-1480.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.diffusion_fast_fast.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, flow_out=None, spinup='constant')[source]#
Compute extracted concentration with advection, microdispersion, and molecular diffusion (approximate).
Fast approximate counterpart of
gwtransport.diffusion_fast.infiltration_to_extraction(). Advection + macrodispersion + microdispersion (alpha_L) and molecular diffusion (D_m, folded in as an effective dispersivityalpha_L + D_m*r_vpv/(L*q_mean)per streamtube) form a single exact skewed Kreft-Zuber breakthrough on the native cumulative-volume grid. At constant flow the result reproducesgwtransport.diffusion_fast.infiltration_to_extraction()to theIbarinterpolation floor (~1e-6 smooth, ~1e-4 sharp) at realistic Peclet numbers (Pe = L/alpha_eff >> 1), including heat (R > 1, largeD_m); only the extreme low-Peclet corner (Pe <~ 2) loses breakthrough-shape accuracy to the fine-grid sample cap. Under variable flow the molecular part carries a commutator residual from the frozenq_mean: small when molecular diffusion is sub-dominant (alpha_L > 0, <~1e-3 for realistic soluteD_m), growing to the multi-percent range for sharp inputs in the molecular-diffusion-dominated corner (alpha_L~ 0) under strongly variable flow. For machine precision – or that corner – usegwtransport.diffusion_fast.- Parameters:
cin (
ArrayLike) – Concentration of the compound in the infiltrating water. Lengthlen(tedges) - 1.flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Lengthlen(tedges) - 1.tedges (
DatetimeIndex) – Time edges for cin and flow data. Lengthlen(cin) + 1.cout_tedges (
DatetimeIndex) – Time edges for output data bins. Lengthlen(output) + 1.aquifer_pore_volumes (
ArrayLike) – Aquifer pore volumes [m³] – one independent streamtube per entry. Any distribution shape (the APVD is pre-summed exactly).streamline_length (
NDArray[floating] |float) – Travel distance L [m]: a scalar (shared by all streamtubes) or an array with one value per aquifer pore volume. Must be positive.molecular_diffusivity (
NDArray[floating] |float) – Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.longitudinal_dispersivity (
NDArray[floating] |float) – Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume. Must be non-negative.retardation_factor (
float, default:1.0) – Retardation factor (default 1.0). Values > 1.0 indicate slower transport.flow_out (
ArrayLike|None, default:None) – Extraction flow rate [m³/day] on the output grid (aligned tocout_tedges, lengthlen(cout_tedges) - 1). Required whencout_tedgesdiffers fromtedges; may be omitted only whencout_tedgesequalstedges. Default None.spinup (
str|None, default:'constant') –"constant"(default) extendstedgesby 100 years on each side so a constant warm-start fills the left-edge spin-up region;Noneleaves spin-up cout as NaN.
- Returns:
Bin-averaged Kreft-Zuber flux concentration
C_Fin the extracted water. Lengthlen(cout_tedges) - 1. NaN where no infiltration data has broken through.- Return type:
See also
gwtransport.diffusion_fast.infiltration_to_extractionExact (machine-precision) counterpart; use it when approximation is unacceptable, especially in the molecular-dominant regime.
gwtransport.diffusion.infiltration_to_extractionQuadrature reference.
extraction_to_infiltrationInverse operation (deconvolves this same operator).
- Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity
Macrodispersion vs microdispersion.
- gwtransport.diffusion_fast_fast.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, flow_out=None, spinup='constant')[source]#
Reconstruct infiltration concentration from extracted water (fast approximate deconvolution).
Inverts the same approximate operator
Wthe forward applies: it assembles the banded breakthrough operatorW(advection + macro + micro + molecular, molecular folded in as an effective dispersivity) directly in banded form and deconvolves it with banded Tikhonov regularization (_solve_reverse_banded– banded Cholesky on the normal equations,O(n * band**2)). It buildsWfrom oneIbargather – no per-pore-volume closed-form loop and no dense(n_cout, n_cin)matrix – so it is much faster thangwtransport.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 recoverscinup to the deconvolution conditioning and regularization. On real extraction data the reverse is as accurate as the forward: at constant flow it matchesgwtransport.diffusion_fast.extraction_to_infiltration()to ~1e-6, while the molecular-diffusion-dominated corner under strongly variable flow amplifies the forward’s commutator residual (usegwtransport.diffusion_fastthere).- Parameters:
cout (
ArrayLike) – Concentration of the compound in extracted water. Lengthlen(cout_tedges) - 1.flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Lengthlen(tedges) - 1.tedges (
DatetimeIndex) – Time edges for cin (output) and flow data. Lengthlen(flow) + 1.cout_tedges (
DatetimeIndex) – Time edges for cout data bins. Lengthlen(cout) + 1.aquifer_pore_volumes (
ArrayLike) – Aquifer pore volumes [m³] – one independent streamtube per entry.streamline_length (
NDArray[floating] |float) – Travel distance L [m]: scalar or one value per pore volume. Must be positive.molecular_diffusivity (
NDArray[floating] |float) – Effective molecular diffusivity D_m [m²/day]: scalar or one value per pore volume. Must be non-negative.longitudinal_dispersivity (
NDArray[floating] |float) – Longitudinal dispersivity alpha_L [m] (microdispersion): scalar or one value per pore volume. Must be non-negative.retardation_factor (
float, default:1.0) – Retardation factor (default 1.0).regularization_strength (
float, default:1e-10) – Tikhonov regularization parameter (default 1e-10). Must be strictly positive: the banded solver relies on it to make the normal equations positive-definite (it cannot return the denselambda = 0minimum-norm solution).flow_out (
ArrayLike|None, default:None) – Extraction flow rate [m³/day] on the output grid (aligned tocout_tedges). Seeinfiltration_to_extraction(). Default None.spinup (
str|None, default:'constant') – Seeinfiltration_to_extraction(). Default"constant".
- Returns:
Bin-averaged concentration in the infiltrating water. Length
len(tedges) - 1. NaN where no extraction data constrains the bin.- Return type:
See also
infiltration_to_extractionForward operation (the operator inverted here).
gwtransport.diffusion_fast.extraction_to_infiltrationExact (machine-precision) counterpart; use it when the approximation is unacceptable.
- Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity
Macrodispersion vs microdispersion.
- gwtransport.diffusion_fast_fast.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, flow_out=None, spinup='constant')[source]#
Compute extracted concentration for a gamma-distributed pore volume distribution (approximate).
Convenience wrapper around
infiltration_to_extraction()that discretizes a (shifted) gamma aquifer pore-volume distribution inton_binsequal-probability streamtubes. Provide either (mean, std) or (alpha, beta);locdefaults to 0. Approximate – seeinfiltration_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. Lengthlen(cin) + 1.cout_tedges (
DatetimeIndex) – Time edges for output data bins. Lengthlen(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. Seeinfiltration_to_extraction(). Default None.spinup (
str|None, default:'constant') – Seeinfiltration_to_extraction(). Default"constant".
- Returns:
Bin-averaged Kreft-Zuber flux concentration
C_Fin the extracted water. Lengthlen(cout_tedges) - 1. NaN where no infiltration data has broken through.- Return type:
See also
infiltration_to_extractionTransport with an explicit pore volume distribution.
gamma_extraction_to_infiltrationReverse operation.
gwtransport.gamma.binsCreate gamma distribution bins.
- Gamma Distribution Model
Two-parameter pore volume model.
- gwtransport.diffusion_fast_fast.gamma_extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, flow_out=None, spinup='constant')[source]#
Reconstruct infiltration concentration for a gamma-distributed pore volume distribution.
Convenience wrapper around
extraction_to_infiltration()that discretizes a (shifted) gamma aquifer pore-volume distribution inton_binsequal-probability streamtubes. Provide either (mean, std) or (alpha, beta);locdefaults to 0. Fast approximate banded deconvolution (seeextraction_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. Lengthlen(flow) + 1.cout_tedges (
DatetimeIndex) – Time edges for cout data bins. Lengthlen(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. Seeinfiltration_to_extraction(). Default None.spinup (
str|None, default:'constant') – Seeinfiltration_to_extraction(). Default"constant".
- Returns:
Bin-averaged concentration in the infiltrating water. Length
len(tedges) - 1.- Return type:
See also
extraction_to_infiltrationDeconvolution with an explicit pore volume distribution.
gamma_infiltration_to_extractionForward operation.
gwtransport.gamma.binsCreate gamma distribution bins.
- Gamma Distribution Model
Two-parameter pore volume model.
diffusion#
Analytical solutions for 1D advection-dispersion transport.
Water infiltrates and is transported in parallel along multiple aquifer pore volumes to extraction. For each aquifer pore volume, transport is 1D advection with microdispersion, molecular diffusion, and linear sorption; the spread across aquifer pore volumes provides macrodispersion. Forward and backward modeling are supported. The flow is assumed orthogonal.
The orthogonal-flow (Cartesian) geometry is what makes the Kreft-Zuber breakthrough the exact 1D solution used below.
Choosing among the three diffusion modules#
This is the reference implementation: it evaluates the bin-averaged Kreft-Zuber flux
concentration by resolution-aware composite Gauss-Legendre quadrature (splitting at
flow-bin boundaries, with extra front-centred panels wherever a sharp breakthrough front
is otherwise under-resolved).
Prefer it only when the output grid is coarser than the flow detail – it integrates the
full within-bin flow, which the closed-form gwtransport.diffusion_fast approximates as
constant per output bin. Otherwise that module computes the same physics to machine
precision for every parameter regime (including retardation_factor != 1 with non-zero
molecular diffusivity, whose flux correction it also evaluates in closed form) and is
~80-90x faster (no quadrature, no residence-time inversion). Both modules accept
per-streamtube streamline_length / molecular_diffusivity /
longitudinal_dispersivity arrays (heterogeneous flow paths – partially-penetrating
wells, wedge-shaped capture zones).
gwtransport.diffusion_fast_fast is the third rung and is approximate by design: it folds
molecular diffusion into an effective dispersivity alpha_eff = alpha_L + D_m * R * V_pore /
(L * q_mean) per streamtube, so the whole streamtube bundle collapses to one banded breakthrough
on the native cumulative-volume grid – the fastest of the three. At constant flow it reproduces
gwtransport.diffusion_fast to ~1e-6 for smooth inputs and ~1e-4 for sharp ones, and it loses
accuracy in the molecular-diffusion-dominated corner (alpha_L ~ 0) under strongly variable flow,
where the frozen record-mean flow leaves a commutator residual. This module is the ground truth both
fast modules are validated against.
Reported outlet concentration: Kreft-Zuber (1978) flux concentration#
The outlet concentration reported by this module is the flux concentration
C_F(L, t) = C_R(L, t) - (D_s / v_s) * dC_R/dx |_{x=L}
with the solute-front (retarded-frame) velocity v_s = Q L / (R V_pore) and the dispersion D_s = D_m + alpha_L * v_s, so the flux coefficient is D_s / v_s = D_m / v_s + alpha_L = R D_m / v_fluid + alpha_L (with the fluid velocity v_fluid = Q L / V_pore). The resident profile C_R solves the retarded ADE with advection v_s and dispersion D_s, so its flux-vs-resident correction must use v_s — not v_fluid; pairing v_s with the moving-frame variance below is what conserves mass for R > 1 with D_m > 0.
— the solute mass flux at the outlet divided by the volumetric fluid flux. This
is what is measured when sampling the extracted fluid. The resident
concentration C_R is Bear (1972) eq. 10.6.4, the variable-flow moving-frame
Ogata-Banks solution
C_R(L, V; t_j) = 0.5 * erfc((L - xi_j(V)) / (2 * sqrt(D_t(V))))
with the dispersion variance accumulated in the moving (Lagrangian) frame:
D_t(V) = sigma^2(V) / 2 = D_m * tau(V) + alpha_L * xi(V)
where:
D_m is the effective molecular (or thermal) diffusivity [m²/day]
alpha_L is the longitudinal dispersivity [m]
tau(V) is the elapsed time since infiltration [day], with V the cumulative extracted volume
xi(V) = L (V - V_j) / (R V_pore) is the distance the parcel has actually travelled [m]
The K-Z flux-correction term is what makes the column-sum invariant
integral Q c_out dt = integral Q c_in dt hold under arbitrary variable Q.
Without it, the leading-order C_R loses O(1/Pe) per column under variable Q +
pure D_m.
Implementation: the bin-averaged C_F is computed by resolution-aware composite
Gauss-Legendre quadrature in volume space, split at flow-bin boundaries so each
sub-interval sees a linear t(V). Within a sub-interval the erf-like front has
width sqrt(4*D_t) (in volume units); for near-zero dispersivity this can be
orders of magnitude below the flow-bin width, so a single fixed-order rule
cannot resolve it. Sub-intervals whose front is under-resolved are therefore
tiled with front-centred panels (fine near the front, flat tails outside),
which restores the column-mass invariant to ~1e-11 for every dispersion regime;
smooth/already-resolved sub-intervals keep the plain single 16-point rule. The
variance is evaluated at each quadrature node from the parcel’s own tau and xi
histories — never capped at the residence time. The K-Z identity requires
Bear’s formula to satisfy the variable-coefficient ADE exactly, which holds only
when D_t is allowed to keep growing past breakthrough.
Macrodispersion vs microdispersion#
This module adds microdispersion (alpha_L) and molecular diffusion (D_m) on top of macrodispersion captured by the pore volume distribution (APVD). Both represent velocity heterogeneity at different scales. Microdispersion is an aquifer property; macrodispersion depends additionally on hydrological boundary conditions. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for guidance on when to use each approach and how to avoid double-counting spreading effects.
Streamtube assumption (no cross-sectional area parameter)#
Each entry in aquifer_pore_volumes is treated as an independent 1D streamtube. There is
no cross-sectional area parameter: the variance budget uses 2 D_m tau (molecular
diffusion in time) and 2 alpha_L xi (microdispersion in travelled distance), with
the streamline length L and the pore volume V_pore together fixing the implicit
streamtube cross-section A = V_pore / L. Callers who need distributed-area effects must
provide multiple streamtubes (via aquifer_pore_volumes or the gamma-parameterised
wrappers).
Available functions:
infiltration_to_extraction()- Forward transport from an explicitaquifer_pore_volumesdistribution: returns the bin-averaged Kreft-Zuber flux concentration oncout_tedges, integrated over the fulltedges-resolution flow within each output bin and averaged with equal weight over the streamtubes.streamline_length,molecular_diffusivityandlongitudinal_dispersivityare either scalars shared by all streamtubes or one value per pore volume. Output bins that no infiltration has reached, or whose look-back leaves the record, are NaN.extraction_to_infiltration()- Reverse direction: builds the same forward coefficient matrix and solvesW @ cin = coutby Tikhonov regularization, returning the bin-averaged infiltration concentration ontedges. NaN entries incoutmark measurement gaps; their rows are dropped from the solve and cin bins that nothing else constrains are returned as NaN.gamma_infiltration_to_extraction()-infiltration_to_extraction()with the pore volume distribution given as a (shifted) gamma – either (mean, std) or (alpha, beta), plusloc– discretized inton_binsequal-probability streamtubes that share onestreamline_lengthand one pair of dispersion parameters.gamma_extraction_to_infiltration()-extraction_to_infiltration()with the same gamma parameterization of the pore volume distribution: reconstructscinontedgesfromcout.
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:
Water infiltrates with concentration cin at time t_in
Water travels distance L through aquifer with residence time tau = V_pore / Q
During transport, microdispersion and molecular diffusion spread each streamline, while the spread across pore volumes provides macrodispersion
At extraction, the concentration is a dispersed breakthrough curve
The reported extracted concentration is the Kreft-Zuber (1978) flux concentration at the outlet – the solute mass flux divided by the volumetric fluid flux, which is what is measured when sampling the outflowing fluid. Microdispersion and molecular diffusion enter as the moving-frame variance
sigma^2(V) = 2*D_m*tau(V) + 2*alpha_L*xi(V), withtau(V)the elapsed time since infiltration andxi(V)the distance the parcel has actually travelled. See the module docstring for the derivation and for why the flux form is what makes the column-sum invariantintegral Q c_out dt = integral Q c_in dthold exactly under variable flow.- Parameters:
cin (
ArrayLike) – Concentration of the compound in infiltrating water [concentration units]. Length must match the number of time bins defined by tedges. The model assumes this value is constant over each interval[tedges[i], tedges[i+1]).flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length must match cin and the number of time bins defined by tedges. The model assumes this value is constant over each interval[tedges[i], tedges[i+1]).tedges (
DatetimeIndex) – Time edges defining bins for both cin and flow data. Has length of len(cin) + 1.cout_tedges (
DatetimeIndex) – Time edges for output data bins. Has length of desired output + 1. The output concentration is averaged over each bin.aquifer_pore_volumes (
ArrayLike) – Array of aquifer pore volumes [m³] representing the distribution of flow paths. Each pore volume determines the residence time for that flow path: tau = V_pore / Q.streamline_length (
ArrayLike) – Array of travel distances [m] corresponding to each pore volume. Must have the same length as aquifer_pore_volumes.molecular_diffusivity (
ArrayLike) –Effective (retarded-frame) molecular diffusivity [m²/day]. Can be a scalar (same for all pore volumes) or an array with the same length as aquifer_pore_volumes. Must be non-negative. For solute transport, this is the molecular diffusion coefficient D_m [m²/day] — typically ~1e-5 m²/day, negligible compared to microdispersion. For heat transport, pass the thermal diffusivity D_th = lambda / (rho*c)_eff [m²/day], typically 0.01-0.1 m²/day.
Internally, this contributes
2 * molecular_diffusivity * tauto the variance, wheretauis the elapsed time in days (no extra factor of R). The retardation factor instead enters the flux coefficientD_s/v_s = R D_m / v_fluid + alpha_Lthrough the solute-front velocityv_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.Nonedisables the extension; output bins without sufficient upstream data become NaN. Float fraction-threshold mode is not implemented and raisesNotImplementedError.
- 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:
- 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_infiltrationInverse operation (deconvolution)
gwtransport.advection.infiltration_to_extractionPure advection (no dispersion)
gwtransport.diffusion_fast.infiltration_to_extractionFast 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:
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)
For each cell, compute the bin-averaged Kreft-Zuber flux concentration
frac[i, j] = (1/dV_i) * integral C_F(L, V; t_j) dVby resolution-aware composite Gauss-Legendre quadrature in volume space, split at flow-bin boundaries so thatt(V)is linear within each sub-interval. Where the erf-like front (widthsqrt(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 varianceD_t = D_m*tau + alpha_L*xiis evaluated at each quadrature node (never capped at the residence time).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.Average coefficients across all pore volumes.
Examples
Basic usage with constant flow:
>>> import pandas as pd >>> import numpy as np >>> from gwtransport.diffusion import infiltration_to_extraction >>> >>> # Create time edges >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D") >>> >>> # Input concentration (step function) and constant flow >>> cin = np.zeros(len(tedges) - 1) >>> cin[5:10] = 1.0 # Pulse of concentration >>> flow = np.ones(len(tedges) - 1) * 100.0 # 100 m³/day >>> >>> # Single pore volume of 500 m³, travel distance 100 m >>> aquifer_pore_volumes = np.array([500.0]) >>> streamline_length = np.array([100.0]) >>> >>> # Compute with dispersion (molecular diffusion + dispersivity) >>> # Scalar values broadcast to all pore volumes >>> cout = infiltration_to_extraction( ... cin=cin, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... aquifer_pore_volumes=aquifer_pore_volumes, ... streamline_length=streamline_length, ... molecular_diffusivity=1e-4, # m²/day, same for all pore volumes ... longitudinal_dispersivity=1.0, # m, same for all pore volumes ... )
With multiple pore volumes (heterogeneous aquifer):
>>> # Distribution of pore volumes and corresponding travel distances >>> aquifer_pore_volumes = np.array([400.0, 500.0, 600.0]) >>> streamline_length = np.array([80.0, 100.0, 120.0]) >>> >>> # Scalar diffusion parameters broadcast to all pore volumes >>> cout = infiltration_to_extraction( ... cin=cin, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... aquifer_pore_volumes=aquifer_pore_volumes, ... streamline_length=streamline_length, ... molecular_diffusivity=1e-4, # m²/day ... longitudinal_dispersivity=1.0, # m ... )
- gwtransport.diffusion.extraction_to_infiltration(*, cout, flow, tedges, cout_tedges, aquifer_pore_volumes, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, regularization_strength=1e-10, spinup='constant')[source]#
Compute infiltration concentration from extracted water (deconvolution with dispersion).
Inverts the forward transport model by building the forward coefficient matrix
W_forwardfrominfiltration_to_extraction()and solvingW_forward @ cin = coutvia 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. Seeinfiltration_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 λ. Seegwtransport.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.Nonedisables the extension; output bins without sufficient upstream data become NaN. Float fraction-threshold mode is not implemented and raisesNotImplementedError.
- 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:
- 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_extractionForward operation (convolution)
gwtransport.advection.extraction_to_infiltrationPure 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 byinfiltration_to_extraction()) and solvesW_forward @ cin = coutusinggwtransport.utils.solve_inverse_transport()(which builds the reverse regularization target and then applies Tikhonov regularization). This ensures mathematical consistency between forward and inverse operations.NaN values in
coutmark measurement gaps (e.g. sparse lab samples). Their rows are excluded from the Tikhonov solve, matchinggwtransport.deposition.extraction_to_deposition(); cin bins constrained only by gappedcoutbins are returned as NaN.Examples
Basic usage with constant flow:
>>> import pandas as pd >>> import numpy as np >>> from gwtransport.diffusion import extraction_to_infiltration >>> >>> # Create time edges: tedges for cin/flow, cout_tedges for cout >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D") >>> >>> # Extracted concentration and constant flow >>> cout = np.zeros(len(cout_tedges) - 1) >>> cout[5:10] = 1.0 # Observed pulse at extraction >>> flow = np.ones(len(tedges) - 1) * 100.0 # 100 m³/day >>> >>> # Single pore volume of 500 m³, travel distance 100 m >>> aquifer_pore_volumes = np.array([500.0]) >>> streamline_length = np.array([100.0]) >>> >>> # Reconstruct infiltration concentration >>> cin = extraction_to_infiltration( ... cout=cout, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... aquifer_pore_volumes=aquifer_pore_volumes, ... streamline_length=streamline_length, ... molecular_diffusivity=1e-4, ... longitudinal_dispersivity=1.0, ... )
- gwtransport.diffusion.gamma_infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100, streamline_length, molecular_diffusivity, longitudinal_dispersivity, retardation_factor=1.0, spinup='constant')[source]#
Compute extracted concentration with advection and dispersion for gamma-distributed pore volumes.
Combines advection with microdispersion and molecular diffusion along each streamline (gamma-distributed pore volumes, whose spread provides macrodispersion). This is a convenience wrapper around
infiltration_to_extraction()that parameterizes the aquifer pore volume distribution as a (shifted) gamma distribution.Provide either (mean, std) or (alpha, beta);
locis 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 thanloc.std (
float|None, default:None) – Standard deviation of the gamma distribution of the aquifer pore volume (invariant under thelocshift).loc (
float, default:0.0) – Location (minimum pore volume) of the gamma distribution. Must satisfy0 <= loc < mean. Default is0.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. Seeinfiltration_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.Nonedisables the extension; output bins without sufficient upstream data become NaN. Float fraction-threshold mode is not implemented and raisesNotImplementedError.
- 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:
See also
infiltration_to_extractionTransport with explicit pore volume distribution
gamma_extraction_to_infiltrationReverse operation (deconvolution)
gwtransport.gamma.binsCreate gamma distribution bins
gwtransport.advection.gamma_infiltration_to_extractionPure 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
stdcomes from calibration on measurements, it absorbs all mixing: macrodispersion, microdispersion, and an average molecular diffusion contribution. Whenstdcomes 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);
locis 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 thanloc.std (
float|None, default:None) – Standard deviation of the gamma distribution of the aquifer pore volume (invariant under thelocshift).loc (
float, default:0.0) – Location (minimum pore volume) of the gamma distribution. Must satisfy0 <= loc < mean. Default is0.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. Seeinfiltration_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.Nonedisables the extension; output bins without sufficient upstream data become NaN. Float fraction-threshold mode is not implemented and raisesNotImplementedError.
- 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:
See also
extraction_to_infiltrationDeconvolution with explicit pore volume distribution
gamma_infiltration_to_extractionForward operation (convolution)
gwtransport.gamma.binsCreate gamma distribution bins
gwtransport.advection.gamma_extraction_to_infiltrationPure 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
stdcomes from calibration on measurements, it absorbs all mixing: macrodispersion, microdispersion, and an average molecular diffusion contribution. Whenstdcomes from streamline analysis, it represents macrodispersion only; microdispersion and molecular diffusion can be added via the dispersion parameters. See Macrodispersion and Microdispersion as Scale-Dependent Heterogeneity for guidance on when to add microdispersion.Examples
>>> import pandas as pd >>> import numpy as np >>> from gwtransport.diffusion import gamma_extraction_to_infiltration >>> >>> tedges = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") >>> cout_tedges = pd.date_range(start="2020-01-05", end="2020-01-25", freq="D") >>> cout = np.zeros(len(cout_tedges) - 1) >>> cout[5:10] = 1.0 >>> flow = np.ones(len(tedges) - 1) * 100.0 >>> >>> cin = gamma_extraction_to_infiltration( ... cout=cout, ... flow=flow, ... tedges=tedges, ... cout_tedges=cout_tedges, ... mean=500.0, ... std=100.0, ... n_bins=5, ... streamline_length=100.0, ... molecular_diffusivity=1e-4, ... longitudinal_dispersivity=1.0, ... )
examples#
Example Data Generation for Groundwater Transport Modeling.
This module provides utilities to generate synthetic datasets for demonstrating and testing groundwater transport models. It creates realistic flow patterns, concentration/temperature time series, and deposition events suitable for testing advection, diffusion, and deposition analysis functions.
Available functions:
generate_example_data()- Build a daily(DataFrame, tedges)pair with columnsflow(seasonal sinusoid plus noise and randomly placed low-flow spill periods, floored at 5 m³/day),cin(a seasonal sinusoid, a constant, or inline KNMI De Bilt soil temperature, selected withcin_method), andcout, obtained by transporting the noiselesscinthrough either a gamma-distributed or an explicitly listed set of aquifer pore volumes, usinggwtransport.advectionor, when the three diffusion parameters are given,gwtransport.diffusion_fast. Independent Gaussian measurement noise is added tocinandcout, so the pair is not exactly consistent whenmeasurement_noise > 0. The aquifer and generation settings are recorded indf.attrs. Consumed byexamples/04_Deposition_Analysis_Bank_Filtration.ipynb.generate_temperature_example_data()- Same dataset viewed as heat transport: a thin wrapper overgenerate_example_data()whose defaults are thermal (retardation factor 2.0, diffusivity 0.05 m²/day, longitudinal dispersivity 1.0 m, streamline length 100 m), so the diffusion path is taken. Consumed byexamples/01_Aquifer_Characterization_Temperature.ipynb,examples/02_Residence_Time_Analysis.ipynb,examples/03_Pathogen_Removal_Bank_Filtration.ipynb, andexamples/08_bank_filtration_timflow.ipynb.generate_example_deposition_timeseries()- Build a(Series, tedges)pair of surface deposition rates (ng/m²/day) on a UTC index: a baseline plus an annual sinusoid, Gaussian noise, and episodic events that start at the given dates and decay exponentially overevent_durationdays, optionally clipped at zero. Feedsgwtransport.deposition.deposition_to_extraction()inexamples/04_Deposition_Analysis_Bank_Filtration.ipynb.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.examples.generate_example_data(*, date_start='2020-01-01', date_end='2021-12-31', date_freq='D', flow_mean=100.0, flow_amplitude=30.0, flow_noise=10.0, cin_method='synthetic', cin_mean=12.0, cin_amplitude=8.0, measurement_noise=1.0, aquifer_pore_volumes=None, aquifer_pore_volume_gamma_mean=None, aquifer_pore_volume_gamma_std=None, aquifer_pore_volume_gamma_loc=None, aquifer_pore_volume_gamma_nbins=None, retardation_factor=1.0, molecular_diffusivity=None, longitudinal_dispersivity=None, streamline_length=None, rng=None)[source]#
Generate synthetic concentration/temperature and flow data for groundwater transport.
Creates a synthetic dataset with seasonal flow patterns, input concentration (cin), and output concentration (cout) computed via gamma-distributed pore volume transport. When
molecular_diffusivity,longitudinal_dispersivity, andstreamline_lengthare 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 bycin_meanandcin_amplitude. Measurement noise is applied."constant": Constant value equal tocin_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 tocinandcout. Because the two noise draws are independent, applying the forward operator todf['cin']does not exactly reproducedf['cout']whenmeasurement_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 theaquifer_pore_volume_gamma_*parameters may be passed. WhenNone, 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 thanaquifer_pore_volume_gamma_loc. Mutually exclusive withaquifer_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 thelocshift). Mutually exclusive withaquifer_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 satisfy0 <= loc < mean. Mutually exclusive withaquifer_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 withaquifer_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 withlongitudinal_dispersivityandstreamline_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 withmolecular_diffusivityandstreamline_length.streamline_length (
float|None, default:None) – Travel distance along the streamline [m]. Must be provided together withmolecular_diffusivityandlongitudinal_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 bynumpy.random.default_rng(). Pass an integer (ornumpy.random.Generator) for reproducible output;Nonedraws 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:
- Raises:
ValueError – If
cin_methodis not one of the supported methods, if only some of the diffusion parameters are provided, or ifaquifer_pore_volumesis passed together with anyaquifer_pore_volume_gamma_*parameter.
See also
generate_temperature_example_dataWrapper with thermal transport defaults.
- gwtransport.examples.generate_temperature_example_data(*, date_start='2020-01-01', date_end='2021-12-31', date_freq='D', flow_mean=100.0, flow_amplitude=30.0, flow_noise=10.0, cin_method='synthetic', cin_mean=12.0, cin_amplitude=8.0, measurement_noise=1.0, aquifer_pore_volumes=None, aquifer_pore_volume_gamma_mean=None, aquifer_pore_volume_gamma_std=None, aquifer_pore_volume_gamma_loc=None, aquifer_pore_volume_gamma_nbins=None, retardation_factor=2.0, molecular_diffusivity=0.05, longitudinal_dispersivity=1.0, streamline_length=100.0, rng=None)[source]#
Generate synthetic temperature and flow data for groundwater transport examples.
Convenience wrapper around
generate_example_data()with sensible defaults for temperature transport: thermal retardation factor, thermal diffusivity, longitudinal dispersivity, and streamline length.Typical parameter values for temperature transport in various sand types:
Parameter
Fine sand
Medium sand
Coarse sand/gravel
retardation_factor R
2.0–3.0
1.5–2.5
1.2–2.0
molecular_diffusivity (m²/day)
0.03–0.06
0.05–0.08
0.08–0.12
longitudinal_dispersivity (m)
0.1–1.0
0.5–5.0
1.0–10.0
streamline_length (m)
site-specific
- Parameters:
retardation_factor (
float, default:2.0) – Thermal retardation factor.molecular_diffusivity (
float, default:0.05) – Thermal diffusivity [m²/day].longitudinal_dispersivity (
float, default:1.0) – Longitudinal dispersivity [m].streamline_length (
float, default:100.0) – Travel distance along the streamline [m].
- Returns:
- Return type:
See also
generate_example_dataGeneric version with full parameter control.
Notes
All other parameters are forwarded unchanged to
generate_example_data(); see that function for their descriptions.
- gwtransport.examples.generate_example_deposition_timeseries(*, date_start='2018-01-01', date_end='2023-12-31', freq='D', base=0.8, seasonal_amplitude=0.3, noise_scale=0.1, event_dates=None, event_magnitude=3.0, event_duration=30, event_decay_scale=10.0, ensure_non_negative=True, rng=None)[source]#
Generate synthetic deposition timeseries for groundwater transport examples.
- Parameters:
date_start (
str, default:'2018-01-01') – Start and end dates for the generated time series (YYYY-MM-DD).date_end (
str, default:'2023-12-31') – Start and end dates for the generated time series (YYYY-MM-DD).freq (
str, default:'D') – Frequency string for pandas.date_range (default ‘D’).base (
float, default:0.8) – Baseline deposition rate (ng/m²/day).seasonal_amplitude (
float, default:0.3) – Amplitude of the annual seasonal sinusoidal pattern (ng/m²/day).noise_scale (
float, default:0.1) – Standard deviation of Gaussian noise added to the signal (ng/m²/day).event_dates (
ArrayLike|DatetimeIndex|None, default:None) – Dates (strings or pandas-compatible) at which to place episodic events. Time-zone-naive entries are interpreted as UTC to match the generateddatesindex. 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 overevent_durationdays at rateevent_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 bynumpy.random.default_rng(). Pass an integer (ornumpy.random.Generator) for reproducible output;Nonedraws 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:
- Raises:
ValueError – If
event_decay_scaleorevent_durationis not positive, or if anyevent_datesentry falls outside the generateddatesrange.
See also
gwtransport.deposition.deposition_to_extractionForward operator consuming this data.
gwtransport.deposition.extraction_to_depositionInverse operator.
fronttracking#
Front tracking module for exact nonlinear transport modeling.
fronttracking.events#
Event detection for front tracking in (V, θ) coordinates.
All intersections are pure line/line geometry in the (V, θ) plane because every wave speed dV/dθ is independent of flow. Functions return θ-coordinates of intersections; the solver translates to user-facing t at the API boundary.
Events include:
Characteristic-characteristic collisions
Shock-shock collisions
Shock-characteristic collisions
Rarefaction boundary interactions
Outlet crossings
All calculations return exact floating-point results with machine precision.
The theta coordinate is cumulative flow [m³]; see
gwtransport.fronttracking.waves for the (V, θ) convention.
Available functions:
EventType- Enumeration of the event kinds the solver dispatches on: characteristic-characteristic, shock-shock, shock-characteristic, rarefaction-characteristic and shock-rarefaction collisions, decaying-shock fan exhaustion, wave merges from the face calculus, and outlet crossings.Event- Record of one scheduled event: the θ at which it occurs, itsEventType, the waves involved, the positionlocation[m³], the'head'/'tail'boundary_typefor rarefaction collisions, and the colliding face pair for merges. It defines no ordering, because the solver orders(theta, counter, ...)tuples rather than the objects themselves.find_characteristic_intersection()- First strictly-future crossing(θ, V)of two characteristics, orNonewhen their speeds are equal or they never meet aftertheta_current. Both lines are evaluated from the shared referencemax(θ_start_1, θ_start_2, θ_current).find_shock_shock_intersection()- Same line/line crossing for two shocks, using each shock’s constant Rankine-Hugoniot speed.find_shock_characteristic_intersection()- Same for a shock against a characteristic. The operand order is load-bearing:Vis evaluated on the first line, so it fixes the successor wave’sv_startbit for bit.find_rarefaction_boundary_intersections()- Crossings of a rarefaction’s head and tail lines (which travel at1/R(c_head)and1/R(c_tail)) with another wave, returned as a list of(θ, V, boundary_type)withboundary_typein{'head', 'tail'}.find_outlet_crossing()- Cumulative flow at which a wave reachesv_outlet, assuming positive flow: a straight-line inverse for characteristics and shocks, and the cached-trajectory inverseoutlet_crossing_thetafor the fan-fed shocks (both the decaying and the doubly-fed one). ReturnsNoneif the wave is inactive, moves backward, has a pinned characteristic speed, or has already passed the outlet within a relative tolerance that stops a just-processed crossing from re-firing. Rarefactions are excluded — their callers split head and tail into separate boundary crossings.is_outlet_crossing_pinned()- Whether a boundary state sits on thec_minretardation floor with an inflatedR(c_min), so that its scheduled outlet crossing is a floor artifact rather than physics and the caller should drop it.
- gwtransport.fronttracking.events.is_outlet_crossing_pinned(concentration, sorption)[source]#
Whether a boundary state is pinned by the
c_minretardation 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 (suppliesc_minandretardation).
- Returns:
Trueonly whenconcentrationis at/belowc_minAND the floored retardationR(c_min)is inflated pastOUTLET_PIN_RETARDATION.- Return type:
- class gwtransport.fronttracking.events.EventType(*values)[source]#
Bases:
EnumAll possible event types in front tracking simulation.
- CHAR_CHAR_COLLISION = 'characteristic_collision'#
Two characteristics intersect (will form shock).
- SHOCK_SHOCK_COLLISION = 'shock_collision'#
Two shocks collide (will merge).
- SHOCK_CHAR_COLLISION = 'shock_characteristic_collision'#
Shock catches or is caught by characteristic.
- RAREF_CHAR_COLLISION = 'rarefaction_characteristic_collision'#
Rarefaction boundary intersects with characteristic.
- SHOCK_RAREF_COLLISION = 'shock_rarefaction_collision'#
Shock intersects with rarefaction boundary.
- DSW_FAN_EXHAUSTED = 'decaying_shock_fan_exhausted'#
A decaying shock’s fan is exhausted (c_decay reached c_fan_tail).
- WAVE_MERGE = 'wave_merge'#
any interaction involving a decaying/doubly-fed shock — fan-entry, doubly-fed formation, same-apex annihilation, side exhaustion (a doubly-fed shock crossing its own fan boundary line), and their compositions.
- Type:
Two faces overtake (universal merge)
- OUTLET_CROSSING = 'outlet_crossing'#
Wave crosses outlet boundary.
- class gwtransport.fronttracking.events.Event(theta, event_type, waves_involved, location, boundary_type=None, faces=None)[source]#
Bases:
objectA single event in the simulation, ordered by cumulative flow θ.
The solver’s priority queue orders
(theta, counter, ...)tuples, notEventobjects, 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.
- __init__(theta, event_type, waves_involved, location, boundary_type=None, faces=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.
- gwtransport.fronttracking.events.find_shock_shock_intersection(shock1, shock2, theta_current)[source]#
Find exact analytical intersection of two shocks in (V, θ).
- gwtransport.fronttracking.events.find_shock_characteristic_intersection(shock, char, theta_current)[source]#
Find exact analytical intersection of a shock and a characteristic in (V, θ).
- 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 at1/R(c_tail)), so each is a straight (V, θ) line fed directly into_line_intersection()— no temporaryCharacteristicWaveobjects. The operand order is per-branch:ais the raref boundary against a characteristic, but the SHOCK against a raref boundary, soV_intersect— the new wave’sv_start— matchesfind_shock_characteristic_intersection()bit for bit.
- 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, andDecayingShockWave. Rarefaction outlet crossings are handled by the callers directly (the solver andoutput.pysplit them into head/tail boundary crossings), so aRarefactionWavenever reaches this function and returnsNone.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.
fronttracking.handlers#
Event handlers for front tracking in (V, θ) coordinates.
Each handler receives the waves involved in an event and returns the new waves created by the interaction. In (V, θ) coordinates every wave speed is flow-free, so handlers depend only on concentrations and the sorption isotherm — flow does not appear.
All handlers enforce physical correctness:
Mass conservation (Rankine-Hugoniot condition)
Entropy conditions (Lax condition for shocks)
Causality (no backward-traveling information)
Handlers modify wave states in-place by deactivating parent waves and
creating new child waves. Positions are volumetric [m³] and theta_event
is cumulative flow [m³]; see gwtransport.fronttracking.waves for the
(V, θ) convention.
Available functions:
handle_characteristic_collision()- Two characteristics meet, the faster catching the slower from behind. This compression always emits a single shock spanning the two concentrations, in every sorption regime; a resulting shock that fails the Lax condition raisesRuntimeError.handle_shock_collision()- Two shocks merge into one connecting the outer states:c_leftfrom the upstream shock,c_rightfrom the downstream one, with the speed recomputed from Rankine-Hugoniot. An entropy-violating merge raisesRuntimeError.handle_shock_characteristic_collision()- A shock and a characteristic meet; the characteristic’s concentration replacesc_rightwhen the shock does the catching andc_leftwhen the characteristic does. The successor is the modified shock if it satisfies entropy, otherwise a rarefaction, so mass balance is preserved either way.handle_shock_rarefaction_collision()- A shock meets a rarefaction head or tail. The pair is replaced by oneDecayingShockWavethat subsumes fan and shock together: a head collision decays the left side fromraref.c_headwithc_fixed = shock.c_right, a tail collision decays the right side fromraref.c_tailwithc_fixed = shock.c_left, and the fan’s opposite boundary becomesc_fan_tailso partial drying is resolved exactly. Degenerate input where the boundary is not faster than the shock deactivates both waves and emits nothing.handle_rarefaction_characteristic_collision()- A rarefaction boundary meets a characteristic. The characteristic is absorbed when its concentration matches the boundary value within tolerance; otherwiseRuntimeErroris raised rather than silently destroying mass.create_inlet_waves_at_theta()- Emit the wave a step fromc_prevtoc_newproduces at the inlet faceV = 0: a shock when the new characteristic speed is higher (compression), a rarefaction when it is lower (expansion), and a characteristic when the two speeds tie. Returns an empty list for a negligible step or for a shock that fails the entropy check.
- gwtransport.fronttracking.handlers.handle_characteristic_collision(char1, char2, theta_event, v_event)[source]#
Two characteristics collide → emit a shock.
The faster characteristic catches the slower one from behind. By the entropy condition this compressive interaction is always a shock, independently of the sorption regime (Freundlich n>1, n<1, or constant retardation).
- Parameters:
char1 (
CharacteristicWave) – Colliding characteristics.char2 (
CharacteristicWave) – Colliding characteristics.theta_event (
float) – Cumulative flow at which the collision occurs [m³].v_event (
float) – Position at which the collision occurs [m³].
- Returns:
Single shock created at the collision point.
- Return type:
- 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_leftfrom the faster (upstream) shock,c_rightfrom the slower (downstream) shock; its speed is recomputed via Rankine-Hugoniot.- Parameters:
- Returns:
Single merged shock.
- Return type:
- 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:
- 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
DecayingShockWavewhose trajectory subsumes the fan + shock together, for anyNonlinearSorption: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, andc_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, andc_fan_tail = raref.c_head.
The fan is bounded by
c_fan_tail: the solver’sDSW_FAN_EXHAUSTEDevent 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:
- gwtransport.fronttracking.handlers.handle_rarefaction_characteristic_collision(raref, char, theta_event, v_event, boundary_type)[source]#
Rarefaction boundary intersects a characteristic.
When the characteristic’s concentration matches the boundary concentration to within tolerance the characteristic is absorbed; otherwise an informative
RuntimeErroris 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_typeis not'head'or'tail'.- Return type:
- 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:
fronttracking.math#
Mathematical Foundation for Front Tracking with Nonlinear Sorption.
This module provides exact analytical computations for:
Freundlich, Langmuir, and constant retardation models
Brooks-Corey and van Genuchten-Mualem unsaturated conductivity models (for Kinematic-Wave percolation, see
gwtransport.percolation)Shock velocities via Rankine-Hugoniot condition
Characteristic velocities and positions
First arrival time calculations
Entropy condition verification
All sorption-class computations are exact analytical formulas; the
van Genuchten-Mualem class uses scipy.optimize.brentq for the two
inversions that have no closed form.
Available functions:
NonlinearSorption- Abstract base for concentration-dependent isotherms. Subclasses supplyretardation(c),total_concentration(c)andconcentration_from_retardation(r); the base derives the Rankine-Hugoniotshock_speed(c_left, c_right), the Laxcheck_entropy_condition, the pairedc_and_total_from_retardationandfan_converges_at_infinityfrom them.FreundlichSorption- Power-law isotherms(C) = k_f · C^(1/n)with closed-formR(C) = 1 + (ρ_b · k_f)/(n_por · n) · C^(1/n − 1).n > 1makes higher concentrations travel faster,n < 1mirrors that, andn = 1is rejected (useConstantRetardation). BecauseR → ∞asC → 0forn > 1,retardationandconcentration_from_retardationclampCatc_min.ConstantRetardation- Linear sorptions(C) = K_d · Creduced to oneretardation_factor: every concentration moves atdV/dθ = 1/R, so no rarefaction ever forms and the solution is a pure θ-shift with shocks only at inlet discontinuities.LangmuirSorption- Favorable isotherms(C) = s_max · C/(K_L + C)withR(C) = 1 + (ρ_b · s_max · K_L)/(n_por · (K_L + C)²), which is finite atC = 0, so no concentration floor is needed;Rdecreases withC, giving shocks on concentration rises and fans on falls.BrooksCoreyConductivity- Brooks-Corey unsaturated conductivityK(θ) = K_s · Θ^arecast into the(C, C_T)variables of the sorption interface by identifyingC ≡ K(flux) andC_T ≡ θ − θ_r(storage), which letsgwtransport.percolationrun Kinematic-Wave percolation on this solver. All three curve methods are closed form;Cis clamped at a dry-soil floor inretardationandconcentration_from_retardationbut not intotal_concentration, keeping thec_R = 0wetting-front shock speed exact.VanGenuchtenMualemConductivity- Mualem conductivity for the van Genuchten retention curve, recast the same way.total_concentrationand the flux derivative are closed form; the two inversionsS_e(C)andS_e(R)usescipy.optimize.brentq. The retention parameterα_vGis not needed because the Kinematic-Wave approximation drops capillary suction.characteristic_speed()- Characteristic speeddV/dθ = 1/R(c)in (V, θ) coordinates, a property of the isotherm alone. Itssorptionargument is any of the classes above (theSorptionModelalias is their union).characteristic_position()- Position of a characteristic at cumulative flowtheta, evaluated asv_start + (θ − θ_start)/R(c), orNonewhenthetalies beforetheta_start.compute_first_front_arrival_theta()- Cumulative flow at which the first injected concentration level is fully present at the outlet: the θ-edge of the first bin that both carries water (positive θ-width) and hascin > 0, plusV · R(c_first)for Freundlichn < 1andV · C_T(c_first)/c_firstotherwise. These are tail-arrival semantics — a rarefaction head can reach the outlet much earlier — and the result isinfwhen no water-carrying bin injects concentration.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- class gwtransport.fronttracking.math.NonlinearSorption[source]#
Bases:
ABCAbstract 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
FreundlichSorptionFreundlich isotherm implementation.
LangmuirSorptionLangmuir isotherm implementation.
ConstantRetardationLinear (constant R) retardation model.
- abstractmethod retardation(c)[source]#
Compute retardation factor R(C).
R fixes the characteristic celerity in (V, θ) coordinates:
dV/dθ = 1/R(C).
- abstractmethod total_concentration(c)[source]#
Compute total concentration (dissolved + sorbed per unit pore volume).
C_Tis the conserved quantity of∂C_T/∂θ + ∂C/∂V = 0; the flux carries only the dissolved part because sorbed mass is immobile.
- abstractmethod concentration_from_retardation(r)[source]#
Invert retardation factor to obtain concentration.
Used by rarefaction fans, where the self-similar solution gives R as a function of position and cumulative flow.
- 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 = 0becomes∂C_T/∂θ + ∂C/∂V = 0, and Rankine-Hugoniot reduces to:dV_s/dθ = (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.
- c_and_total_from_retardation(r)[source]#
Return
(c, C_T(c))at a given retardationr.Default implementation calls
concentration_from_retardation(r)thentotal_concentration(c)— two independent root-finds for sorptions where both routes back-solve the same equation (e.g. vG-Mualem withL ≠ 0). Subclasses for which both can be computed from a single root-find should override this for ~2× speedup of the IBP fan integrators.
- fan_converges_at_infinity()[source]#
Whether a
c_apex=0fan’s∫ c dθconverges asθ → +∞.True when
c → 0asR → ∞(sobase·c → 0faster thanbase → ∞): Brooks-Corey, van Genuchten-Mualem, Langmuir, and Freundlichn > 1. The only divergent case is Freundlichn < 1(c → ∞asR → ∞), which overrides this toFalse. Used by the universal temporal fan integrator to reject a+∞upper bound when the integral diverges.- Return type:
- class gwtransport.fronttracking.math.FreundlichSorption(k_f, n, bulk_density, porosity, c_min=1e-12)[source]#
Bases:
NonlinearSorptionFreundlich sorption isotherm with exact analytical methods.
The Freundlich isotherm is: s(C) = k_f * C^(1/n)
where: - s is sorbed concentration [mass/mass of solid] - C is dissolved concentration [mass/volume of water] - k_f is Freundlich coefficient [(volume/mass)^(1/n)] - n is Freundlich exponent (dimensionless)
For n > 1: Higher C travels faster For n < 1: Higher C travels slower For n = 1: linear (not supported, use ConstantRetardation instead)
Notes
- The retardation factor is defined as:
- R(C) = 1 + (rho_b/n_por) * ds/dC
= 1 + (rho_b*k_f)/(n_por*n) * C^((1/n)-1)
For Freundlich sorption, R depends on C, which creates nonlinear wave behavior.
For n>1 (higher C travels faster), R(C)→∞ as C→0, which can cause extremely slow wave propagation. The c_min parameter prevents this by enforcing a minimum concentration, making R(C) finite for all C≥0.
Examples
>>> sorption = FreundlichSorption( ... k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3 ... ) >>> r = sorption.retardation(5.0) >>> c_back = sorption.concentration_from_retardation(r) >>> bool(np.isclose(c_back, 5.0)) True
- __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,porosityoutside (0, 1), orc_min< 0.
- retardation(c)[source]#
R(C) = 1 + (rho_b*k_f)/(n_por*n) * C^((1/n)-1). SeeNonlinearSorption.retardation().Notes
R decreases with C for n > 1 (higher C travels faster) and increases for n < 1. With n<1 and
c_min=0,R(0) = 1(no sorption at zero) because clamping toc_min=0leavesC^((1/n)-1) = 0^positive = 0; otherwisecis clamped toc_minbefore evaluation.Clamping with
np.maximumbefore the power keeps a single general path for every(n, c_min)combination and avoids raising the base to a fractional power on negativec.A pure-float fast path handles the (dominant) scalar case, bit-identical to the array path; it falls through to the array expression only when the clamped
cis0(c_min == 0andc <= 0), where numpy’s0.0**exponentyields the documentedinf(n>1) /0(n<1) that a pure0.0**negwould instead raise on.
- total_concentration(c)[source]#
C_T = C + (rho_b/n_por)·k_f·C^(1/n). SeeNonlinearSorption.total_concentration().Notes
For
c = 0,c^(1/n) = 0exactly (no singularity for anyn > 0), soC_T(0) = 0is physically correct and noc_minclamp is needed here.c_minis only required to keepretardation()finite asc -> 0forn > 1; clampingtotal_concentrationtoc_minwould bias Rankine-Hugoniot shock speeds whenc_R = 0(e.g. the canonical 0->c->0 pulse). Negativecis clamped to0defensively.
- concentration_from_retardation(r)[source]#
C = [(R-1)·n_por·n/(rho_b·k_f)]^(n/(1-n)). SeeNonlinearSorption.concentration_from_retardation().Notes
The exponent
n/(1-n)is undefined at n = 1, which is why linear sorption must useConstantRetardationinstead. Results belowc_minare clamped to it.
- fan_converges_at_infinity()[source]#
Freundlich
n > 1:c → 0asR → ∞(converges).n < 1:c → ∞(diverges).- Return type:
- __init__(k_f, n, bulk_density, porosity, c_min=1e-12)#
- class gwtransport.fronttracking.math.ConstantRetardation(retardation_factor)[source]#
Bases:
objectConstant (linear) retardation model.
For linear sorption: s(C) = K_d * C This gives constant retardation: R(C) = 1 + (rho_b/n_por) * K_d = constant
This is a special case where concentration-dependent behavior disappears. Used for conservative tracers or as approximation for weak sorption.
Notes
With constant retardation: - All concentrations travel at same speed in (V, θ): dV/dθ = 1/R - No rarefaction waves form (all concentrations travel together) - Shocks occur only at concentration discontinuities at inlet - Solution reduces to simple θ-shifting (and then t-shifting via the θ↔t map)
This is equivalent to a single-pore-volume advective time-shift (the deterministic limit of
gwtransport.advection.infiltration_to_extraction()) in the gwtransport package.Examples
>>> sorption = ConstantRetardation(retardation_factor=2.0) >>> sorption.retardation(5.0) 2.0 >>> sorption.retardation(10.0) 2.0
- retardation_factor: float#
Constant retardation factor [-]. At least 1.0;
R = 1is a conservative tracer.
- __post_init__()[source]#
Validate parameters after initialization.
- Raises:
ValueError – If
retardation_factoris less than 1.0.
- concentration_from_retardation(r)[source]#
Not applicable: with constant R the inversion is not meaningful.
- Raises:
NotImplementedError – Always.
- Return type:
- shock_speed(c_left, c_right)[source]#
dV/dθ = 1/Rfor any concentration pair — identical to every characteristic speed.- Return type:
- check_entropy_condition(c_left, c_right, shock_speed)[source]#
Check the Lax entropy condition; always satisfied, since with constant R it holds as an equality.
- Returns:
satisfies – Always True.
- Return type:
- __init__(retardation_factor)#
- class gwtransport.fronttracking.math.LangmuirSorption(s_max, k_l, bulk_density, porosity)[source]#
Bases:
NonlinearSorptionLangmuir sorption isotherm with exact analytical methods.
The Langmuir isotherm is: s(C) = s_max * C / (K_L + C)
where: - s is sorbed concentration [mass/mass of solid] - C is dissolved concentration [mass/volume of water] - s_max is maximum sorption capacity [mass/mass of solid] - K_L is half-saturation constant [mass/volume]
Retardation always decreases with C (favorable isotherm), and R(0) is finite — unlike Freundlich with n > 1, no minimum concentration threshold is needed.
See also
FreundlichSorptionFreundlich isotherm (unbounded sorption).
ConstantRetardationLinear (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
- __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, orporosityoutside (0, 1).
- retardation(c)[source]#
R(C) = 1 + A/(K_L + C)²withA = rho_b·s_max·K_L/n_por.See
NonlinearSorption.retardation().R(0)is finite, R decreases with C, andR → 1asC → ∞(all sorption sites saturated).
- total_concentration(c)[source]#
C_T = C + (rho_b/n_por)·s_max·C/(K_L + C). SeeNonlinearSorption.total_concentration().
- concentration_from_retardation(r)[source]#
C = sqrt(A/(R - 1)) - K_L. SeeNonlinearSorption.concentration_from_retardation().Notes
Returns
0.0forR <= 1(unphysical) and forR >= R(0) = 1 + A/K_L²(at or below zero concentration).
- __init__(s_max, k_l, bulk_density, porosity)#
- class gwtransport.fronttracking.math.BrooksCoreyConductivity(theta_r, theta_s, k_s, brooks_corey_lambda)[source]#
Bases:
NonlinearSorptionBrooks-Corey unsaturated conductivity recast as a NonlinearSorption.
Used by
gwtransport.percolationto 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 identifyingC ≡ K(the flux variable) andC_T ≡ θ - θ_r(the conserved storage). All three abstract methods have closed forms;shock_speedandcheck_entropy_conditionare inherited unchanged fromNonlinearSorption.See also
VanGenuchtenMualemConductivityVan Genuchten variant with brentq inversions.
FreundlichSorptionPower-law sorption isotherm (closed form, analogous shape).
gwtransport.percolation.root_zone_to_water_table_kinematic_waveThe 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. Since1/a − 1 < 0always (a > 3),R(C) → ∞asC → 0(dry-soil singularity). The class clampsCto a small floor inretardationandconcentration_from_retardation(the same pattern asFreundlichSorptionwithn > 1);total_concentrationand the inheritedshock_speeddo not clamp, so the canonical wetting-front shockc_R = 0produces 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_s: float#
Saturated volumetric moisture content [-];
theta_r < theta_s < 1. The porosity for typical soils.
- 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) givesa = 2.5 + 2/λand is not implemented; a user wanting it can re-deriveλso the Burdineamatches the desired Mualem exponent.
- __post_init__()[source]#
Validate parameters and derive
a,delta_theta.- Raises:
ValueError – If any parameter is outside its valid range.
- Return type:
- __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:
NonlinearSorptionMualem prediction for the van Genuchten retention curve, recast as NonlinearSorption.
Used by
gwtransport.percolationfor 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
α_vGis not needed forK(θ)— the Kinematic-Wave approximation drops capillary suction, so only theK(S_e)curve matters. The two inversionsS_e(C)andS_e(R)have no closed form; both usescipy.optimize.brentqwithxtol = BRENTQ_XTOL = 1e-14.See also
BrooksCoreyConductivityBrooks-Corey closed-form variant.
gwtransport.percolation.root_zone_to_water_table_kinematic_waveThe 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}andU = 1 - T^m. It is evaluated by_dk_dseand used forretardation(C)(after solvingS_e(C)) and as the brentq objective in_se_from_retardation.dK_M/dS_eis strictly increasing for everyn_vG > 1andL ≥ 0(the conductivity flux is convex,d²K/dS_e² > 0; proved at 200-digit precision indocs/theory/front_tracking_interactions.md§2), so the brentq inversions are always well-posed — no monotonicity guard is needed. A convex flux also means the transport admits only shocks and rarefactions, never compound waves.Examples
>>> sorption = VanGenuchtenMualemConductivity( ... theta_r=0.01, theta_s=0.337, k_s=0.174, van_genuchten_n=2.28 ... ) >>> r = sorption.retardation(0.05) >>> c = sorption.concentration_from_retardation(r) >>> bool(np.isclose(c, 0.05, rtol=1e-12)) True
- __init__(theta_r, theta_s, k_s, van_genuchten_n, mualem_l=0.5)#
- mualem_l: float = 0.5#
Mualem pore-connectivity
L >= 0. Default 0.5 (standard Mualem).L = 0(Burdine variant) gives a closed-formS_e(C)inverse;L != 0requiresbrentq.
- __post_init__()[source]#
Validate parameters and derive
m,delta_theta.No convexity/monotonicity guard is needed:
dK_M/dS_eis strictly increasing (the fluxK(S_e)is convex,d²K/dS_e² > 0) for everyn_vG > 1andL ≥ 0— proved at 200-digit precision indocs/theory/front_tracking_interactions.md§2 — so the brentq inversions are always well-posed.- Raises:
ValueError – If any parameter is outside its valid range.
- Return type:
- concentration_from_retardation(r)[source]#
Invert
R(C) = r. SolvedK_M/dS_e(S_e) = Δθ/rvia brentq, thenC = K_M(S_e).
- c_and_total_from_retardation(r)[source]#
Return
(c, C_T)at retardationrfrom a SINGLE brentq call.Overrides the default base-class implementation (which calls
concentration_from_retardationandtotal_concentrationseparately and ends up doing two independent brentq solves on the same underlying equation). Halves the iterative-solver cost in the IBP fan integrators.
- 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:
c (
float) – Dissolved concentration [mass/volume].sorption (
NonlinearSorption|ConstantRetardation) – Sorption model.
- Returns:
speed – Characteristic speed dV/dθ.
- Return type:
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:
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_firstarrives at the outlet (end of spin-up).“Arrival” means the θ at which the
c_firstlevel is fully present at the outlet,θ_emit + V·R(c_first)forn<1andθ_emit + V·C_T(c_first)/c_firstforn>1/constant retardation.Warning
For
n<1withc_min > 0(defaultc_min = 1e-12inFreundlichSorption), the actual wave emitted is aRarefactionWavewhose head (c = c_min ≈ 0) reaches the outlet at θ ≈V·R(c_min) ≈ V— much earlier than the value this function returns (which is the tail arrivalV·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 (
NDArray[floating]) – Inlet concentration [mass/volume].theta_edges (
NDArray[floating]) – Cumulative-flow edges; lengthlen(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_firstis fully present at the outlet [m³]. Returnsnp.infonly if no water-carrying (positive θ-width) bin has nonzerocin.- Return type:
Examples
>>> cin = np.array([0.0, 10.0] + [10.0] * 10) >>> theta_edges = np.arange(0.0, 1300.0, 100.0) # constant flow=100, dt=1 >>> sorption = ConstantRetardation(retardation_factor=2.0) >>> theta_first = compute_first_front_arrival_theta( ... cin, theta_edges, 500.0, sorption ... ) >>> bool(np.isclose(theta_first, 100.0 + 500.0 * 2.0)) # θ_emit + V·R True
fronttracking.output#
Concentration extraction from front-tracking solutions (V, θ coordinates).
Every public function in this module takes θ (cumulative flow, m³). Callers
translate user-facing time t → θ at the API boundary via
FrontTrackerState.theta_at_t.
Outlet-mass functions use the PDE conservation identity
m_out(θ) = m_in(θ) − m_dom(θ) (Bear & Cheng 2010, Ch. 3: mass
conservation for transport with sorption). m_dom honors historical
wave activity via wave.was_active_at(theta) so retrospective queries
at θ before a collision event correctly attribute c at v_outlet.
Available functions:
concentration_at_point()- Exact concentration at a single point(v, θ): the upstream state of the nearest wave face at or downstream ofvamong the waves active atθ, the downstream state of the outermost face when nothing lies downstream, and0in a virgin domain. Exactly on a shock or fan face the two sides are averaged; a contact carries its upstream value at its own position. Thesorptionargument is unused — each wave carries its own sorption reference.compute_breakthrough_curve()- Outlet concentration sampled at every θ of a θ-array, i.e.concentration_at_point()evaluated atv_outlet. Returns one concentration [mass/volume] per θ; these are point samples of the exact trace, not bin averages.compute_bin_averaged_concentration_exact()- Outlet concentration averaged over each output θ-bin (a flow-weighted average, since θ is cumulative flow), evaluated through the conservation identityc_avg = (Δm_in − Δm_dom) / Δθrather than by integrating the outlet trace, so overlapping fans need no ownership dispatch. A residual inside the floating-point cancellation band is set to zero; a bin more negative than that band warns and is clamped to zero to preserve thecout >= 0contract.identify_outlet_segments()- Partition of a θ-interval into the segments over which a single wave controls the outlet, as a list of dicts holding the segment θ-range, its type ('constant','rarefaction'or'decaying_fan'), the controlling wave and the concentrations at both segment ends. Crossings extrapolated at or past a wave’s deactivation are dropped.compute_domain_mass()- Total mass∫₀^{v_outlet} C_total(v, θ) dvheld in the domain at one θ [mass], dissolved plus sorbed. The domain is partitioned at the active wave faces; a constant region integrates asC_total·Δvand a fan region in closed form viaintegrate_fan_spatial_exact().compute_cumulative_inlet_mass()- Mass injected at the inlet fromθ = 0up totheta,∫₀^θ cin dτ[mass], exact for the piecewise-constantcinontheta_edges.compute_cumulative_outlet_mass()- Mass that has left through the outlet fromθ = 0up totheta[mass], asm_in(θ) − m_dom(θ);0forθ <= 0.compute_total_outlet_mass()- Outlet mass over all θ, from the inlet record alone: the full injected massΣ cin·Δθwhen the record returns tocin[-1] = 0, and+inffor a sustainedcin[-1] > 0boundary, which keeps injecting forever.integrate_fan_exact()- Exact∫ c(θ) dθ[mass] at a fixed position for any self-similar fan, given the fan apex(theta_origin, v_origin), via the universal integration-by-parts antiderivativeF(θ) = c·(θ − θ_origin) − Δv·C_T(c).c_apex > 0clamps the fan at its tail θ and adds the constant plateau beyond it;theta_endmay be+infwhere the fan integral converges, and raises otherwise.integrate_rarefaction_exact()-integrate_fan_exact()with the apex andc_apex = raref.c_tailread off aRarefactionWave.integrate_fan_spatial_exact()- Exact∫ C_total(v, θ) dv[mass] over a v-segment of a self-similar fan at fixed θ, via the spatial counterpartG(u) = C_T(c)·u − κ·cwithκ = θ − θ_originandu = v − v_origin.c_apex > 0splits off the constant-C_total(c_apex)region near the apex.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.fronttracking.output.concentration_at_point(v, theta, waves, sorption)[source]#
Compute concentration at point (v, θ) with exact analytical value.
The function works entirely in (V, θ) coordinates: public callers must translate user-facing time t → θ at the API boundary (e.g., via
FrontTrackerState.theta_at_t).- Parameters:
v (
float) – Position [m³].theta (
float) – Cumulative flow [m³].waves (
Sequence[Wave]) – All waves in the simulation (active and inactive).sorption (
NonlinearSorption|ConstantRetardation) – Sorption model (unused — kept for API symmetry; wave methods carry their own sorption reference).
- Returns:
concentration – Concentration at point (v, θ) [mass/volume].
- Return type:
Notes
Nearest-downstream-face sweep.
C(v, θ)is the left (upstream) state of the nearest face strictly downstream ofvamong the waves active atθ; if no face is downstream, the right state of the outermost face;0in a virgin domain. A face’s feeder is a constant or a bounded self-similar fan (clamped to its extent, so the plateau beyond a fan reads correctly). Because every face crossing fires a solver event, the front list is interaction-consistent and each region has a single owner — no ownership heuristic is needed. This is exact for a single front (one owner everywhere) and for genuinely interacting multi-front inputs alike.
- gwtransport.fronttracking.output.compute_breakthrough_curve(theta_array, v_outlet, waves, sorption)[source]#
Concentration at the outlet evaluated over a θ-array (breakthrough curve).
- Parameters:
theta_array (
NDArray[floating]) – Cumulative-flow points at which to query the outlet concentration [m³]. Must be sorted in ascending order. Callers translate from user-facing time viaFrontTrackerState.theta_at_tbefore passing.v_outlet (
float) – Outlet position [m³].sorption (
NonlinearSorption|ConstantRetardation) – Sorption model.
- Returns:
c_out – Concentration at
v_outletfor each θ intheta_array[mass/volume].- Return type:
See also
concentration_at_pointPoint-wise concentration
compute_bin_averaged_concentration_exactBin-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:
theta_start (
float) – Start of cumulative-flow interval [m³].theta_end (
float) – End of cumulative-flow interval [m³].v_outlet (
float) – Outlet position [m³].sorption (
NonlinearSorption|ConstantRetardation) – Sorption model.
- 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 aDecayingShockWaveafter its head crossesv_outlet; c atv_outletthen 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:
Notes
Segments are constructed by:
Finding all wave crossing events at the outlet for θ in [theta_start, theta_end].
Sorting events by θ.
Creating constant-concentration segments between events.
Handling rarefaction and decaying-fan profiles with θ-varying concentration.
The segments completely partition the interval [theta_start, theta_end].
Every crossing is clamped to
theta_cross < theta_deactivation(matchingwas_active_atsemantics): a crossing extrapolated past a wave’s deactivation is an artifact — after a collision the front belongs to the successor wave, whose own crossing covers the outlet.
- gwtransport.fronttracking.output.integrate_rarefaction_exact(raref, v_outlet, theta_start, theta_end, sorption)[source]#
Exact θ-integral
∫ c(θ) dθof a rarefaction at the outlet.Convenience wrapper over
integrate_fan_exact()that pulls the fan apex fromraref.theta_start, raref.v_start. Returns the mass-like quantity∫ c dθ(=∫ c·flow dtin 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(θ) dθ[mass — i.e. concentration × volume].- Return type:
- 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(θ) dθ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) andDecayingShockWave(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_endmay be+np.inf;theta_startmust be finite.theta_end (
float) – Integration range in cumulative flow [m³].theta_endmay be+np.inf;theta_startmust 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. ForRarefactionWavethis israref.c_tail; forDecayingShockWave(decay_side=’left’) this iswave.c_fan_tail(the plateau the outlet holds once the fan’s far edge sweeps by). Forc_apex > 0the fan formula extrapolates past the physical fan range; the integration is clamped atθ_tail(wherec(θ_tail) = c_apex) and the constant-c_apex region beyond contributesc_apex · (theta_end − θ_tail). Default 0.0 preserves the c=0 apex behavior for canonical c_R=0 fans.
- Returns:
Mass-like quantity
∫ c(θ) dθ[mass — concentration × volume].- Return type:
- Raises:
TypeError – If the sorption model does not support exact fan integration.
- gwtransport.fronttracking.output.compute_bin_averaged_concentration_exact(theta_bin_edges, v_outlet, waves, sorption, *, cin, theta_edges_inlet)[source]#
θ-bin-averaged outlet concentration.
For each θ-bin
[θ_i, θ_{i+1}]:C_avg = (1 / Δθ) · ∫_{θ_i}^{θ_{i+1}} C(v_outlet, θ) dθevaluated through the conservation-law identity
C_avg = (Δm_in − Δm_dom) / Δθper bin — analytical and explicit, no outlet-side fan dispatch, so multi-DSW and n<1 mirror geometries are handled by the same single path.- Parameters:
theta_bin_edges (
NDArray[floating]) – Cumulative-flow OUTPUT bin edges [m³] (where C_avg is reported). Length N+1 for N bins. Callers translate t-bin edges withstate.theta_at_t.v_outlet (
float) – Outlet position [m³].waves (
Sequence[Wave]) – All waves from front tracking simulation.sorption (
NonlinearSorption|ConstantRetardation) – Sorption model.cin (
ArrayLike) – Inlet concentration per inlet θ-bin.theta_edges_inlet (
NDArray[floating]) – θ bin edges of the INLET (state.theta_edges), lengthlen(cin) + 1.
- Returns:
c_avg – Bin-averaged outlet concentrations [mass/volume]. Length N.
- Return type:
- Raises:
ValueError – If any output θ-bin has non-positive width.
See also
concentration_at_pointPoint-wise concentration
compute_breakthrough_curveBreakthrough curve
compute_cumulative_outlet_massCumulative 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 viaintegrate_fan_spatial_exact()).- Parameters:
theta (
float) – Cumulative flow at which to compute domain mass [m³].v_outlet (
float) – Outlet position (domain extent) [m³].sorption (
NonlinearSorption|ConstantRetardation) – Sorption model.
- Returns:
mass – Total mass in domain [mass]. Closed-form analytical to machine precision.
- Return type:
See also
compute_cumulative_inlet_massCumulative inlet mass
compute_cumulative_outlet_massCumulative outlet mass
concentration_at_pointPoint-wise concentration
integrate_fan_spatial_exactClosed-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, θ) dvfor any self-similar fan.Decoupled from the wave object so the same closed-form math applies to
RarefactionWave(apex =theta_start, v_start) andDecayingShockWave(apex =theta_origin, v_origin).In (V, θ) the self-similar fan satisfies
R(C) = (θ - θ_origin)/(v - v_origin); definekappa = θ - θ_originandu = v - v_origin. The dissolved and sorbed contributions reduce to power-law forms inuthat 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’sc_tailor the DSW’sc_fixedfordecay_side='left'). Forc_apex > 0the fan formula is unphysical foru < u_tail = kappa / R(c_apex); the integration is split into a constant-C_total(c_apex) region foru ∈ [u_start, u_tail]plus the fan integral foru ∈ [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:
- 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(τ) dτ; for piecewise-constantcinthis is exact under summation over θ-bin widths.- Parameters:
- Returns:
mass_in – Cumulative inlet mass [mass].
- Return type:
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 = 0over 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³].sorption (
NonlinearSorption|ConstantRetardation) – Sorption model.cin (
ArrayLike) – Inlet concentration per θ-bin [mass/volume].theta_edges (
NDArray[floating]) – θ bin edges [m³], lengthlen(cin) + 1.
- Returns:
mass_out – Cumulative outlet mass [mass].
- Return type:
Examples
mass_out = compute_cumulative_outlet_mass( theta=5000.0, v_outlet=500.0, waves=tracker.state.waves, sorption=sorption, cin=cin, theta_edges=tracker.state.theta_edges, ) mass_out >= 0.0
- gwtransport.fronttracking.output.compute_total_outlet_mass(*, cin, theta_edges)[source]#
Total outlet mass over θ → ∞ (finite only for a returning-to-zero pulse).
The final inlet value
c_∞ = cin[-1]is the sustained boundary state as θ → ∞:For
c_∞ = 0(canonical c_R=0 pulse): injection ceases, the domain empties, and every injected mass unit eventually exits —m_out_total = m_in_total(the finite record integralΣ cin·Δθ). The wave list is not needed.For
c_∞ > 0(sustained ambient): the inlet keeps injectingc_∞forever, so the cumulative outlet mass grows without bound — return+inf. Pairing the FINITE record integral with the infinite-time steady-state fillC_T(c_∞)·v_outletis not an option: that difference goes negative wheneverm_in_total < C_T(c_∞)·v_outlet.
- Parameters:
- Returns:
m_in_totalforcin[-1] = 0;+infforcin[-1] > 0.- Return type:
See also
compute_cumulative_outlet_massCumulative outlet mass up to a finite θ (use this for a sustained
c_∞ > 0boundary, where the θ → ∞ total is unbounded).compute_domain_massSpatial integral of C_total in the aquifer
fronttracking.plot#
Visualization functions for front tracking.
This module provides plotting utilities for visualizing front-tracking simulations:
V-t diagrams showing wave propagation in space-time
Breakthrough curves showing concentration at outlet over time
Internally the simulation uses cumulative-flow coordinates (V, θ). All plots
remain in user-facing time t (days). Translation is done via the state’s
t_at_theta / theta_at_t methods at the plotting boundary.
Available functions:
plot_vt_diagram()- Draw every wave of a completed simulation in the (time, position) plane: characteristics as thin blue lines, shocks as thick red lines, rarefactions as filled green fans, with the inlet and the outlet marked as horizontal lines. Each straight-in-θ trajectory is sampled in θ and translated to time t before plotting. Returns the axes;show_inactiveadds the waves deactivated by interactions andshow_eventsmarks the interaction events.plot_inlet_concentration()- Drawcinon itstedgesas a step function of time [days], optionally marking a first-arrival time with a vertical line. Returns the axes.plot_front_tracking_summary()- Three-panel figure for one simulation: the V-t diagram, the inlet concentration, and across the bottom the outlet concentration as the exact analytical breakthrough curve and/or the bin-averagedcoutstep trace, both referenced to the tracker’s own time origin so the two overlay without a shift. Returns the figure and a dict of axes keyed'vt','inlet'and'outlet'.plot_sorption_comparison()- 2x3 figure contrasting a pulse inlet and a dip inlet with the exact outlet response each produces under a favorable (n>1) and an unfavorable (n<1) isotherm: column 0 holds the two inlets, columns 1-2 their responses. Returns the figure and the 2x3 array of axes.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.fronttracking.plot.plot_vt_diagram(state, ax=None, *, t_max=None, figsize=(14, 10), show_inactive=False, show_events=False)[source]#
Create V-t diagram showing all waves in space-time.
Plots characteristics (blue lines), shocks (red lines), and rarefactions (green fans) in the (time, position) plane. This visualization shows how waves propagate and interact throughout the simulation.
Internally the waves live in (V, θ); each wave’s straight-line θ-trajectory is converted back to user-facing time t via
state.t_at_thetabefore 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 usingfigsize.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:
See also
plot_front_tracking_summaryMulti-panel summary combining these views.
gwtransport.advection.infiltration_to_extraction_nonlinear_sorptionProduces the tracker state.
Notes
Characteristics appear as blue lines (constant speed in θ).
Shocks appear as thick red lines (jump discontinuities).
Rarefactions appear as green fans (smooth transition regions).
Outlet position is shown as a horizontal dashed line.
Only waves within domain [0, v_outlet] are plotted.
Examples
from gwtransport.fronttracking.solver import FrontTracker tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption) tracker.run() ax = plot_vt_diagram(tracker.state) ax.figure.savefig("vt_diagram.png")
- gwtransport.fronttracking.plot.plot_inlet_concentration(tedges, cin, ax=None, *, t_first_arrival=None, color='blue', t_max=None, figsize=(8, 5))[source]#
Plot inlet concentration as a step function.
- Parameters:
tedges (
DatetimeIndex) – Time bin edges for inlet concentration. Length = len(cin) + 1.cin (
ArrayLike) – Inlet concentration values. Length = len(tedges) - 1.ax (
Axes|None, default:None) – Existing axes to plot into. If None, creates new figure.t_first_arrival (
float|None, default:None) – First arrival time to mark with vertical line [days].color (
str, default:'blue') – Color for inlet concentration line. Default ‘blue’.t_max (
float|None, default:None) – Maximum time for x-axis [days]. If None, uses full range.figsize (
tuple[float,float], default:(8, 5)) – Figure size if creating new figure. Default (8, 5).
- Returns:
ax – Axes object.
- Return type:
See also
plot_front_tracking_summaryMulti-panel summary that places this inlet panel.
- gwtransport.fronttracking.plot.plot_front_tracking_summary(structure, tedges, cin, cout_tedges, cout, *, figsize=(16, 10), show_exact=True, show_bin_averaged=True, show_events=True, show_inactive=False, t_max=None, title=None)[source]#
Create comprehensive 3-panel summary figure for front tracking simulation.
Creates a multi-panel visualization with: - Top-left: V-t diagram showing wave propagation - Top-right: Inlet concentration time series - Bottom: Outlet concentration (exact and/or bin-averaged)
- Parameters:
structure (
dict) – Structure returned from infiltration_to_extraction_nonlinear_sorption. Must contain keys: ‘tracker_state’, ‘theta_first_arrival’.tedges (
DatetimeIndex) – Time bin edges for inlet concentration. Length = len(cin) + 1.cin (
ArrayLike) – Inlet concentration values. Length = len(tedges) - 1.cout_tedges (
DatetimeIndex) – Output time bin edges for bin-averaged concentration. Length = len(cout) + 1.cout (
ArrayLike) – Bin-averaged output concentration values. Length = len(cout_tedges) - 1.figsize (
tuple[float,float], default:(16, 10)) – Figure size (width, height). Default (16, 10).show_exact (
bool, default:True) – Whether to show exact analytical breakthrough curve. Default True.show_bin_averaged (
bool, default:True) – Whether to show bin-averaged concentration. Default True.show_events (
bool, default:True) – Whether to show wave interaction events on V-t diagram. Default True.show_inactive (
bool, default:False) – Whether to show inactive waves on V-t diagram. Default False.t_max (
float|None, default:None) – Maximum time for plots [days]. If None, uses input data range.title (
str|None, default:None) – Overall figure title. If None, uses generic title.
- Return type:
- Returns:
fig (
matplotlib.figure.Figure) – Figure object.axes (
dict) – Dictionary with keys ‘vt’, ‘inlet’, ‘outlet’ containing axes objects.
See also
plot_vt_diagramThe top-left sub-panel.
plot_inlet_concentrationThe top-right sub-panel.
gwtransport.advection.infiltration_to_extraction_nonlinear_sorptionProduces
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:
- Returns:
fig (
matplotlib.figure.Figure) – Figure object.axes (
numpy.ndarray) – 2x3 array of axes objects.
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:
Initialize waves from inlet boundary conditions (one per cin step at θ_edges[i]).
Find next event (earliest collision or outlet crossing in θ).
Advance θ to event.
Handle event (create new waves, deactivate old ones).
Repeat until no more events.
All calculations are exact analytical with machine precision.
Available functions:
FrontTrackerState- Dataclass carrying the whole simulation state: every wave ever created (active and deactivated), the event log, the current θ, the outlet positionv_outlet, the sorption model, the inlet series, and thetedges_days/theta_edgesgrids. Itst_at_theta,theta_at_tandtheta_at_t_arraymethods are the piecewise-linear θ↔t maps a caller needs to read any solver output in user-facing days; events at a zero-flow θ plateau map to the end of that plateau.FrontTracker- The event-driven solver. Construction validates the inputs (non-negativecinandflow,len(tedges) == len(cin) + 1, positive pore volume), buildstheta_edgesby cumulatingflow · dt, sets the resolution horizontheta_horizon(default: the last inlet edge), computestheta_first_arrival, and emits the inlet waves.find_next_eventreturns the earliest candidate in θ — characteristic, shock and rarefaction collisions, face merges involving a decaying or doubly-fed shock, fan exhaustion, and outlet crossings —handle_eventdispatches it to the handler that retires the parents and appends the successors, andrunrepeats until the candidate set is empty ormax_iterationsis reached.verify_physicsasserts that every active shock still satisfies the Lax entropy condition.find_unresolved_interaction()- Invariant tripwire over a completed state. The cumulative outlet massm_out(θ) = m_in(θ) − m_dom(θ)must never decrease; this returns a short description of the first θ where it does beyond the floating-point cancellation band — the fingerprint of an interaction the solver failed to resolve — andNonefor a mass-conserving wave field.
- class gwtransport.fronttracking.solver.FrontTrackerState(waves, events, theta_current, v_outlet, sorption, cin, flow, tedges, tedges_days, theta_edges, theta_horizon=inf)[source]#
Bases:
objectComplete 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 viaFrontTrackerState.t_at_theta.theta_current (
float) – Current simulation cumulative flow [m³].v_outlet (
float) – Outlet position [m³].sorption (
NonlinearSorption|ConstantRetardation) – Sorption model.cin (
ndarray) – Inlet concentration time series [mass/volume].flow (
ndarray) – Flow rate time series [m³/day], one value per bin.tedges (
DatetimeIndex) – Time bin edges.tedges_days (
NDArray[floating]) –tedgesas days fromtedges[0], lengthlen(flow) + 1.theta_edges (
NDArray[floating]) – Cumulative flow at every bin edge.theta_edges[i] = sum_{k<i} flow[k] * (tedges_days[k+1] - tedges_days[k]). Lengthlen(flow) + 1.
- sorption: NonlinearSorption | ConstantRetardation#
- tedges: DatetimeIndex#
- 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') - 1lands on the rightmost such bin, so this function returnstedges_days[i]for the right-most i sharing that θ. Convention: events at a zero-flow θ plateau map to the END of the zero-flow interval.- Return type:
- theta_at_t(t)[source]#
Translate user-facing time t [days] to cumulative flow θ [m³].
Scalar form of
theta_at_t_array().- Return type:
- theta_at_t_array(t)[source]#
Map times t [days] to cumulative flow θ [m³].
Piecewise linear forward map. Outside the input range the boundary flow is extrapolated.
- __init__(waves, events, theta_current, v_outlet, sorption, cin, flow, tedges, tedges_days, theta_edges, theta_horizon=inf)#
- class gwtransport.fronttracking.solver.FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption, theta_horizon=None)[source]#
Bases:
objectEvent-driven front-tracking solver for nonlinear sorption transport.
- Parameters:
cin (
ArrayLike) – Inlet concentration time series [mass/volume]; lengthn.flow (
ArrayLike) – Flow rate time series [m³/day]; lengthn(one value per bin).tedges (
DatetimeIndex) – Time bin edges (lengthn+1).aquifer_pore_volume (
float) – Total pore volume [m³] — used as the outlet position.sorption (
NonlinearSorption|ConstantRetardation) – Sorption model.
- state#
Complete simulation state.
- Type:
- 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:
Notes
The solver works exclusively in cumulative flow θ; events appended to
state.eventscarry"theta". Translation to user-facing time t is the caller’s responsibility (usestate.t_at_theta).- handle_event(event)[source]#
Dispatch an event to its handler and record it (with t translated from θ).
- run(max_iterations=10000)[source]#
Process events in θ-order until the queue is empty or
max_iterationsis 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 anym_in_domain + m_out_cumulative == m_in_cumulativetest tautological (residual identically zero, regardless of anycompute_domain_massbug), so it cannot catch a conservation error. The non-tautological, integral-based conservation check (an independent breakthrough integral compared to the inlet mass) lives ingwtransport.fronttracking.validation.verify_physics()check 7.Every shock the solver creates is entropy-checked at construction and a shock’s
(c_left, c_right)never change, so this scan is an assertion for externally assembled wave lists, not a solver self-check.- Raises:
RuntimeError – If an active shock violates the Lax entropy condition.
- gwtransport.fronttracking.solver.find_unresolved_interaction(state)[source]#
Tripwire for a solver-left inconsistency in the resolved wave field.
The solver resolves every wave interaction (shock↔shock, fan-entry, doubly-fed formation, same-apex annihilation, and their compositions), so the wave list is interaction-consistent and the single-owner sweep reader is exact — overlapping fans are normal and correct, not an error. This is an internal invariant check: the cumulative outlet mass
m_out(θ) = m_in(θ) − m_dom(θ)must be non-decreasing in θ (mass leaves the column, it never re-enters). A decrease beyond the FP-cancellation band means the reader’s domain-mass field transiently over-counts stored mass — the fingerprint of an interaction the solver failed to resolve (a bug). The public API turns a non-Nonereturn into a fail-loudRuntimeErrorrather than returning a silently wrongcout.- Parameters:
state (
FrontTrackerState) – Completed simulation state (afterFrontTracker.run()).- Returns:
A short description (θ and mechanism) of the first monotonicity violation, or
Nonewhen the resolved field conserves mass (the normal case).- Return type:
Notes
The scan stays strictly inside the inlet θ-window, so the benign out-of-window saturation clamp (a run whose output bins extend past the last injected mass) does not trip it.
fronttracking.validation#
Physics validation utilities for front tracking in (V, θ) coordinates.
This module provides functions to verify physical correctness of front-tracking
simulations, including entropy conditions, concentration bounds, mass conservation,
and event ordering. The solver runs in cumulative-flow coordinate
θ = ∫flow(t') dt'; events on state.events carry "theta" (m³). Because
flow ≥ 0 is enforced, θ is monotone non-decreasing in t, so θ-ordering and
chronological ordering are equivalent.
Available functions:
verify_physics()- Run seven checks on a completed front-tracking result and return a summary dictionary with'all_passed', the check counts, the'failures'list, a per-check record list and a one-line'summary': Lax entropy for every shock, no negative concentrations, output bounded by the inlet maximum, finite first-arrival θ, no NaN after spin-up, θ-ordered events, and mass conservation comparing an independent outlet integral plus the domain mass against the injected mass atθ_max. The mass-balance check usesmax(rtol, _MASS_BALANCE_RTOL)because integrating a shock-bearing breakthrough curve is only first-order accurate. Passingverbose=Falsesuppresses printing but returns the same dictionary.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.fronttracking.validation.verify_physics(structure, cout, cout_tedges, cin, *, verbose=True, rtol=1e-10)[source]#
Run comprehensive physics verification checks on front tracking results.
Performs the following checks:
Entropy condition for all shocks
No negative concentrations (within tolerance)
Output concentration <= input maximum
Finite first arrival θ
No NaN values after spin-up period
Events θ-ordered (equivalent to chronological under non-negative flow)
Mass conservation: independent outlet integral + domain mass == inlet mass at θ_max
- Parameters:
structure (
dict) – Structure returned frominfiltration_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 ismax(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:
Examples
results = verify_physics(structure, cout, cout_tedges, cin, verbose=False) print(results["summary"]) assert results["all_passed"]
fronttracking.waves#
Wave Representation for Front Tracking in (V, θ) coordinates.
This module implements wave classes for representing characteristics, shocks,
and rarefaction waves in the front tracking algorithm. Each wave stores its
formation position in cumulative-flow coordinate θ = ∫flow(t') dt' and
knows how to compute its position at any later θ.
In (V, θ) every wave velocity is a property of the sorption isotherm alone —
flow does not enter wave dynamics. Time-varying flow is absorbed entirely into
the θ(t) mapping at the API boundary, so no wave needs recreation when the flow
rate changes. Attributes named theta_* are therefore cumulative flow [m³],
never time; v_* are volumetric positions [m³] measured from the inlet face
V = 0. Every module in gwtransport.fronttracking follows this convention.
Available functions:
Wave- Abstract base of the hierarchy. Holds the formation point(v_start, theta_start), theis_activeflag and thetheta_deactivationhistory marker, and requiresposition_at_theta,concentration_left,concentration_rightandconcentration_at_pointfrom every subclass. Usewas_active_at(theta)for retrospective queries;is_activedescribes only the current state.CharacteristicWave- A single characteristic line carrying one constant concentration at the flow-free speed1/R(c). Physically a contact discontinuity in a smooth region: the concentration value travels unchanged, andc_aheadrecords the state it separates from downstream.ShockWave- A sharp front across which concentration jumps fromc_lefttoc_right, formed where faster water overtakes slower water. Itsspeedis the Rankine-Hugoniot secant(c_R − c_L)/(C_T(c_R) − C_T(c_L)), constant in θ;satisfies_entropytests the Lax condition.RarefactionWave- An expansion fan spreading betweenc_headandc_tail, formed where slower water follows faster water. The interior is self-similar,R(c) = (θ − θ_start)/(V − v_start), so head and tail move at the characteristic speeds1/R(c_head)and1/R(c_tail); construction is rejected when the head is not faster than the tail (that geometry is a compression, hence a shock).DecayingShockWave- A shock with a fan on one side, formed when a rarefaction and a shock collide. The fan feeds thedecay_side, whose concentration relaxes fromc_decay_initialtoward the fan’s far boundc_fan_tailwhile the other side stays atc_fixed, so the front decelerates along a curved trajectory instead of a straight line. The trajectory uses a closed form where one exists (Freundlich withc_fixed = 0orn = 2, Langmuir and Brooks-Corey withc_fixed = 0) and a cached quadrature profile otherwise;theta_at_fan_exhaustionreports the θ at which the fan is spent and the wave hands off to a plain shock.DoubleFanShockWave- A shock fed by a self-similar fan on both sides, formed when a fan boundary catches a decaying shock whose surviving side is itself a fan. Both side concentrations then relax as the front advances. The trajectory is closed form for Freundlichn = 2with a shared apex position (the case every inlet-born fan produces) and a cached RK4 spline otherwise; a side ending is detected externally, as the shock face crossing its own fan boundary line.Feeder- The boundary state on one side of a front: either a constant concentration or a bounded self-similar fan with apex(v_apex, theta_apex)spanning[c_a, c_b], evaluated by invertingR = (θ − θ_apex)/(v − v_apex)and clamped inR-space so a query past the fan edge reads the plateau value. Feeders are the common currency of the interaction calculus ingwtransport.fronttracking.interactions.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- class gwtransport.fronttracking.waves.Feeder(c_a, c_b, is_const, v_apex=0.0, theta_apex=0.0, sorption=None, far_boundary_free=True)[source]#
Bases:
objectOne side’s boundary state feeding a front: a constant, or a bounded self-similar fan.
A
constfeeder ignores(v, θ)and returns its value everywhere. Afanfeeder evaluates the self-similar retardationR = (θ − θ_apex)/(v − v_apex)and inverts it to a concentration, clamped to the fan’s physical extent[c_a, c_b]. The clamp is monotonicity-agnostic (it clamps inR-space, so it is correct for both R-decreasing isotherms — Freundlichn>1, Langmuir, Brooks-Corey, van Genuchten-Mualem — and the R-increasing Freundlichn<1mirror), and it is exactly what makes a fan feeder read the plateau concentration beyond the fan’s edge.Feeders are the uniform currency of the interaction calculus: every wave exposes its sides as feeders, event handlers form a successor from
(rear.left, front.right), and the reader evaluates the left feeder of the nearest downstream face.- sorption: NonlinearSorption | ConstantRetardation | None = None#
Sorption model used to invert
R(required for a fan;Nonefor a constant).
- far_boundary_free: bool = True#
Whether the fan’s far edge is a free plateau boundary (a collision line) rather than already terminated by another shock. Propagated through merges so a wave born onto a fan whose far end another wave owns does not re-expose a phantom boundary line.
- classmethod fan(v_apex, theta_apex, c_a, c_b, sorption, *, far_boundary_free=True)[source]#
Return a bounded self-similar fan feeder with apex
(v_apex, theta_apex)spanning[c_a, c_b].- Return type:
- value(v, theta)[source]#
Concentration this feeder supplies at
(v, θ)(clamped to the fan extent).- Return type:
- __init__(c_a, c_b, is_const, v_apex=0.0, theta_apex=0.0, sorption=None, far_boundary_free=True)#
- class gwtransport.fronttracking.waves.Wave(theta_start, v_start, *, is_active=True, theta_deactivation=inf)[source]#
Bases:
ABCAbstract base class for all wave types in front tracking.
All waves share common attributes and must implement methods for computing position and concentration. Waves can be active or inactive (deactivated waves are preserved for history but don’t participate in future interactions).
- theta_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 = Falseis the “current state” flag the solver uses for its event loop;theta_deactivationis the moment in θ-history when the wave stopped contributing. Retrospective queries (any θ in the past) must usewas_active_at(theta)instead ofis_activeso thatcompute_domain_massetc. 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_activereflects only the wave’s current (post-simulation) state, which is wrong forcompute_domain_massand similar at θ before a deactivation event.- Parameters:
theta (
float) – Cumulative flow at which to query historical activity [m³].- Returns:
Truefortheta_start <= theta < theta_deactivation. A wave constructed withis_active=Falseand no recordedtheta_deactivation(default+∞) is treated as never-active — e.g., synthetic test fixtures that want the wave excluded from dispatch entirely.- Return type:
- deactivate(theta)[source]#
Mark the wave inactive at cumulative flow
theta(collision handler API).Sets both
is_active = False(solver event-loop flag) andtheta_deactivation = theta(historical record for retrospectivewas_active_atqueries).
- 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_activecheck.)- Return type:
- abstractmethod concentration_left()[source]#
Concentration on the left (upstream) side of the wave.
- Return type:
- abstractmethod concentration_right()[source]#
Concentration on the right (downstream) side of the wave.
- Return type:
- abstractmethod concentration_at_point(v, theta)[source]#
Compute concentration at point (v, θ) if the wave controls it.
- __init__(theta_start, v_start, *, is_active=True, theta_deactivation=inf)#
- class gwtransport.fronttracking.waves.CharacteristicWave(theta_start, v_start, concentration, sorption, *, is_active=True, theta_deactivation=inf, c_ahead=0.0)[source]#
Bases:
WaveCharacteristic line along which concentration is constant.
In smooth regions, concentration travels at speed
1/R(C)in (V, θ) coordinates. Along each characteristic line, the concentration value is constant. This is the fundamental solution element for hyperbolic conservation laws.Examples
>>> sorption = FreundlichSorption( ... k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3 ... ) >>> char = CharacteristicWave( ... theta_start=0.0, v_start=0.0, concentration=5.0, sorption=sorption ... ) >>> speed = char.speed() >>> bool(np.isclose(char.position_at_theta(1000.0), speed * 1000.0)) True
- sorption: NonlinearSorption | ConstantRetardation#
Sorption model determining the speed.
- c_ahead: float = 0.0#
Concentration on the downstream (ahead) side — the state the contact advances into.
A contact separates the carried
concentration(behind, upstream) fromc_ahead(ahead, downstream, the pre-existing state). The solver sets it to the previous inlet value; it defaults to0(the virgin initial condition) for a lone contact. The reader sweep uses it as the contact’s downstream feeder.
- position_at_theta(theta)[source]#
Position at cumulative flow θ.
V(θ) = v_start + speed * (θ - θ_start).
- concentration_left()[source]#
Concentration on the left (upstream) side; equals the carried value.
- Return type:
- concentration_right()[source]#
Concentration on the right (downstream) side; equals the carried value.
- Return type:
- concentration_at_point(v, theta)[source]#
Return the carried concentration if the characteristic has reached
vby θ.
- __init__(theta_start, v_start, concentration, sorption, *, is_active=True, theta_deactivation=inf, c_ahead=0.0)#
- class gwtransport.fronttracking.waves.ShockWave(theta_start, v_start, c_left, c_right, sorption, *, is_active=True, theta_deactivation=inf)[source]#
Bases:
WaveShock 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/dθ = (C_R - C_L) / (C_T(C_R) - C_T(C_L))
Examples
>>> sorption = FreundlichSorption( ... k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3 ... ) >>> shock = ShockWave( ... theta_start=0.0, ... v_start=0.0, ... c_left=10.0, ... c_right=2.0, ... sorption=sorption, ... ) >>> shock.speed > 0 True >>> shock.satisfies_entropy() True
- sorption: NonlinearSorption | ConstantRetardation#
Sorption model.
- 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).
- satisfies_entropy()[source]#
Check Lax entropy condition in (V, θ):
λ_θ(C_L) ≥ s ≥ λ_θ(C_R).- Return type:
- __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:
WaveRarefaction (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)and1/R(C_tail).- Raises:
ValueError – If head speed <= tail speed (would be a compression, not a rarefaction).
Examples
>>> sorption = FreundlichSorption( ... k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3 ... ) >>> raref = RarefactionWave( ... theta_start=0.0, ... v_start=0.0, ... c_head=10.0, ... c_tail=2.0, ... sorption=sorption, ... ) >>> raref.head_speed() > raref.tail_speed() True >>> raref.contains_point(v=150.0, theta=2000.0) True
- 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:
- tail_speed()[source]#
Speed of rarefaction tail dV/dθ = 1/R(C_tail) (
+∞at a saturated state, R = 0).- Return type:
- position_at_theta(theta)[source]#
Head position (leading edge of rarefaction). Implements abstract Wave method.
- contains_point(v, theta)[source]#
Return
Trueif(v, θ)lies between the fan’s tail and head.- Return type:
- concentration_left()[source]#
Upstream concentration is the trailing-edge value c_tail.
- Return type:
- concentration_right()[source]#
Downstream concentration is the leading-edge value c_head.
- Return type:
- 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.Examples
>>> sorption = FreundlichSorption( ... k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3 ... ) >>> raref = RarefactionWave(0.0, 0.0, 10.0, 2.0, sorption) >>> c = raref.concentration_at_point(v=150.0, theta=2000.0) >>> c is not None True >>> 2.0 <= c <= 10.0 True
- __init__(theta_start, v_start, c_head, c_tail, sorption, *, is_active=True, theta_deactivation=inf)#
- class gwtransport.fronttracking.waves.DecayingShockWave(theta_start, v_start, c_decay_initial, c_fixed, c_fan_tail, decay_side, v_origin, theta_origin, sorption, *, is_active=True, theta_deactivation=inf, fan_boundary_consumed=False, theta_fan_boundary_consumed=inf)[source]#
Bases:
WaveMerging 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’sc_leftdecays from the rarefaction head value towardc_fan_tail(the unchanged downstream c_right isc_fixed).'right'(unfavorable tail-collision, n<1 mirrored): a trailing shock catches the rarefaction’s tail. After collision, the shock’sc_rightdecays from the rarefaction tail value towardc_fan_tail(the unchanged upstream c_left isc_fixed).
The wave is valid only while
c_decay ∈ (c_fan_tail, c_decay_initial]; oncec_decayreachesc_fan_tailthe fan is exhausted (see the solver’sDSW_FAN_EXHAUSTEDevent).Dispatch.
_c_decay_at_theta_localis 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 — anyNonlinearSorptionis valid. Withθ_local := θ − theta_originmeasured from the rarefaction apex,α := ρ_b · k_f / n_porfor Freundlich, andu_d := c_decay^(1/n):Freundlich,
c_fixed = 0(generaln > 0,n ≠ 1) — closed form: invariantθ_local · u_d^n = K · (n · u_d^(n-1) + α), positionV_s(θ) = v_origin + n · K / u_d(θ).Freundlich,
c_fixed > 0,n = 2(either decay orientation) — closed form: invariant(u_d - u_R)² · θ_local = K · (2 u_d + α)withu_R := c_fixed^(1/2), positionV_s(θ) = v_origin + 2 K · u_d(θ) / (u_d - u_R)². The root of the quadratic inu_dis selected by the decay orientation (u_d > u_Rshrinking,u_d < u_Rgrowing); both are exact (verified to ~1e-14 vs a DOP853 integration).Langmuir,
c_fixed = 0— closed form: invariantθ_local · c_d² = K · ((K_L + c_d)² + a)witha := ρ_b · s_max · K_L / n_por, positionV_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/aconstant), soR(c_d) = R(c0)·(θ_local/θ_local_coll)^{(a−1)/a}.Every other
(isotherm, c_fixed)combination (Freundlichc_fixed>0, n≠2, Langmuir/Brooks-Coreyc_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 speedS = (c − c_fixed)/(C_T(c) − C_T(c_fixed)), built once by composite quadrature and inverted forc_d(θ)by monotone spline interpolation.
Every path shares the fan-continuity identity
V_s = v_origin + θ_local / R(c_decay), whichposition_at_thetaandoutlet_crossing_thetause uniformly across all isotherms.The invariant constant
K(closed-form Freundlich/Langmuir only) is set in__post_init__from the collision IC(theta_start, c_decay_initial).theta_start/v_startare the collision coordinates; a fan-consistent construction hasv_start = v_origin + (theta_start − theta_origin)/R(c_decay_initial).See also
ShockWaveLinear-θ shock (no decaying side).
RarefactionWaveSelf-similar expansion fan.
- c_decay_initial: float#
Concentration on the decaying side at θ=theta_start [mass/volume]. Non-negative.
A fully-drained collision value of
0is floored to the shared dry-soil singularity floor_C_MINso the retardation and secant-speed evaluations stay finite.
- c_fan_tail: float#
Concentration at the fan’s far boundary [mass/volume]; bounds the decay. Non-negative.
The wave is valid only while
c_decay ∈ (c_fan_tail, c_decay_initial]; atc_fan_tailthe fan is exhausted.
- sorption: NonlinearSorption#
Sorption model (any concentration-dependent isotherm).
- K: float#
Invariant constant set in
__post_init__(closed-form Freundlichc_fixed=0/n≈2and Langmuirc_fixed=0cases;nanfor every numerical case).
- fan_boundary_consumed: bool = False#
Whether the fan’s far-boundary line is owned from birth (a fan-entry successor rides a fan another wave already terminates downstream, so its boundary is never a free face).
- theta_fan_boundary_consumed: float = inf#
Cumulative flow at which a free boundary line was later consumed by a wave entering the fan. Retrospective reader/event queries treat the boundary as free only for
θ < theta_fan_boundary_consumed(historical truth, mirroringwas_active_at).
- __post_init__()[source]#
Validate inputs and compute the closed-form invariant K when applicable.
- Return type:
- c_decay_at_theta(theta)[source]#
Concentration on the decaying side at cumulative flow θ.
Returns
Noneforθ < theta_startor when the wave is inactive; otherwise delegates to the single per-isotherm dispatch in_c_decay_at_theta_local.
- 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. ReturnsNoneforθ < theta_startor when inactive.
- theta_at_fan_exhaustion()[source]#
Cumulative flow θ at which
c_decayreachesc_fan_tail.c_decay(θ)is strictly monotone fromc_decay_initialtowardc_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). ReturnsNonewhenc_fan_tailis not strictly betweenc_fixedandc_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.
- __init__(theta_start, v_start, c_decay_initial, c_fixed, c_fan_tail, decay_side, v_origin, theta_origin, sorption, *, is_active=True, theta_deactivation=inf, fan_boundary_consumed=False, theta_fan_boundary_consumed=inf)#
- outlet_crossing_theta(v_outlet)[source]#
Cumulative flow at which
V_s = v_outlet.Returns
Noneif 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_localitself uses the closed form, so the same conditions are mirrored here); every other case inverts the monotoneV_s(θ)viabrentq.
- concentration_left()[source]#
Concentration on the left (upstream) side at θ=theta_start.
For
decay_side='left'returns the decaying c at the collision moment; fordecay_side='right'returns the fixed side.- Return type:
- concentration_right()[source]#
Concentration on the right (downstream) side at θ=theta_start.
For
decay_side='right'returns the decaying c at the collision moment; fordecay_side='left'returns the fixed side.- Return type:
- concentration_at_point(v, theta)[source]#
Concentration at
(v, θ)if controlled by this decaying shock.Three regions:
v == V_s(θ)(within FP): average of decay-side and fixed-side c.v > V_s(θ)(downstream): fixed-side c ifdecay_side='left'; decay-side c at θ ifdecay_side='right'.v < V_s(θ)(upstream, inside the fan): the fan’s self-similar concentrationR(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 thec_fan_tailboundary (the fan’s far edge) — returnsNone.
Returns
Noneforθ < theta_startor inactive waves.
- class gwtransport.fronttracking.waves.DoubleFanShockWave(theta_start, v_start, left_feeder, right_feeder, sorption, *, is_active=True, theta_deactivation=inf, left_boundary_consumed=False, right_boundary_consumed=False, theta_left_boundary_consumed=inf, theta_right_boundary_consumed=inf)[source]#
Bases:
WaveShock fed by a self-similar fan on BOTH sides (a doubly-fed front).
Formed when a fan boundary (rarefaction head/tail, or another fan-fed shock) catches a
DecayingShockWavewhose surviving side is itself a fan — the merged front then has a fan feeder on each side. The shock trajectory solves\[\frac{dV}{d\theta} = S\bigl(c_L(V,\theta),\, c_R(V,\theta)\bigr), \qquad R(c_i) = \frac{\theta - \theta_{{\rm apex},i}}{V - v_{{\rm apex},i}},\]with
Sthe Rankine-Hugoniot secant and eachc_ithe self-similar value of its fan.Closed form (Freundlich ``n = 2``, shared apex position ``v_L = v_R = v_o``). With
u_i = \sqrt{c_i} = A V'/(2(\tau_i - V'))(V' = V - v_o,\tau_i = \theta - \theta_{{\rm apex},i},A = \rho_b k_f/n_{por}), the productK = u_L u_Ris a first integral of the ODE, and the trajectory is the physical root of\[(A^2 - 4K)\,V'^2 + 4K(\tau_L + \tau_R)\,V' - 4K\,\tau_L\,\tau_R = 0, \qquad 0 < V' < \min(\tau_L, \tau_R).\]Every fan born at the inlet shares
v_o = 0, so inlet-driven inputs use the closed form. General fallback (distinct apex positions, or a non-n=2isotherm): the ODE is integrated once by fixed-step RK4 (\Delta\theta = \text{fan age}/DFSW_RK_SUBSTEPS, speed-independent since the fans vary on the scale of their age) into a cached monotone spline. Both paths answer position, side concentrations and outlet crossing uniformly (side exhaustion is detected externally, as the shock face crossing its own fan boundary).See also
DecayingShockWaveOne fan side; the doubly-fed front degrades to this on side exhaustion.
FeederThe bounded-fan side descriptor this wave carries.
- __init__(theta_start, v_start, left_feeder, right_feeder, sorption, *, is_active=True, theta_deactivation=inf, left_boundary_consumed=False, right_boundary_consumed=False, theta_left_boundary_consumed=inf, theta_right_boundary_consumed=inf)#
- sorption: NonlinearSorption#
Sorption model (concentration-dependent).
- left_boundary_consumed: bool = False#
Whether the left fan’s far-boundary line is owned from birth (not a free collision face).
- right_boundary_consumed: bool = False#
Whether the right fan’s far-boundary line is owned from birth (not a free collision face).
- theta_left_boundary_consumed: float = inf#
Cumulative flow at which a free left boundary was later consumed (historical, see DSW).
- theta_right_boundary_consumed: float = inf#
Cumulative flow at which a free right boundary was later consumed (historical, see DSW).
- __post_init__()[source]#
Classify closed-form vs numerical and set the first integral
K.- Return type:
- outlet_crossing_theta(v_outlet)[source]#
Cumulative flow at which
V_s = v_outlet(monotone, inverted by brentq).θ_localis measured from the older of the two fan apexes, so the shared bracket-then-brentq inversion sees a positive seed.
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.
meanis the total expected value,stdis the spread (invariant under shift), andlocis the lower bound of support. Constraint:0 <= loc < mean.(alpha, beta, loc) — scipy-style.
alphais shape,betais scale, andlocis the lower bound of support. Constraint:alpha > 0,beta > 0,loc >= 0.
Conversion formulas (with constraint mean > loc):
alpha = ((mean - loc) / std) ** 2 beta = std ** 2 / (mean - loc) mean = alpha * beta + loc std = sqrt(alpha) * beta
When loc == 0 the three-parameter model reduces to the standard two-parameter
gamma distribution.
Streamtube discretization by bins() is always into bins of equal probability mass.
Available functions:
parse_parameters()- Resolve either(mean, std)or(alpha, beta), together with an optionalloc, into the validated(alpha, beta, loc)triple the rest of the package works with. Exactly one of the two pairs must be supplied; positivity,0 <= loc < meanand finiteness are enforced, otherwiseValueErroris raised.mean_std_loc_to_alpha_beta()- Convert the physically intuitive(mean, std, loc)parameters to gamma shape and scale from the excess-over-locmoments,alpha = ((mean - loc) / std) ** 2andbeta = std ** 2 / (mean - loc).bins()- Split the (shifted) gamma distribution at then_bins + 1uniform quantile edges, so every bin (streamtube) carries probability mass1 / n_bins. Returns a dict of arrays: the bin edges (lower_bound,upper_bound,edges; the first lower bound islocand the last upper bound is infinite), the per-bin conditional mean pore volume (expected_values) that serves as the streamtube pore volume in transport calculations, and the per-binprobability_mass.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.gamma.parse_parameters(*, mean=None, std=None, loc=0.0, alpha=None, beta=None)[source]#
Parse parameters for gamma distribution.
Either
(mean, std)or(alpha, beta)must be provided.locis 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 thanloc.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.stdis invariant under thelocshift.loc (
float, default:0.0) – Location (horizontal shift) of the gamma distribution; the lower bound of support. Must satisfyloc >= 0and, whenmeanis supplied,loc < mean. Default is0.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:
- Returns:
- Raises:
ValueError – If neither
(mean, std)nor(alpha, beta)is provided, if both pairs are provided, if only one of a pair is provided, ifalphaorbetaare not positive, iflocis negative, if the resolvedalpha,beta, orlocis not finite, or ifmean <= 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-locmoments:mean_excess = mean - loc,std_excess = std.- Parameters:
mean (
float) – Mean of the gamma distribution. Must be strictly greater thanloc.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.stdis invariant under thelocshift.loc (
float, default:0.0) – Location (horizontal shift) of the gamma distribution. Must satisfy0 <= loc < mean. Default is0.0.
- Return type:
- Returns:
- Raises:
ValueError – If
stdis not positive, iflocis negative, or ifmean <= loc.
See also
parse_parametersParse and validate gamma distribution parameters.
Examples
>>> from gwtransport.gamma import mean_std_loc_to_alpha_beta >>> mean_pore_volume = 30000.0 # m³ >>> std_pore_volume = 8100.0 # m³ >>> alpha, beta = mean_std_loc_to_alpha_beta( ... mean=mean_pore_volume, std=std_pore_volume ... ) >>> print(f"Shape parameter (alpha): {alpha:.2f}") Shape parameter (alpha): 13.72 >>> print(f"Scale parameter (beta): {beta:.2f}") Scale parameter (beta): 2187.00
With a 5000 m³ minimum pore volume:
>>> alpha, beta = mean_std_loc_to_alpha_beta(mean=30000.0, std=8100.0, loc=5000.0) >>> print(f"Shape parameter (alpha): {alpha:.2f}") Shape parameter (alpha): 9.53 >>> print(f"Scale parameter (beta): {beta:.2f}") Scale parameter (beta): 2624.40
- gwtransport.gamma.bins(*, mean=None, std=None, loc=0.0, alpha=None, beta=None, n_bins=100)[source]#
Divide a (shifted) gamma distribution into equal-probability-mass bins and compute bin properties.
The distribution is split at the
n_bins + 1uniform quantile edges, so every bin (streamtube) carries probability mass1 / n_bins.- Parameters:
mean (
float|None, default:None) – Mean of the gamma distribution. Must be strictly greater thanloc.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 satisfy0 <= loc < mean(orloc >= 0when using alpha/beta). Default is0.0.alpha (
float|None, default:None) – Shape parameter of gamma distribution (must be > 0).beta (
float|None, default:None) – Scale parameter of gamma distribution (must be > 0).n_bins (
int, default:100) – Number of bins to divide the gamma distribution (must be >= 2). Default is 100.
- Returns:
Dictionary with keys of type str and values of type numpy.ndarray:
lower_bound: lower bounds of bins (first one equalsloc)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 underlocshift).
- Return type:
- Raises:
ValueError – If
n_binsis not greater than 1, or if parameter validation inparse_parameters()fails. Also raised for numerically-degenerate requests that would otherwise return silently-wrong structure: analphaso large thatalpha + 1 == alphain float64 relative to the1 / n_binsquantile gap (the distribution is numerically a point mass), or a bin whose expected value underflows toloc.
See also
mean_std_loc_to_alpha_betaConvert mean/std/loc to alpha/beta parameters.
gwtransport.advection.gamma_infiltration_to_extractionUse 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
stdrepresents (macrodispersion vs total spreading).- 8. Gamma Distribution Adequacy
When gamma distribution is adequate.
Examples
Create equal-mass bins for a gamma distribution:
>>> from gwtransport.gamma import bins >>> result = bins(mean=30000.0, std=8100.0, n_bins=5) >>> print(f"Number of bins: {len(result['probability_mass'])}") Number of bins: 5
With a location parameter representing a minimum pore volume:
>>> result = bins(mean=30000.0, std=8100.0, loc=5000.0, n_bins=5) >>> float(result["edges"][0]) 5000.0
logremoval#
Log Removal Calculations for First-Order Decay Processes.
This module provides utilities to calculate log removal values from first-order decay processes, including pathogen inactivation and radioactive decay. The module supports basic log removal calculations and parallel flow arrangements where multiple flow paths operate simultaneously.
First-Order Decay Model#
The log removal from any first-order decay process is:
Log Removal = log10_decay_rate * residence_time
where log10_decay_rate has units [log10/day] and residence_time has units [days].
This is equivalent to exponential decay C_out/C_in = 10^(-mu * t), where mu is the
log10 decay rate and t is residence time. The natural-log decay rate constant lambda [1/day]
is related to mu by lambda = mu * ln(10).
This model applies to any process that follows first-order kinetics:
Pathogen inactivation: viruses, bacteria, and protozoa lose infectivity over time
Radioactive decay: isotopes used for groundwater dating (tritium, CFC, SF6)
Chemical degradation: first-order breakdown of contaminants
Pathogen Removal in Bank Filtration#
For pathogen removal during soil passage, total removal consists of two distinct mechanisms (Schijven and Hassanizadeh, 2000):
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.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 equivalentrt_mean/rt_std/rt_loc) parameterize the gamma distribution of the residence time (used bygamma_pdf(),gamma_cdf(),gamma_mean()).apv_alpha/apv_beta/apv_loc(or the equivalentapv_mean/apv_std/apv_loc) parameterize the gamma distribution of the aquifer pore volume (used bygamma_find_flow_for_target_mean()).
These prefixes are intentional and load-bearing: residence time and pore volume are different
quantities, so a bare alpha / beta / loc would be ambiguous in this module. Both the
shape/scale and the mean/std pairs are validated through gwtransport.gamma.parse_parameters(),
so invalid parameters (e.g. a negative shape) raise ValueError rather than silently returning
an unphysical result.
Available functions:
residence_time_to_log_removal()- Multiply residence times [days] by a log10 decay rate [log10/day] to get log removal, returning an array of the same shape as the input. Negative residence times or a negative rate give negative log removal; the caller interprets the sign.decay_rate_to_log10_decay_rate()- Convert a natural-log first-order decay rate constant lambda [1/day] to a log10 decay rate mu [log10/day] viamu = lambda / ln(10).log10_decay_rate_to_decay_rate()- Convert a log10 decay rate mu [log10/day] to a natural-log first-order decay rate constant lambda [1/day] vialambda = mu * ln(10).parallel_mean()- Combine the log removals of parallel flow paths into the single log removal of their blended outflow,-log10(sum(F_i * 10^(-LR_i))). Flow fractions default to an equal split and must sum to 1.0 along the reduction axis;axis=Nonereduces over the flattened input and returns a scalar.gamma_pdf()- Probability density of log removal when the residence time is (shifted) gamma-distributed: a gamma density with shapert_alpha, scalelog10_decay_rate * rt_beta, and locationlog10_decay_rate * rt_loc, evaluated at the requested log removal values.gamma_cdf()- Cumulative distribution of log removal for the same shifted-gamma residence time, i.e. the fraction of the extracted water whose log removal does not exceedr.gamma_mean()- Effective (flow-weighted, concentration-mixing) mean log removal for gamma-distributed residence time,log10_decay_rate * rt_loc + rt_alpha * log10(1 + rt_beta * log10_decay_rate * ln(10)). This is the continuous counterpart ofparallel_mean()and lies below the arithmetic meanlog10_decay_rate * (rt_alpha * rt_beta + rt_loc), because short-residence-time paths dominate the mixed outflow.gamma_find_flow_for_target_mean()- Invertgamma_mean()for the flow rate that attains a target effective mean log removal, given a gamma-distributed aquifer pore volume (hence theapv_prefix; dividing the pore volume by the flow gives the residence time). Closed form whenapv_loc == 0, otherwise a bracketedscipy.optimize.brentq()root in1 / flow. Requires a positivetarget_meanand a positivelog10_decay_rate.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.logremoval.residence_time_to_log_removal(*, residence_times, log10_decay_rate)[source]#
Compute log removal given residence times and a log10 decay rate.
Log Removal = log10_decay_rate * residence_time, equivalent to the exponential decayC_out/C_in = 10^(-log10_decay_rate * residence_time).- Parameters:
residence_times (
ArrayLike) – Residence times (days), of any shape. Negative values produce negative log removal (mathematical amplification); the caller interprets the sign.log10_decay_rate (
float) – Log10 decay rate mu (log10/day). Negative values correspond to first-order production rather than decay.
- Returns:
log_removals – Log removal values, same shape as
residence_times. Log 1 is a 90% reduction, log 2 a 99% reduction, log 3 a 99.9% reduction.- Return type:
See also
decay_rate_to_log10_decay_rateConvert natural-log decay rate to log10 decay rate
gamma_meanEffective mean log removal for gamma-distributed residence times
gwtransport.residence_time.fullCompute residence times from flow and pore volume
- Residence Time
Time in aquifer determines pathogen contact time
Examples
>>> from gwtransport.logremoval import residence_time_to_log_removal >>> residence_time_to_log_removal( ... residence_times=[10.0, 20.0, 50.0], log10_decay_rate=0.2 ... ) array([ 2., 4., 10.])
- gwtransport.logremoval.decay_rate_to_log10_decay_rate(decay_rate)[source]#
Convert a natural-log decay rate constant to a log10 decay rate: mu = lambda / ln(10).
- Parameters:
decay_rate (
float) – Natural-log first-order decay rate constant lambda (1/day), e.g.np.log(2) / half_life.- Returns:
log10_decay_rate – Log10 decay rate mu (log10/day).
- Return type:
See also
log10_decay_rate_to_decay_rateInverse conversion
Examples
>>> import numpy as np >>> from gwtransport.logremoval import decay_rate_to_log10_decay_rate >>> decay_rate_to_log10_decay_rate(np.log(2) / 30) np.float64(0.01003...)
- gwtransport.logremoval.log10_decay_rate_to_decay_rate(log10_decay_rate)[source]#
Convert a log10 decay rate to a natural-log decay rate constant: lambda = mu * ln(10).
- Parameters:
log10_decay_rate (
float) – Log10 decay rate mu (log10/day).- Returns:
decay_rate – Natural-log first-order decay rate constant lambda (1/day).
- Return type:
See also
decay_rate_to_log10_decay_rateInverse 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 waynp.mean/np.sumtreataxis=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:
- Raises:
ValueError – If
flow_fractionsdoes not sum to 1.0 along the specified axis.
See also
residence_time_to_log_removalCompute 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_locwhereT0 ~ Gamma(rt_alpha, rt_beta), the log removalR = mu * Tfollows a shifted gamma distribution with shapert_alpha, scalemu * rt_beta, and locationmu * rt_loc.The residence-time distribution is specified with either
(rt_alpha, rt_beta)or(rt_mean, rt_std)(optionally shifted byrt_loc); both are routed throughgwtransport.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 is0.0.rt_mean (
float|None, default:None) – Mean residence time (days). Alternative tort_alpha; supply withrt_std. Must be strictly greater thanrt_loc.rt_std (
float|None, default:None) – Standard deviation of the residence time (days). Alternative tort_beta; supply withrt_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:
- Raises:
ValueError – If parameter validation in
gwtransport.gamma.parse_parameters()fails (e.g.rt_locnegative, non-positive shape/scale, or neither/both parameter pairs supplied).
See also
gamma_cdfCumulative distribution function of log removal
gamma_meanMean 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_locwhereT0 ~ Gamma(rt_alpha, rt_beta), the CDF isP(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 locationmu * rt_loc.The residence-time distribution is specified with either
(rt_alpha, rt_beta)or(rt_mean, rt_std)(optionally shifted byrt_loc); both are routed throughgwtransport.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 is0.0.rt_mean (
float|None, default:None) – Mean residence time (days). Alternative tort_alpha; supply withrt_std. Must be strictly greater thanrt_loc.rt_std (
float|None, default:None) – Standard deviation of the residence time (days). Alternative tort_beta; supply withrt_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:
- Raises:
ValueError – If parameter validation in
gwtransport.gamma.parse_parameters()fails (e.g.rt_locnegative, non-positive shape/scale, or neither/both parameter pairs supplied).
See also
gamma_pdfProbability density function of log removal
gamma_meanMean 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_locwithT0 ~ 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_locterm shifts the whole log-removal distribution by a constantmu * rt_loc; the alpha/beta term is unchanged. This is always less than the arithmetic meanmu * (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 byrt_loc); both are routed throughgwtransport.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 is0.0.rt_mean (
float|None, default:None) – Mean residence time (days). Alternative tort_alpha; supply withrt_std. Must be strictly greater thanrt_loc.rt_std (
float|None, default:None) – Standard deviation of the residence time (days). Alternative tort_beta; supply withrt_mean. Must be positive.log10_decay_rate (
float) – Log10 decay rate mu (log10/day).
- Returns:
mean – Effective (parallel) mean log removal value.
- Return type:
- Raises:
ValueError – If parameter validation in
gwtransport.gamma.parse_parameters()fails (e.g.rt_locnegative, non-positive shape/scale, or neither/both parameter pairs supplied).
See also
gamma_find_flow_for_target_meanFind flow for target mean log removal
parallel_meanDiscrete version of this calculation
gamma_pdfPDF of the log removal distribution
gamma_cdfCDF 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 flowQis a shifted gamma with shapeapv_alpha, scaleapv_beta/Q, and locationapv_loc/Q. Fromgamma_mean():LR_eff = mu * apv_loc / Q + apv_alpha * log10(1 + (apv_beta/Q) * mu * ln(10))
For
apv_loc == 0this is closed-form:Q = apv_beta * mu * ln(10) / (10^(target_mean / apv_alpha) - 1)
For
apv_loc > 0the equation is transcendental and solved numerically withscipy.optimize.brentq()by bracketing the root in1/Q.The pore-volume distribution is specified with either
(apv_alpha, apv_beta)or(apv_mean, apv_std)(optionally shifted byapv_loc); both are routed throughgwtransport.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 is0.0.apv_mean (
float|None, default:None) – Mean aquifer pore volume. Alternative toapv_alpha; supply withapv_std. Must be strictly greater thanapv_loc.apv_std (
float|None, default:None) – Standard deviation of the aquifer pore volume. Alternative toapv_beta; supply withapv_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:
- Raises:
ValueError – If
target_meanis not positive, iflog10_decay_rateis not positive (no decay can never produce a positive target log removal), or if parameter validation ingwtransport.gamma.parse_parameters()fails (e.g.apv_locnegative, non-positive shape/scale, or neither/both parameter pairs supplied).
See also
gamma_meanCompute effective mean log removal for given parameters
percolation#
Percolation through thick unsaturated zones via the Kinematic Wave method.
This module solves gravity-driven percolation between the bottom of the root zone and the water table by exact front tracking, following the Kinematic-Wave method described in Olsthoorn (2026, Stromingen 32(1)).
Forward-only. Inverse mapping water_table_to_root_zone is not
provided. The KW unsaturated-zone problem is fundamentally one-way under
gravity: multiple q_root_zone(t) series produce indistinguishable
q_water_table(t) after the column’s intrinsic low-pass response,
making the inverse ill-posed. Users wanting an inverse should formulate
it as a regularised inverse problem outside this package.
Cumulative pore-volume coordinate. The position axis is cumulative
pore volume per unit cross-sectional area (units of length), not
geometric depth. For a soil of constant porosity n_p ≡ θ_s and
water-table depth z_wt, the conversion is V_out = θ_s · z_wt.
The docstring of root_zone_to_water_table_kinematic_wave()
spells out the recovery rule and the layered-porosity generalisation.
The full Kinematic-Wave derivation and the constitutive-curve references
are documented on root_zone_to_water_table_kinematic_wave().
Available functions:
root_zone_to_water_table_kinematic_wave()- Solve the Kinematic-Wave conservation law∂θ_m/∂t + ∂K(θ_m)/∂z = 0exactly by front tracking, returning the bin-averaged percolation flux at the water table onq_water_table_tedgestogether with the per-column solver structures (waves, events, first-arrival time, counts, and the tracker state that maps cumulative effective time back to wall-clock time). The flux is in the same units asq_root_zoneand is averaged over the columns listed incumulative_pore_volumes_outlet, whose entries are cumulative pore volume per unit area rather than geometric depth. The constitutive curve is Brooks-Corey whenbrooks_corey_lambdais given and van Genuchten-Mualem whenvan_genuchten_nis given; exactly one of the two is required. The optionalk_scalingapplies a time-only multiplicative factorf(t)to the wholeK(θ)curve (e.g. temperature-corrected viscosity) and then requiresq_water_table_tedgesto equaltedges, since the back-transformq_water_table = f · coutis exact only on the input grid.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.percolation.root_zone_to_water_table_kinematic_wave(*, q_root_zone, tedges, q_water_table_tedges, cumulative_pore_volumes_outlet, theta_r, theta_s, k_s, brooks_corey_lambda=None, van_genuchten_n=None, mualem_l=0.5, k_scaling=None, max_iterations=10000)[source]#
Percolation flux at the water table by exact Kinematic-Wave front tracking.
Solves the nonlinear scalar conservation law
\[\begin{split}\\frac{\\partial \\theta_m}{\\partial t} + \\frac{\\partial K(\\theta_m)}{\\partial z} = 0\end{split}\]exactly via
gwtransport.fronttracking.solver.FrontTracker, using either a Brooks-Corey or a van Genuchten-Mualem constitutive curve. Implements the Kinematic-Wave method (see [3] for the general theory) described in Olsthoorn (2026) [1]. The capillary term∂ψ/∂zis dropped (gravity drainage only); real fronts are slightly smoothed by capillarity, so if smoothing matters use the Munsflow-style approach ingwtransport.diffusioninstead.- 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_smust hold (withf = k_scalingor 1) for the inlet inversion to be well-defined; the validator raisesValueErrorotherwise.tedges (
DatetimeIndex) – Time bin edges of the input series. Lengthn + 1fornbins.q_water_table_tedges (
DatetimeIndex) – Output time bin edges. Free monotone index whenk_scalingis None; must equaltedgeswhenk_scalingis set (the back-transformq_wt = f · coutis 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 depthz_wt, this isθ_s · z_wt. For layered porosity,∫₀^{z_wt} n_p(z') dz'. The geometric depth is recovered asz_wt = V_out / θ_s(uniform case). Array-like to support a distribution of column lengths in parallel (analogous togwtransport.advection.gamma_infiltration_to_extraction()); each entry must be positive.theta_r (
float) – Residual volumetric moisture content [-]. Must satisfy0 <= theta_r < theta_s.theta_s (
float) – Saturated volumetric moisture content [-]. Equal to the porosity for typical soils. Must satisfytheta_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 withvan_genuchten_n. Tabulated soil values are available in the Staringreeks [2].van_genuchten_n (
float|None, default:None) – Van Genuchten shape parametern_vG > 1. Set to use the van Genuchten-Mualem branch (numerical inversion via brentq). Mutually exclusive withbrooks_corey_lambda.mualem_l (
float, default:0.5) – Mualem pore-connectivity parameterL. Default 0.5 (standard Mualem). Honored only whenvan_genuchten_nis set.k_scaling (
ArrayLike|None, default:None) –Dimensionless time-only multiplicative factor
f(t)applied to the entireK(θ)curve:K(θ, t) = f(t) · K_reference(θ). Lengthn. Default None meansf ≡ 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 viscosityf(t) = μ_ref / μ(T(t));μvaries ~60% between 5 °C and 25 °C, so seasonal swings of 30-50% in effectiveK_sare realistic for shallow soils.max_iterations (
int, default:10000) – Maximum number of solver events. Default 10000.
- Return type:
- Returns:
q_water_table (
ndarray) – Bin-averaged percolation flux at the water table [same units asq_root_zone], lengthlen(q_water_table_tedges) - 1, averaged across the columns incumulative_pore_volumes_outlet.structures (
listofdict) – Per-column simulation structures (same schema asgwtransport.advection.infiltration_to_extraction_nonlinear_sorption(), withaquifer_pore_volumerenamed tocumulative_pore_volume_outlet):waves— all wave objects.events— event history; each record has"theta"(cumulative effective time) and"type"keys. Translatethetato wall-clock time viatracker_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— completeFrontTrackerStatefor the column (usestate.t_at_thetato translateθ → t).cumulative_pore_volume_outlet— the V_out for this column.
- Raises:
ValueError – If inputs are inconsistent (wrong lengths, NaN, negative
q_root_zoneork_scaling, non-finite or non-positivecumulative_pore_volumes_outletork_s), if neither or both sorption-parameter groups are supplied, ifq_root_zone > f(t) * k_sat any bin (saturation/ponding limit), or ifq_water_table_tedgesdoes not equaltedgeswhilek_scalingis provided.- Warns:
UserWarning – If output θ-bins extend beyond the inlet θ-window (i.e. the drying tail of
q_root_zonereaches 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_sorptionSolute transport with nonlinear sorption (analogous front-tracking algorithm in the saturated-zone domain).
gwtransport.diffusionMunsflow-style linearised advection-diffusion (complementary; smoothed fronts).
gwtransport.fronttracking.math.BrooksCoreyConductivityBrooks-Corey constitutive class.
gwtransport.fronttracking.math.VanGenuchtenMualemConductivityvan 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 withn_p = theta_s,V = theta_s * z; depth is recovered asz = V / theta_s. The solver-side identificationflow = theta_s * f(t)(withfthe optional K-scaling) follows from the chain ruled/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 ascin_solver(t) = q_root_zone(t) / f(t)and recovered at the outlet asq_water_table(t) = f(t) * cout(t). The requirementcin_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 togwtransport.diffusionin 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 integralG(u) = C_T(c) * u - kappa * c, and for the temporal fan integralF(theta) = c * (theta - theta_origin) - Delta_v * C_T(c). For Brooks-Corey bothcandC_Tat the endpoints are closed form; for van Genuchten-Mualem they require a singlebrentqcall per endpoint (transcendentalK(theta)). The Burdine variant (mualem_l = 0) admits a closed-form inverse and is fully free of root-finding.References
Examples
Reproduce a 10-year step-response for the article’s soil O05 (coarse sand, Brooks-Corey):
import numpy as np import pandas as pd from gwtransport.percolation import ( root_zone_to_water_table_kinematic_wave, ) tedges = pd.date_range("1995-01-01", "2005-01-01", freq="D") q_root = np.full(len(tedges) - 1, 1e-3) # 1 mm/day q_wt, structures = root_zone_to_water_table_kinematic_wave( q_root_zone=q_root, tedges=tedges, q_water_table_tedges=tedges, cumulative_pore_volumes_outlet=np.array([0.337 * 20.0]), theta_r=0.01, theta_s=0.337, k_s=0.174, brooks_corey_lambda=0.25, )
With time-varying water viscosity:
days = ((tedges[:-1] - tedges[0]) / pd.Timedelta(days=1)).values T = 10.0 + 5.0 * np.sin(2 * np.pi * days / 365.25) # °C mu_ref, dmu_dT = 1.31, -0.027 # mPa·s, linear around 10 °C mu = mu_ref + dmu_dT * (T - 10.0) k_scaling = mu_ref / mu q_wt_visc, _ = root_zone_to_water_table_kinematic_wave( q_root_zone=q_root, tedges=tedges, q_water_table_tedges=tedges, cumulative_pore_volumes_outlet=np.array([0.337 * 20.0]), theta_r=0.01, theta_s=0.337, k_s=0.174, brooks_corey_lambda=0.25, k_scaling=k_scaling, )
radial_asr#
Exact radial advection-dispersion transport for a single well (push-pull / ASR).
Water is injected in an infinite aquifer at a single fully-penetrating well and later recovered at the same well under a signed flow schedule (push-pull / ASR). Transport is radial advection with microdispersion, molecular diffusion, and linear sorption; the spread of velocities across the well screen provides macrodispersion. Forward and backward modeling are supported.
Computes the extracted flux concentration cout at a single fully-penetrating well driven by an
arbitrary signed flow schedule (positive = injection, negative = extraction, zero = rest) and an
arbitrary injected concentration cin. The physics is the exact radial advection-dispersion:
volume coordinate V(r) = pi b n (r^2 - r_w^2), Scheidegger velocity-dependent dispersion
D = alpha_L |u| + D_m (microdispersion alpha_L |u| plus molecular diffusion D_m),
Kreft-Zuber flux boundary conditions, and the exact per-phase kernels (Airy for D_m = 0; the
log-derivative Riccati ODE for D_m > 0). Nothing is reduced to a Gaussian; the exact
non-Gaussian breakthrough (with the correct skewness) is carried.
The forward map is grid-free end to end – no PDE is discretized, so none of the finite-volume
artefacts appear. Every signed-flow schedule (single cycle, multi-cycle ASR, intervening rests) is
composed by the propagator-matrix engine (gwtransport._radial_asr_reuse), which carries the
resident field across each flow reversal with the exact per-phase interior Green’s functions
(Airy / Riccati / Bessel); cycles are expressed through the flow sign pattern, not an argument.
Molecular diffusion during pumping (the D_m > 0 Whittaker kernel) is evaluated through the
log-derivative Riccati ODE – exact to the de Hoog inversion floor at any A_0/D_m, with no
special-function precision cap, and reducing continuously to the Airy branch as D_m -> 0. During
a rest (Q = 0) advection and microdispersion vanish and molecular diffusion acts alone on the
wall-clock clock; it is carried exactly by the order-0 modified Bessel pure-diffusion kernel, the
dominant mixing for seasonal storage / ATES. The only numerical steps are Gauss-Legendre quadrature
and de Hoog Laplace inversion of exact special-function kernels. An independent finite-volume solve
of the same PDE (tests/src/_radial_asr_fv_oracle.py) is used only as a test oracle.
The reported cout is the flow-weighted average over each output bin – defined on extraction bins
(flow < 0) and NaN on injection / rest bins (nothing is recovered there).
Macrodispersion within the well screen#
The well screen has a known height; macrodispersion is the spread of arrival times caused by
velocity heterogeneity across the screen. It is modelled as parallel streamtubes (pore_heights):
each streamtube is an independent radial cell carrying the full flow, with an effective pore height
that sets its velocity, and the output is the weight-averaged breakthrough. A streamtube of effective
height b has velocity proportional to 1/b (its pore volume to radius r is
pi b n (r^2 - r_w^2)), so smaller b means faster breakthrough.
gamma_infiltration_to_extraction() builds this ensemble from a gamma distribution of the layer
velocity within the fixed screen height (see that function); the mean velocity is set by the screen
height and the spread by a velocity coefficient of variation. The spread is a within-screen velocity
distribution – velocity heterogeneity across the well screen – not an aquifer pore-volume distribution.
Regional background flow (drift)#
With a steady uniform regional Darcy flux regional_flux (U, drift seepage v_d = U/n) the well
field is superimposed on a regional gradient, so the stored bubble drifts and recovery degrades. The
radial symmetry is broken and the transport is solved by an azimuthal Fourier-mode expansion
c(r, theta) = sum_m c_m(r) e^{i m theta} (m = 0 is the radial engine; drift couples m to
m +- 1), composed through the same per-phase interior Green’s functions
(gwtransport._radial_asr_drift_kernels). regional_flux = 0 (default) dispatches to the radial path
bit-for-bit. The engine is for the slow-drift envelope – the plume (including its rest-phase drift
displacement) must stay well inside the stagnation radius r_s = |A_0|/|v_d| (else a ValueError).
Rest phases (flow == 0) are propagated by the exact free-space drift kernel (translate + anisotropic
spread). The drift-induced recovery loss is validated against an independent 2-D finite-volume oracle.
Available functions:
infiltration_to_extraction()- Forward transport: compute the extracted flux concentrationcoutfrom an injected concentrationcin, a signed flow schedule (> 0injection,< 0extraction,0rest) and the well geometry. Grid-free – the exact per-phase kernels are composed across every flow reversal, and the phases (and hence the number of push-pull / ASR cycles) follow from the flow sign pattern rather than from an argument.coutis the flow-weighted average over each output bin, in the units ofcin, defined on the extraction bins andNaNon injection and rest bins;cout_tedgesmust equaltedges.pore_heightsis a scalar (one homogeneous screen) or an array of streamtube heights – each streamtube carries the full flow, and the breakthroughs are averaged withweights(equal by default). Sorption is linear throughretardation_factor;backgroundis the ambient aquifer concentration, so the deviationcin - backgroundis transported andbackgroundadded back. Nonzeroregional_fluxengages the azimuthal-mode drift engine within its slow-drift envelope, and raisesValueErrorwhen the plume leaves that envelope.extraction_to_infiltration()- Reverse operation: recover the injected concentration from measured extracted concentrations under the same flow schedule and geometry. The forward operator is assembled column-by-column from unit injection-pulse responses and inverted by Tikhonov least squares (regularization_strength). Returns one value per bin: the recoveredcinon injection bins andNaNon extraction and rest bins.NaNincouton an extraction bin raisesValueError; structuralNaNon injection and rest bins is ignored.gamma_infiltration_to_extraction()- Forward transport as ininfiltration_to_extraction(), with the streamtube ensemble built from a gamma distribution of the layer velocity across a well screen of known heightscreen_height. The velocity ratio has mean 1 and coefficient of variationvelocity_cv, a streamtube of velocity ratiorhogets effective pore heightscreen_height / rho, and the distribution is discretized inton_binsequal-probability bins averaged by probability mass.velocity_cv = 0is the homogeneous screen: a single streamtube at pore heightscreen_height.gamma_extraction_to_infiltration()- Reverse operation as inextraction_to_infiltration(), over the same gamma layer-velocity streamtube ensemble asgamma_infiltration_to_extraction().
References
The references below give the published closed-form solutions for the single-phase radial injection
problem (steady divergent flow from one well) – the per-phase forward kernel this module composes. The
convergent-extraction dual and the multi-cycle push-pull / ASR composition across flow reversals are
built on top of those kernels here and are not in the single-injection references. All
share the assumptions used here: a single fully-penetrating well in a homogeneous medium with steady
divergent flow v = Q / (2 pi b n r), plus retardation.
The D_m = 0 kernel (velocity-proportional microdispersion D = alpha_L |u|, Airy functions)
is the classical radial-dispersion problem: Tang & Babu (1979) under a Dirichlet (resident-concentration)
well boundary, and Chen (1987) under the Cauchy / third-type (flux) boundary used here – explicitly the
Kreft-Zuber flux concentration, with transfer function Ai(Y) / [Ai(Y0)/2 - p^(1/3) Ai'(Y0)] equal to
the flux operator this module evaluates. The D_m > 0 kernel (D = alpha_L |u| + D_m, Kummer /
confluent-hypergeometric functions) under the same flux boundary, with retardation, is Aichi & Akitaya
(2018) – whose well operator U(a,b) + 2a U(a+1,b+1) is this module’s Whittaker flux boundary; they
record the D_m -> 0 reduction to Chen (1987) as an open problem, which this module performs
continuously – the log-derivative Riccati kernel reduces smoothly to the Airy branch as D_m -> 0.
The alpha_L = 0 limit (constant diffusion, drift-dominated radial transport, Whittaker equation) is
Akanji & Falade (2019). Each is an injection-only solution; none treats extraction or multi-cycle push-pull.
Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its solutions for different initial and boundary conditions. Chemical Engineering Science, 33(11), 1471-1480.
Tang, D. H., & Babu, D. K. (1979). Analytical solution of a velocity dependent dispersion problem. Water Resources Research, 15(6), 1471-1478.
Chen, C.-S. (1987). Analytical solutions for radial dispersion with Cauchy boundary at injection well. Water Resources Research, 23(7), 1217-1224.
Aichi, M., & Akitaya, K. (2018). Analytical solution for a radial advection-dispersion equation including both mechanical dispersion and molecular diffusion for a steady-state flow field in a horizontal aquifer caused by a constant rate injection from a well. Hydrological Research Letters, 12(3), 23-27.
Akanji, L. T., & Falade, G. K. (2019). Closed-form solution of radial transport of tracers in porous media influenced by linear drift. Energies, 12(1), 29.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.radial_asr.infiltration_to_extraction(*, cin, flow, tedges, cout_tedges, pore_heights, porosity, well_radius, longitudinal_dispersivity, molecular_diffusivity=0.0, retardation_factor=1.0, weights=None, background=0.0, regional_flux=0.0, n_modes=None, n_quad=240)[source]#
Compute the extracted flux concentration at a radial well for a signed flow schedule.
- Parameters:
cin (
ArrayLike) – Injected concentration per time bin (used only on injection bins,flow > 0).flow (
ArrayLike) – Signed flow per time bin [m^3/day]:> 0injection,< 0extraction,0rest.tedges (
DatetimeIndex) – Time bin edges (n + 1fornbins).cout_tedges (
DatetimeIndex) – Output time bin edges; must equaltedges. 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; smallerb= faster). See the module docstring andgamma_infiltration_to_extraction().porosity (
float) – Porosityn[-].well_radius (
float) – Well (screen) radiusr_w[m].longitudinal_dispersivity (
float) – Longitudinal dispersivityalpha_L[m].molecular_diffusivity (
float, default:0.0) – Molecular diffusivityD_m[m^2/day]. Default 0.D_m = 0uses the vectorized Airy branch;D_m > 0uses the log-derivative Riccati kernel – exact to the de Hoog floor at anyA_0/D_mwith no precision cap, reducing continuously to the Airy branch asD_m -> 0. ForD_m > 0each one-signed pumping phase is advanced at its (constant) dt-weighted mean flow magnitude, so within-phase variable flow is a constant-|Q| approximation whose error grows with the within-phase flow variation; it is exact for constant-|Q| phases, and (at any flow schedule) forD_m = 0.retardation_factor (
float, default:1.0) – Linear retardationR >= 1. Default 1.weights (
ArrayLike|None, default:None) – Per-streamtube averaging weights (same length aspore_heights). Default equal weights.background (
float, default:0.0) – Ambient aquifer concentrationc_bg. The deviationcin - c_bgis transported andc_bgis added back; constantcin = c_bgreturnscout = c_bg. Default 0.regional_flux (
float, default:0.0) – Steady uniform regional background Darcy fluxU[m/day] in+x(drift seepagev_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 radiusr_s = |A_0| / |v_d|(aValueErroris raised otherwise). Rest phases (flow == 0) are propagated by the exact free-space drift kernel (translationv_d t / Rplus 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 truncationMfor the drift engine (keeps modes-M .. M). DefaultNoneauto-sizesMfrom the plume-front drift ratioeps = v_d R_b / A_0and the rest-phase displacement (clamped to[2, 8]). Ignored whenregional_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:
- 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:
cout (
ArrayLike) – Measured extracted concentration (used on extraction bins,flow < 0).flow (
ArrayLike) – As ininfiltration_to_extraction().tedges (
DatetimeIndex) – As ininfiltration_to_extraction().cout_tedges (
DatetimeIndex) – As ininfiltration_to_extraction().pore_heights (
ArrayLike) – As ininfiltration_to_extraction().porosity (
float) – As ininfiltration_to_extraction().well_radius (
float) – As ininfiltration_to_extraction().longitudinal_dispersivity (
float) – As ininfiltration_to_extraction().molecular_diffusivity (
float, default:0.0) – As ininfiltration_to_extraction().retardation_factor (
float, default:1.0) – As ininfiltration_to_extraction().weights (
ArrayLike|None, default:None) – As ininfiltration_to_extraction().background (
float, default:0.0) – As ininfiltration_to_extraction().regional_flux (
float, default:0.0) – As ininfiltration_to_extraction().n_modes (
int|None, default:None) – As ininfiltration_to_extraction().n_quad (
int, default:240) – As ininfiltration_to_extraction().regularization_strength (
float, default:1e-10) – Tikhonov parameter, must be non-negative. Default1e-10.
- Returns:
Recovered injected concentration; NaN on extraction / rest bins.
- Return type:
- Raises:
ValueError – If
coutcontains NaN on any extraction bin (flow < 0), which would poison the least-squares solve. Structural NaN on injection / rest bins is allowed. Ifregularization_strengthis negative: it would reachnp.sqrtin the augmented system and silently produce an all-NaNcin.
- 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 heightscreen_height) and coefficient of variationvelocity_cv. A streamtube with velocity ratiorho(gamma, mean 1) has effective pore heightscreen_height / rho– faster layers are thinner and break through sooner. The gamma is discretized inton_binsequal-probability bins (gwtransport.gamma.bins()) and averaged by probability mass viainfiltration_to_extraction().- Parameters:
screen_height (
float) – Known well-screen heightH[m] (the fixed total; the mean streamtube velocity is set by it).velocity_cv (
float) – Coefficient of variation of the layer velocity (the macrodispersion strength).0is a homogeneous screen (a single streamtube, sharp breakthrough); typically< 1– larger values give a heavy slow-velocity tail.n_bins (
int, default:100) – Number of equal-probability velocity bins. Default 100.cin (
ArrayLike) – As ininfiltration_to_extraction().flow (
ArrayLike) – As ininfiltration_to_extraction().tedges (
DatetimeIndex) – As ininfiltration_to_extraction().cout_tedges (
DatetimeIndex) – As ininfiltration_to_extraction().porosity (
float) – As ininfiltration_to_extraction().well_radius (
float) – As ininfiltration_to_extraction().longitudinal_dispersivity (
float) – As ininfiltration_to_extraction().molecular_diffusivity (
float, default:0.0) – As ininfiltration_to_extraction().retardation_factor (
float, default:1.0) – As ininfiltration_to_extraction().background (
float, default:0.0) – As ininfiltration_to_extraction().regional_flux (
float, default:0.0) – As ininfiltration_to_extraction().n_modes (
int|None, default:None) – As ininfiltration_to_extraction().n_quad (
int, default:240) – As ininfiltration_to_extraction().
- Returns:
Extracted flux concentration; NaN on injection / rest bins.
- Return type:
- 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).
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 meanretardation_factor * aquifer_pore_depth / N— independent of the pumping rate, hydraulic conductivity, capture-zone size, and planform shape (Haitjema, 1995). In the cumulative-recharge clocku(t) = ∫ N dt / (retardation_factor * aquifer_pore_depth)(pore volumes flushed) the model is the stationary unit filterdC/du = cin_recharge - C, which this module integrates in closed form per bin. No flow rate is needed.Bounded aquifer (
aquifer_pore_volumeset): the aquifer extent is capped at pore volumeaquifer_pore_volume(strip areaaquifer_pore_volume / aquifer_pore_depth). Water with concentrationcinenters at the upstream side at rateq_b = flow - N * areawhenever 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 currentcin. The exact solution is the unbounded exponential kernel acting oncin_recharge, truncated at the boundary-entry time of the extracted water, with the residual tail weight placed as an atom oncinat the entry time. With zero recharge this reduces exactly to single-pore-volume piston flow (gwtransport.advection.infiltration_to_extraction()); with the boundary never feeding the well it reduces exactly to the unbounded model.
Available functions:
recharge_to_extraction()- Compute the extracted concentration from the recharge concentrationcin_rechargeand, in the bounded model, the upstream-boundary concentrationcinand the extraction rateflow. Forward only: there is no extraction-to-recharge inverse. The result is one value percout_tedgesbin, a flow-weighted bin average in the bounded model and a recharge-weighted bin average in the unbounded model, NaN outside the input time range and in bins whose weight is zero. All terms are closed-form, so the output is exact to machine precision for bin-constant inputs.
References
Haitjema, H.M. (1995). On the residence time distribution in idealized groundwatersheds. Journal of Hydrology, 172(1-4), 127-146. https://doi.org/10.1016/0022-1694(95)02732-5
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.recharge.recharge_to_extraction(*, cin=None, cin_recharge, flow=None, recharge, tedges, cout_tedges, aquifer_pore_volume=None, aquifer_pore_depth, retardation_factor=1.0)[source]#
Compute the concentration of extracted water under uniform areal recharge.
Unbounded model (
aquifer_pore_volume=None): exponential residence-time distribution with meanretardation_factor * aquifer_pore_depth / N(Haitjema, 1995), exact for bin-constant inputs. Bounded model (aquifer_pore_volumeset, together withcinandflow): the exponential kernel is truncated at the upstream-boundary entry time and the residual weight is an atom oncin; 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 whenaquifer_pore_volumeis set; must be None otherwise.cin_recharge (
ArrayLike) – Concentration of the recharge water entering via the surface [concentration units]. Length must equallen(tedges) - 1; constant over each interval[tedges[i], tedges[i+1]).flow (
ArrayLike|None, default:None) – Extraction rate [m3/day]. Required whenaquifer_pore_volumeis 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 asaquifer_pore_depth]. Length must equallen(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 thetedgesrange 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 isaquifer_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_tedgesbin, lengthlen(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:
- 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_extractionZero-recharge limit of the bounded model.
gwtransport.deposition.deposition_to_extractionDistributed 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 byaquifer_pore_volumeinstead 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 beforetedges[0]. For the bounded model this is the steady concentration profileC(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 profilecin_recharge[0]otherwise.Under constant inputs with
flow > N * areathe extracted water is the mass-balance mixturecin_recharge + (cin - cin_recharge) * q_b / flow: an exponential residence-time density carrying the recharge fraction plus a piston atom of massq_b / flowat the boundary-to-well travel time.The exponential kernel lives on the dimensionless clock
uand is parameter-free; the pumping rate enters the bounded model only through the boundary-entry times. All formulas are closed-form (exp/log of bin-local quantities), exact to machine precision for bin-constant inputs.References
Haitjema, H.M. (1995). On the residence time distribution in idealized groundwatersheds. Journal of Hydrology, 172(1-4), 127-146. https://doi.org/10.1016/0022-1694(95)02732-5
Examples
>>> import numpy as np >>> import pandas as pd >>> from gwtransport.recharge import recharge_to_extraction >>> tedges = pd.date_range("2020-01-01", periods=11, freq="D") >>> cout = recharge_to_extraction( ... cin_recharge=np.full(10, 2.5), ... recharge=np.full(10, 0.002), ... tedges=tedges, ... cout_tedges=tedges[3:], ... aquifer_pore_depth=3.0, ... ) >>> np.allclose(cout, 2.5) True
residence_time#
Residence Time Calculations for Retarded Compound Transport.
This module provides functions to compute residence times for compounds traveling through aquifer systems, accounting for flow variability, pore volume, and retardation due to physical or chemical interactions with the aquifer matrix. Residence time represents the duration a compound spends traveling from infiltration to extraction points, depending on flow rate (higher flow yields shorter residence time), pore volume (larger volume yields longer residence time), and retardation factor (interaction with matrix yields longer residence time).
The residence times are resolved per pore volume (full()), collapsed over a discrete
equally-weighted pore-volume distribution (mean()), or in closed form over a (shifted) gamma
aquifer pore-volume distribution with no discretization (gamma()).
Spin-up period#
The spin-up region is determined entirely by the supplied flow record (tedges, which
fixes the cumulative throughflow volume V from 0 at the record start to V_end at the
record end) together with the retarded pore volume retardation_factor * V_p – it is not a
length you set. A residence time for an output time needs the corresponding parcel to stay inside
the flow record:
direction='extraction_to_infiltration'looks back to the infiltration event, so the spin-up sits at the start of the output record: the residence time of a pore volumeV_pneedsV(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 needsV_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 isNaN.Noneis strict (no extrapolation), marking a pore volumeNaNfor 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
floatcovered-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 leastspinup(0.0matchesNone; larger values demand more coverage). For the per-pore-volumefull()there is no axis to collapse, so thefloatbehaves exactly likeNone.
Output bins lying wholly outside tedges are NaN under every policy.
The fraction_explained_full() / fraction_explained_mean() /
fraction_explained_gamma() diagnostics report, per output bin, the advective fraction of the
pore-volume distribution that is out of spin-up (1.0 = advectively fully informed, 0.0 =
entirely in spin-up) and are the way to locate the spin-up region when the means warm-start over it.
They are purely advective – molecular diffusion and microdispersion spread each bin over a
range of infiltration times that is not captured there, so no bin is fully informed once dispersion
is present (for that dispersive informed fraction use the captured kernel mass of the diffusion
coefficient matrix).
Available functions:
full()- Flow-weighted mean residence time [days] over each output bin, resolved per pore volume: the full(n_pore_volumes, n_output_bins)array, without collapsing the pore-volume axis. The bin average is uniform in cumulative throughflow volume, anddirectionselects whether the time is the look-back to infiltration (extraction_to_infiltration) or the look-forward to extraction (infiltration_to_extraction).mean()- Mean residence time [days] per output bin for a discrete aquifer pore-volume distribution: thefull()array collapsed by averaging over the equally-weighted streamtubes that are valid in each bin, shape(n_output_bins,).gamma()- Mean residence time [days] per output bin for a continuous (shifted) gamma aquifer pore-volume distribution, parameterized by either(mean, std, loc)or(alpha, beta, loc). The expectation is taken in closed form from regularized incomplete-gamma partial moments, so there is no pore-volume discretization and no accuracy/cost knob; shape(n_output_bins,).fraction_explained_full()- Advective coverage per pore volume: the flow-weighted fraction of each output bin, in[0, 1], whose retarded parcel lies inside the supplied flow record. Returned as the full(n_pore_volumes, n_output_bins)array, mirroringfull().fraction_explained_mean()- Equally-weighted mean offraction_explained_full()over the discrete streamtubes, shape(n_output_bins,).fraction_explained_gamma()- Closed-form expectation of the advective in-record indicator over a (shifted) gamma pore-volume distribution, shape(n_output_bins,)– the continuum analogue offraction_explained_mean(), evaluated from the antiderivative of the shifted-gamma CDF.freundlich_retardation()- Concentration-dependent retardation factorsR = 1 + (rho_b / theta) * k_f * (1 / n) * C ** (1 / n - 1)from the Freundlich isotherms = k_f * C ** (1 / n), one per concentration entry, for use as theretardation_factorinput of the transport functions.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.residence_time.full(*, flow, tedges, cout_tedges, aquifer_pore_volumes, direction='extraction_to_infiltration', retardation_factor=1.0, spinup='constant')[source]#
Compute the mean residence time over output bins, per pore volume.
The flow-weighted mean residence time is computed over each output interval
[cout_tedges[i], cout_tedges[i + 1])and returned as the full(n_pore_volumes, n_output_bins)array – one row per entry inaquifer_pore_volumes, without collapsing the pore-volume axis. The average is uniform in cumulative throughflow volume, matching the package’s bin-edge convention.- Parameters:
flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matchestedgesminus 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 + 1edges definenoutput 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-upNaN.Noneor afloatin[0, 1]: strict – a pore volume whose parcel leaves the record at any point within an output bin isNaNfor 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; thefloatcovered-fraction threshold therefore behaves identically toNonehere and only takes effect once the axis is collapsed inmean()/gamma().
Output bins lying wholly outside
tedgesareNaNunder 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 thecout_tedgesbins. Negative orNaNflowmakes the cumulative-volume map non-monotone or undefined; the whole array is returned asNaN(the function refuses rather than raising).- Return type:
- Raises:
ValueError – If
tedgesdoes not have exactly one more element thanflow. Ifdirectionis not'extraction_to_infiltration'or'infiltration_to_extraction'. Ifspinupis not'constant',None, or a float in[0, 1].
See also
fraction_explained_fullAdvective 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 isNaN; usefraction_explained_mean()(orspinup=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_infiltrationand \(+1\) forinfiltration_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 withfull()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, usegamma().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; withspinup=Noneit renormalizes over the streamtubes that have broken through.- Parameters:
flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matchestedgesminus 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 + 1edges definenoutput bins.aquifer_pore_volumes (
ArrayLike) – Discrete pore volumes [m³], one per (equally-weighted) streamtube. A single value collapses to the per-streamtube mean offull().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 ofgamma().'constant'(default) warm-starts by extrapolating the boundary flow so no in-record bin isNaN;Noneleaves spin-up streamtubesNaNand the mean renormalizes over those that have broken through (emitted wherever at least one streamtube is valid). Afloatin[0, 1]is the covered-fraction threshold: the renormalized mean is emitted only where the fraction of valid streamtubes is at leastspinup(0.0matchesNone;1.0demands every streamtube; larger values demand more streamtubes to have broken through). Usefraction_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 – withspinup=None– fully in the spin-up zone) are NaN; with afloatspinupso are bins whose valid-streamtube fraction is below the threshold. Negative orNaNflowmakes the cumulative-volume map non-monotone or undefined; the whole series is returned asNaN(the function refuses rather than raising).- Return type:
See also
gammaExact closed-form mean for a continuous (shifted) gamma APVD
fullPer-pore-volume mean residence time over output bins
fraction_explained_meanAdvective fraction of each output bin explained by the record
gwtransport.gamma.binsDiscretize a gamma APVD into pore-volume bins
- Residence Time
Time in aquifer between infiltration and extraction
Notes
With
spinup=Nonethe 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 aNaNbin average (inherited fromfull()) and is dropped from that bin’s mean entirely, rather than contributing its partially-covered share; the bin isNaNonly once every streamtube is in spin-up. In that mode the discrete mean differs fromgamma(), 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 non_binsaccuracy/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
spinuppolicy 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 afloatthreshold renormalizes the mean over the covered sub-mass (0.0reproduces the exact covered-sub-mass conditional mean).- Parameters:
flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matchestedgesminus 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 + 1edges definenoutput bins.mean (
float|None, default:None) – Mean of the gamma APVD [m³]. Must be strictly greater thanloc. 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 satisfy0 <= 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 isNaN.Noneor afloatin[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.Noneand0.0both give the exact covered-sub-mass conditional mean (emit whenever any sub-mass is covered); larger values demand a larger covered fraction, and1.0requires the full distribution to be covered.
Output bins lying wholly outside
tedgesareNaNunder either policy.
- Returns:
APVD-mean residence time [days], shape
(n_output_bins,). Output bins outside the flow record are NaN; with afloatspinupso are bins whose covered fraction is below the threshold. Negative orNaNflowmakes the cumulative-volume map non-monotone or undefined; the whole series is returned asNaN(the function refuses rather than raising).- Return type:
- Raises:
ValueError – If
tedgesdoes not have exactly one more element thanflow. Ifdirectionis not'extraction_to_infiltration'or'infiltration_to_extraction'. Ifspinupis not'constant',None, or a float in[0, 1]. Gamma parameter validation is delegated togwtransport.gamma.parse_parameters().
See also
meanEqually-weighted mean for a discrete set of pore volumes
fullPer-pore-volume mean residence time over output bins
fraction_explained_meanAdvective fraction of each output bin explained by the record
gwtransport.gamma.binsDiscretize 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 inmean()(constant-boundary-flow extrapolation) – the same warm-start policy applies across the whole distribution (meandiscretizes it,gammaintegrates it in closed form). Withspinup=0.0the spin-up is instead handled by exact covered-sub-mass renormalization: each output bin integrates over only the pore-volume sub-range with sufficient flow history. See the module docstring (Spin-up period) for the full rule.The closed form uses regularized incomplete-gamma CDFs. For an extremely narrow APVD (
alpha = (mean / std) ** 2above ~1e7, i.e.std / meanbelow ~3e-4) SciPy’s incomplete gamma loses precision in its far tail and the result degrades to ~1e-5 relative; such a distribution is effectively a single pore volume, somean()with one bin is the exact alternative there.Examples
>>> import pandas as pd >>> import numpy as np >>> from gwtransport.residence_time import gamma >>> flow_dates = pd.date_range(start="2023-01-01", end="2023-02-10", freq="D") >>> flow_values = np.full(len(flow_dates) - 1, 100.0) # 100 m³/day >>> tau_bar = gamma( ... flow=flow_values, ... tedges=flow_dates, ... cout_tedges=flow_dates, ... mean=500.0, ... std=100.0, ... direction="extraction_to_infiltration", ... ) >>> # Deep in the record the mean residence time approaches mean / flow = 5 days >>> float(np.round(tau_bar[-1], 6)) 5.0
- gwtransport.residence_time.fraction_explained_full(*, flow, tedges, cout_tedges, aquifer_pore_volumes, direction='extraction_to_infiltration', retardation_factor=1.0)[source]#
Advective coverage per pore volume: the fraction of each output bin explained by the record.
For each streamtube (entry in
aquifer_pore_volumes) and each output bin[cout_tedges[i], cout_tedges[i + 1])this returns the flow-weighted fraction of the bin whose retarded advective parcel lies inside the supplied flow record – the share of the bin’s throughflow volume for which the look-back infiltration (extraction_to_infiltration) or look-forward extraction (infiltration_to_extraction) event is covered bycin.1.0means the whole bin is explained for that pore volume,0.0that none of it is. The full(n_pore_volumes, n_output_bins)array is returned – one row per pore volume, mirroringfull().Warning
This is a purely advective diagnostic: it uses only the cumulative-volume look-back
V(t) - retardation_factor * V_pand 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 matchestedgesminus one.tedges (
DatetimeIndex|ndarray) – Time edges for the flow data;n + 1edges fornflow values.cout_tedges (
DatetimeIndex|ndarray) – Output time edges;n + 1edges definenoutput 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 outsidetedgesareNaN. Negative orNaNflowmakes the cumulative-volume map non-monotone or undefined; the whole array is returned asNaN(the function refuses rather than raising).- Return type:
- Raises:
ValueError – If
tedgesdoes not have exactly one more element thanflow, or ifdirectionis not'extraction_to_infiltration'or'infiltration_to_extraction'.
See also
fraction_explained_meanEqual-weight mean of this over a discrete APVD
fraction_explained_gammaClosed-form coverage for a (shifted) gamma APVD
fullPer-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 inaquifer_pore_volumes– the coverage analogue ofmean().1.0means every streamtube fully explains the bin,0.0that none do.Warning
Purely advective – see
fraction_explained_full(). Molecular diffusion and longitudinal dispersion spreading are not captured, so a value of1.0is advective coverage, not full dispersive information.- Parameters:
flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matchestedgesminus one.tedges (
DatetimeIndex|ndarray) – Time edges for the flow data;n + 1edges fornflow values.cout_tedges (
DatetimeIndex|ndarray) – Output time edges;n + 1edges definenoutput 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 outsidetedgesareNaN. Negative orNaNflowmakes the cumulative-volume map non-monotone or undefined; the whole series is returned asNaN(the function refuses rather than raising).- Return type:
See also
fraction_explained_fullPer-pore-volume coverage (the array this averages)
fraction_explained_gammaClosed-form coverage for a (shifted) gamma APVD
meanEqually-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 offraction_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_infiltrationand \((V_\mathrm{end} - V) / R\) forinfiltration_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 of1.0is advective coverage, not full dispersive information.- Parameters:
flow (
ArrayLike) – Flow rate of water in the aquifer [m³/day]. Length matchestedgesminus one.tedges (
DatetimeIndex|ndarray) – Time edges for the flow data;n + 1edges fornflow values.cout_tedges (
DatetimeIndex|ndarray) – Output time edges;n + 1edges definenoutput bins.mean (
float|None, default:None) – Mean of the gamma APVD [m³]. Must be strictly greater thanloc. 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 satisfy0 <= 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 outsidetedgesareNaN. Negative orNaNflowmakes the cumulative-volume map non-monotone or undefined; the whole series is returned asNaN(the function refuses rather than raising).- Return type:
- Raises:
ValueError – If
tedgesdoes not have exactly one more element thanflow, or ifdirectionis not'extraction_to_infiltration'or'infiltration_to_extraction'. Gamma parameter validation is delegated togwtransport.gamma.parse_parameters().
See also
fraction_explained_meanDiscrete equal-weight APVD coverage
fraction_explained_fullPer-pore-volume coverage
gammaClosed-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.FreundlichSorptionandgwtransport.advection.infiltration_to_extraction_nonlinear_sorption(), so a fittedfreundlich_nis 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 theflowarray 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 = 1recovers 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:
- Raises:
ValueError – If
porosityis not in(0, 1), ifbulk_densityis not positive, iffreundlich_kis negative, or if anyconcentrationis non-positive whilefreundlich_n > 1(the retardation factor diverges asC -> 0).
See also
fullCompute residence times from flow and pore volume
gwtransport.advection.infiltration_to_extraction_nonlinear_sorptionTransport with nonlinear sorption
- Non-Linear Sorption: Exact Solutions
Freundlich isotherm and concentration-dependent retardation
Examples
>>> concentration = np.array([0.1, 0.2, 0.3]) # same length as flow >>> R = freundlich_retardation( ... concentration=concentration, ... freundlich_k=0.5, ... freundlich_n=2.0, ... bulk_density=1600, # kg/m³ ... porosity=0.35, ... ) >>> # Use R as retardation_factor in the transport functions
deposition_utils#
Utility Functions for the Deposition Module.
This module provides the clipped-trapezoid integral helper _clipped_linear_integral, which the
deposition module’s banded weight builder uses to integrate clip(f(x), lo, hi) over each cin bin
inside an output bin’s residence window, and _positive_part_integral, the positive-part integral
it is built from.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
utils#
General Utilities for 1D Groundwater Transport Modeling.
This module provides general-purpose utility functions for time series manipulation, interpolation, numerical operations, and data processing used throughout the gwtransport package. Functions include linear interpolation, cumulative flow volumes, time-edge construction, linear-system solvers, and external data retrieval.
The inverse solvers below are two intentionally coexisting families: a Tikhonov family (the dense
solve_inverse_transport() and its banded equivalent solve_inverse_transport_banded(),
both fed by compute_reverse_target() and built on solve_tikhonov()) for the
overdetermined deconvolution in advection/diffusion, and a separate nullspace solver
(solve_underdetermined_system()) for the underdetermined deposition inverse.
Available functions:
step_plot_coords()- Expand bin edges (n+1) and bin-averaged values (n) into paired x/y arrays of 2n points each, so thatax.plot(x, y)draws the piecewise-constant series as a step function. Edges may be numeric or datetime; each output keeps the dtype of its input.cumulative_flow_volume()- Accumulate per-bin flow rates times bin widths into the cumulative volume at every bin edge (n+1 values, starting at zero). Withstrictly_monotone=Truethe plateaus left by zero-flow bins are bumped by a few ulps, which is required before inverting the sequence from volume back to time.linear_interpolate()- Interpolatey_refatx_querylinearly;x_refmust be ascending and the result has the shape ofx_query. Query points outside the reference range clamp to the end values unlessleft/rightsupply a fill value such as NaN.simplify_bins()- Merge adjacent bins of a piecewise-constant series until the peak-to-peak range within each merged group is at mosttol, returning the merged edges, values and flow. Values are volume-weighted (flow times width) whenflowis given and width-weighted otherwise, while the merged flow is always width-weighted. Splitting at the largest value jump makes the result independent of scan direction.compute_time_edges()- Build the n+1 bin edges as a nanosecond-precision DatetimeIndex from exactly one of explicit edges, per-bin start times, or per-bin end times, validating the length againstnumber_of_bins. Fromtstartortendthe single missing outer edge is extrapolated from the adjacent interval alone, so passtedgesdirectly when the bins are not uniformly spaced.get_soil_temperature()- Download the KNMI soil-temperature record of one of four Dutch weather stations and return it as a DataFrame in degrees Celsius on a UTC DatetimeIndex, with columns for the temperature at 5, 10, 20, 50 and 100 cm depth and the six-hourly minima and maxima at 5 and 10 cm. The download is cached on disk for the calendar day; missing values are interpolated and forward-filled unless disabled. This is the only function here that needsrequests, which is therefore imported lazily.solve_underdetermined_system()- SolveA x = bfor a wide system (more unknowns than equations) by taking the least-squares solution and adding the nullspace component that minimizes a roughness objective; rows holding NaN are dropped first. The closed-form squared-differences optimum is always computed and also seeds the iterative optimization of the other objectives, so a nullspace containing a near-constant vector raisesnumpy.linalg.LinAlgErrorwhichever objective is requested.compute_reverse_target()- Transpose the forward coefficient matrix, normalize its rows and apply it to the observations, giving each input bin the contribution-weighted average of the output bins it fed. This is the reference solution the Tikhonov solvers pull poorly-determined modes toward; input bins with negligible forward weight are returned as NaN.solve_tikhonov()- Solvemin ||A x - b||² + λ ||x - x_target||²as a single augmented least-squares problem. Rows of the system holding NaN are excluded, and NaN entries ofx_targetare left unregularized.solve_inverse_transport()- Recover the input signal of the dense forward modelw_forward @ x = observedby building the target withcompute_reverse_target()and solving it withsolve_tikhonov(). Observation rows that are NaN or carry negligible weight drop out (an explicitvalid_rowsmask overrides the weight test), and output bins left without forward contribution come back as NaN.solve_inverse_transport_banded()- Solve the same inverse problem for a forward operator stored in banded layout (rowkisband_vals[k]placed at columncol_start[k]), through banded Cholesky normal equations plus corrected semi-normal refinement. The factorization, solve and refinement stay atO(n_output * full_band). The regularization strength must be strictly positive, since it is what makes the banded factor positive definite.
This file is part of gwtransport which is released under AGPL-3.0 license. See the ./LICENSE file or go to gwtransport/gwtransport for full license details.
- gwtransport.utils.step_plot_coords(edges, values)[source]#
Compute step-plot coordinates from bin edges and bin-averaged values.
Converts bin edges (n+1) and bin values (n) into paired x/y arrays suitable for plotting piecewise-constant (step) functions with
ax.plot(x, y).- Parameters:
edges (
ArrayLike) – Bin edges (n+1 elements for n bins). Can be numeric, datetime, or any type accepted bynumpy.repeat().values (
ArrayLike) – Bin-averaged values (n elements), one per bin.
- Return type:
- Returns:
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
Vat 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.diffof edge days).strictly_monotone (
bool, default:False) – WhenTrue, bump consecutive duplicates (plateaus fromQ = 0bins) via_make_strictly_monotoneso the cumulative volume is strictly increasing. Required before a V → t inversion; leaveFalsewhen the plateaus must be preserved. Default isFalse.
- Returns:
Cumulative volume at each edge (length
len(flow) + 1), starting at zero.- Return type:
See also
_make_strictly_monotone: Bump duplicates before V → t inversion.
- gwtransport.utils.linear_interpolate(*, x_ref, y_ref, x_query, left=None, right=None)[source]#
Linear interpolation using numpy’s optimized interp function.
- Parameters:
x_ref (
ArrayLike) – Reference x-values, in ascending order.y_ref (
ArrayLike) – Reference y-values corresponding to x_ref.x_query (
ArrayLike) – Query x-values where interpolation is needed. Array may have any shape.left (
float|None, default:None) –Value to return for x_query < x_ref[0].
If
left=None: clamp to y_ref[0] (default)If
left=float: use specified value (e.g.,np.nan)
right (
float|None, default:None) –Value to return for x_query > x_ref[-1].
If
right=None: clamp to y_ref[-1] (default)If
right=float: use specified value (e.g.,np.nan)
- Returns:
Interpolated y-values with the same shape as x_query.
- Return type:
Examples
Basic interpolation with clamping (default):
>>> import numpy as np >>> from gwtransport.utils import linear_interpolate >>> x_ref = np.array([1.0, 2.0, 3.0, 4.0]) >>> y_ref = np.array([10.0, 20.0, 30.0, 40.0]) >>> x_query = np.array([0.5, 1.5, 2.5, 3.5, 4.5]) >>> linear_interpolate(x_ref=x_ref, y_ref=y_ref, x_query=x_query) array([10., 15., 25., 35., 40.])
Using NaN for extrapolation:
>>> linear_interpolate( ... x_ref=x_ref, y_ref=y_ref, x_query=x_query, left=np.nan, right=np.nan ... ) array([nan, 15., 25., 35., nan])
- gwtransport.utils.simplify_bins(*, edges, values, flow=None, tol=0.0)[source]#
Simplify a piecewise-constant time series by merging adjacent bins.
Splits at the largest value jump until the peak-to-peak range within every group does not exceed tol. The result is independent of scan direction.
- Parameters:
edges (
ArrayLike) – Bin edges with shape(n+1,). May be numeric or pandas Timestamps.values (
ArrayLike) – Bin-averaged values with shape(n,)(e.g., concentrations).flow (
ArrayLike|None, default:None) – Flow rate per bin with shape(n,)(e.g., m³/day). When provided, merged-bin values are weighted by volume (flow x bin width) instead of bin width alone.tol (
float, default:0.0) – Maximum peak-to-peak range within a merged group. Default is 0.0, which merges only runs of identical values.
- Return type:
tuple[NDArray[floating] |DatetimeIndex,NDArray[floating],NDArray[floating] |None]- Returns:
new_edges (
ndarrayorDatetimeIndex) – Simplified bin edges with shape(m+1,), preserving the type of edges.new_values (
ndarrayoffloat) – Volume-weighted (or width-weighted) average values per simplified bin, with shape(m,).new_flow (
ndarrayoffloatorNone) – 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:
- 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
tstartortendare 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, supplytedgesdirectly 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:
Notes
KNMI: Royal Netherlands Meteorological Institute
The timeseries may contain NaN values for missing data.
- gwtransport.utils.solve_underdetermined_system(*, coefficient_matrix, rhs_vector, nullspace_objective='squared_differences', optimization_method='BFGS', rcond=None)[source]#
Solve an underdetermined linear system with nullspace regularization.
For an underdetermined system Ax = b where A has more columns than rows, multiple solutions exist. This function computes a least-squares solution and then selects a specific solution from the nullspace based on a regularization objective.
- Parameters:
coefficient_matrix (
ArrayLike) – Coefficient matrix of shape (m, n) where m < n (underdetermined). May contain NaN values in some rows, which will be excluded from the system.rhs_vector (
ArrayLike) – Right-hand side vector of length m. May contain NaN values corresponding to NaN rows in coefficient_matrix, which will be excluded from the system.nullspace_objective (
str|Callable[[NDArray[floating],NDArray[floating],NDArray[floating]],float], default:'squared_differences') –Objective function to minimize in the nullspace. Options:
”squared_differences” : Minimize sum of squared differences between adjacent elements:
sum((x[i+1] - x[i])**2)”summed_differences” : Minimize sum of absolute differences between adjacent elements:
sum(|x[i+1] - x[i]|)callable : Custom objective function with signature
objective(coeffs, x_ls, nullspace_basis)where:coeffs : optimization variables (nullspace coefficients)
x_ls : least-squares solution
nullspace_basis : nullspace basis matrix
Default is “squared_differences”.
optimization_method (
str, default:'BFGS') – Optimization method passed to scipy.optimize.minimize. Default is “BFGS”.rcond (
float|None, default:None) – Cutoff ratio for small singular values in bothnumpy.linalg.lstsqandscipy.linalg.null_space. Singular values smaller thanrcond * largest_singular_valueare 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:
- Raises:
ValueError – If optimization fails, if coefficient_matrix and rhs_vector have incompatible shapes, or if an unknown nullspace objective is specified.
numpy.linalg.LinAlgError – If the squared-differences normal equations
(DN)^T(DN)are ill-conditioned (condition number above 1e12), which happens when the nullspace contains a near-constant vector.
Notes
The algorithm follows these steps:
Remove rows with NaN values from both coefficient_matrix and rhs_vector
Compute least-squares solution: x_ls = pinv(valid_matrix) @ valid_rhs
Compute nullspace basis: N = null_space(valid_matrix)
Find nullspace coefficients: coeffs = argmin objective(x_ls + N @ coeffs)
Return final solution: x = x_ls + N @ coeffs
For the built-in objectives:
“squared_differences” provides smooth solutions, minimizing rapid changes
“summed_differences” provides sparse solutions, promoting piecewise constant behavior
Examples
Rows containing NaN are dropped; the solution satisfies the remaining equations:
>>> import numpy as np >>> from gwtransport.utils import solve_underdetermined_system >>> matrix = np.array([ ... [1.0, 2.0, 1.0, 0.0], ... [np.nan, np.nan, np.nan, np.nan], ... [0.0, 1.0, 2.0, 1.0], ... ]) >>> rhs = np.array([3.0, np.nan, 4.0]) >>> x = solve_underdetermined_system(coefficient_matrix=matrix, rhs_vector=rhs) >>> bool(np.allclose(matrix[[0, 2]] @ x, [3.0, 4.0])) True
- gwtransport.utils.compute_reverse_target(*, coeff_matrix, rhs_vector)[source]#
Compute reverse matrix target from forward coefficient matrix.
Constructs a target solution for the inverse problem by transposing the forward coefficient matrix and normalizing rows. For
W_forward[i,j]representing the fraction ofcin[j]arriving incout[i], the transpose-and-normalize approach reconstructscin[j]as a weighted average ofcoutbins, weighted by how muchcin[j]contributed to eachcoutbin.- Parameters:
- Returns:
Target solution vector of length n_cin. Entries with near-zero column sums in the forward matrix are set to NaN.
- Return type:
See also
solve_tikhonovConsumes this target as the regularization reference.
- gwtransport.utils.solve_tikhonov(*, coefficient_matrix, rhs_vector, x_target, regularization_strength=1e-10)[source]#
Solve a linear system with Tikhonov regularization toward a target.
Minimizes
||A x - b||² + λ ||x - x_target||²by solving the equivalent augmented least-squares problem:[A; √λ I_v] x = [b; √λ x_target_v]
where
I_vselects only entries wherex_targetis 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 ofrcondin truncated SVD.- Parameters:
coefficient_matrix (
ArrayLike) – Coefficient matrix of shape (m, n). May contain NaN rows, which are excluded from the system.rhs_vector (
ArrayLike) – Right-hand side vector of length m. May contain NaN values corresponding to NaN rows in coefficient_matrix.x_target (
NDArray[floating]) – Target solution of length n, typically fromcompute_reverse_target(). NaN entries are excluded from the regularization term.regularization_strength (
float, default:1e-10) –Tikhonov parameter λ. Controls the tradeoff between fitting the data and staying close to
x_target. Larger values trust the target more; smaller values trust the data more. Default is 1e-10.A good starting value for noisy data is
λ ≈ (noise_std / signal_amplitude)². For noiseless synthetic data, the default 1e-10 preserves machine precision.
- Returns:
Solution vector of length n.
- Return type:
- Raises:
ValueError – If
coefficient_matrixandrhs_vectorhave incompatible shapes, or if all rows contain NaN values.
See also
compute_reverse_targetCompute the regularization target from the forward matrix.
solve_underdetermined_systemAlternative solver using nullspace optimization.
- gwtransport.utils.solve_inverse_transport(*, w_forward, observed, n_output, regularization_strength, valid_rows=None)[source]#
Solve the inverse transport problem via Tikhonov regularization.
Given the forward model
w_forward @ x = observed, recoversxby building the regularization target from the transpose ofw_forwardand solving the regularized least-squares problem.- Parameters:
w_forward (
NDArray[floating]) – Forward coefficient matrix with shape(n_obs, n_output).observed (
NDArray[floating]) – Observed values with shape(n_obs,)(e.g., extraction concentrations). NaN entries mark measurement gaps; their rows are excluded from the solve and the regularization target.n_output (
int) – Length of the output vector (e.g., number of cin bins).regularization_strength (
float) – Tikhonov regularization parameter.valid_rows (
NDArray[bool] |None, default:None) – Which observation rows are valid, with shape(n_obs,). If None, rows withrow_sum > 1e-10are considered valid.
- Returns:
Recovered signal with shape
(n_output,). NaN for bins with no active columns.- Return type:
See also
solve_inverse_transport_bandedMemory-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: rowkof the dense operatorWisband_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_targetare stored in banded form –WᵀWis symmetric with half-bandwidthfull_band - 1– and Cholesky-factored withscipy.linalg.cholesky_banded(). The Gram matrixWᵀWis built with a single dense BLAS matmul (~24xa per-diagonal scatter) before its sub-diagonals are read into the banded layout. FormingWᵀWsquares 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 throughWitself rather thanWᵀW(matching the dense least-squares solution to ~1e-7 at the default regularization, degrading to ~1e-6 only at very small regularization with an ill-conditioned Gram). The banded Cholesky factor, solve, and refinement stay atO(n_output * full_band); only the one-shot Gram assembly transiently materializesWandWᵀWdensely.The regularization target
x_targetis the transpose-and-normalize ofWapplied toobserved(the banded form ofcompute_reverse_target()), matching the dense solver. Columns with no forward contribution are decoupled (unit diagonal) so the system stays symmetric positive definite, and are returned as NaN.- Parameters:
band_vals (
NDArray[floating]) – Banded forward weights of shape(n_obs, full_band). Rows the caller considers invalid must already be zeroed (as_resolve_spinup_maskdoes); zero rows contribute nothing to the normal equations.col_start (
NDArray[int_]) – First output-column index of each row’s band, shape(n_obs,).observed (
NDArray[floating]) – Observed values of shape(n_obs,)(e.g. extraction concentrations). NaN entries mark measurement gaps; their rows are excluded from the normal equations (band row and observed value zeroed).n_output (
int) – Length of the output vector (number of cin bins).regularization_strength (
float) – Tikhonov parameter λ. Seesolve_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:
- Raises:
ValueError – If
regularization_strengthis not strictly positive.
See also
solve_inverse_transport : Dense-matrix equivalent.
gwtransport.advection_utils._infiltration_to_extraction_weights: Banded builder.