Note
This notebook is located in the ./examples directory of the gwtransport repository.
Macrodispersion and Microdispersion in Solute Transport#
This notebook explains the distinction between macrodispersion (spreading from aquifer-scale heterogeneity, captured by the APVD), microdispersion (spreading from pore-scale velocity variations, characterized by \(\alpha_L\)), and molecular diffusion (\(D_m\)), and provides formulas to convert between them. See dispersion scales and advection-dominated assumption for background.
Dispersion as Scale-Dependent Heterogeneity#
All spreading in groundwater transport arises from velocity heterogeneity. What we call “dispersion” vs “heterogeneity” is a matter of observation scale:
Scale |
Mechanism |
Dispersion type |
gwtransport representation |
|---|---|---|---|
Molecular |
Brownian motion |
Molecular diffusion |
\(D_m\) [m²/day] |
Pore (~mm-cm) |
Velocity variations in pores |
Microdispersion |
\(\alpha_L\) [m] (longitudinal dispersivity) |
Aquifer (~m-km) |
Different streamline paths |
Macrodispersion |
\(\sigma_V\) [m³] (std of pore volume distribution) |
Key properties:
Microdispersion (\(\alpha_L\)) is an aquifer property — independent of hydrological boundary conditions
Macrodispersion depends on both aquifer properties and boundary conditions
Molecular diffusion depends on the compound and temperature
Standard approach: The diffusion_fast module is the recommended transport module. It combines advection (via APVD) with Gaussian smoothing for microdispersion and molecular diffusion. The diffusion module provides exact solutions but is much slower. The advection module ignores microdispersion and molecular diffusion entirely.
Prerequisite: The APVD is only well-defined under the steady-streamlines assumption.
Spreading Components#
Breakthrough curve spreading arises from three processes, expressed as equivalent standard deviation in volume units [m³]:
Component |
Symbol |
Physical cause |
Formula [m³] |
|---|---|---|---|
Aquifer heterogeneity |
\(\sigma_{V,apv}\) |
Different streamline lengths |
\(\sigma_{V,apv}\) (given) |
Molecular diffusion |
\(\sigma_{V,diff}\) |
Brownian motion |
\(\frac{\bar{V}}{L} \sqrt{2 D_m \tau}\) |
Mechanical dispersion |
\(\sigma_{V,disp}\) |
Pore-scale velocity variations |
\(\bar{V} \sqrt{\frac{2 \alpha_L}{L}}\) |
where \(\bar{V}\) is mean pore volume, \(L\) is streamline length, \(\tau = R \bar{V} / Q\) is mean solute residence time.
Important: \(\sigma_{V,disp}\) is constant regardless of flow velocity. \(\sigma_{V,diff}\) decreases with higher \(Q\) (less time for diffusion).
Unit conversions#
Unit |
Symbol |
Conversion from spatial \(\sigma_x\) |
|---|---|---|
Spatial [m] |
\(\sigma_x\) |
— |
Volume [m³] |
\(\sigma_V\) |
\(\sigma_V = \sigma_x \cdot \bar{V}/L\) |
Time [day] |
\(\sigma_t\) |
\(\sigma_t = \sigma_x / v = \sigma_x \cdot \tau / L\) |
Choosing the Right Approach#
By APVD source#
APVD source |
Recommendation |
|---|---|
Calibration from measurements |
|
Gamma from streamline analysis |
|
Discrete pore volumes |
|
Cross-compound calibration: When the APVD was calibrated using one compound (e.g., temperature with \(D_m \approx 0.1\) m²/day) and is used to predict another (e.g., a dissolved solute with \(D_m \approx 10^{-4}\) m²/day), the molecular diffusion contribution in the calibrated std is too large for the solute. When calibrating with diffusion_fast or diffusion, the three components are separated automatically.
Modeling approaches#
Method |
Function |
Dispersion included |
Speed |
When to use |
|---|---|---|---|---|
Gamma + fast diffusion |
|
Macro + micro + molecular |
Fast |
Standard approach — recommended for most cases |
Discrete + fast diffusion |
|
Macro + micro + molecular |
Fast |
Discrete APVD from streamline analysis |
Gamma APVD only |
|
Macro only (or combined via variance addition) |
Fast |
When diffusion is negligible, or combined std from variance addition |
Discrete + exact diffusion |
|
Macro + micro + molecular |
Slow |
Validation, highly variable flow, or when high precision is needed |
Variance addition (alternative fast approach)#
When using the advection module, microdispersion and molecular diffusion can be included by increasing the gamma std parameter: \(\sigma_{total} = \sqrt{\sigma_{apv}^2 + \sigma_{diff}^2 + \sigma_{disp}^2}\). This is only valid for gamma-parameterized (continuous) distributions from streamline analysis, not from calibration (where all mixing is already included) and not for discrete APVD.
[1]:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from gwtransport import advection, diffusion, diffusion_fast
np.random.seed(42)
plt.style.use("seaborn-v0_8-whitegrid")
1. System Parameters#
The input parameters are organized in three groups:
Aquifer geometry: streamline length \(L\) and aquifer pore volume distribution (mean \(\bar{V}\), std \(\sigma_{apv}\))
Flow: mean discharge \(Q\)
Transport: retardation factor \(R\), molecular diffusivity \(D_m\), and longitudinal dispersivity \(\alpha_L\)
[2]:
# Aquifer geometry
streamline_length = 100.0 # L [m]
mean_apv = 10000.0 # V_mean [m³] Mean aquifer pore volume
std_apv = 800.0 # sigma_apv [m³] Std from aquifer heterogeneity
# Flow
mean_flow = 120.0 # Q [m³/day]
# Transport
retardation = 2.0 # R [-] Retardation factor
diffusivity_molecular = 1e-4 # D_m [m²/day] Molecular diffusion (~1e-9 m²/s)
dispersivity = 1.0 # alpha_L [m] Longitudinal dispersivity
2. Converting Microdispersion and Molecular Diffusion to Equivalent APVD Spreading#
To compare microdispersion and molecular diffusion with macrodispersion (APVD heterogeneity), we convert spatial spreading to equivalent standard deviation in volume units [m³]. This allows direct variance addition.
Conversion from spatial to volume units#
Spatial spreading is \(\sigma_x\) [m]. To convert to volume units [m³]:
This uses the relationship: pore volume \(V\) = cross-sectional area × length, so \(\bar{V}/L\) [m²] converts length to volume.
Formulas for equivalent spreading in volume units#
After substitution (see derivation below), the formulas simplify to expressions using only input parameters:
Flow-dependence:
\(\sigma_{V,disp}\) (microdispersion) is independent of flow \(Q\): the plume traverses the same pore-scale heterogeneity regardless of velocity — this is a fixed aquifer property
\(\sigma_{V,diff}\) (molecular diffusion) decreases with higher \(Q\) (proportional to \(1/\sqrt{Q}\)): faster flow means less time for molecular diffusion
Derivation
Starting from \(\sigma_x = \sqrt{2 D \tau}\) [m] and converting to volume units via \(\sigma_V = \sigma_x \cdot \bar{V} / L\):
Molecular diffusion (\(D = D_m\), constant):
Microdispersion (\(D_{disp} = \alpha_L v = \alpha_L L / \tau\)):
Note: The retardation factor \(R\) cancels out for microdispersion because \(D_{disp} \propto v \propto 1/\tau\) and the product \(D_{disp} \cdot \tau\) is independent of \(\tau\). Physically: a retarded compound moves slower through the same pore structure, experiencing the same heterogeneity over the same distance.
For molecular diffusion, \(R\) does not cancel: a retarded compound spends more time in the aquifer, giving more time for diffusion to act.
Combined (variances add since processes are independent):
[3]:
# Equivalent spreading in volume units from molecular diffusion
sigma_v_diff = (mean_apv / streamline_length) * np.sqrt(2 * diffusivity_molecular * retardation * mean_apv / mean_flow)
# Equivalent spreading in volume units from mechanical dispersion
# Note: R cancels out because D_disp = alpha_L * v = alpha_L * L / tau, and D_disp * tau = alpha_L * L
sigma_v_disp = mean_apv * np.sqrt(2 * dispersivity / streamline_length)
# Combined spreading (direct formula)
sigma_v_diff_disp = mean_apv * np.sqrt(
2 * diffusivity_molecular * retardation / (streamline_length * mean_flow) + 2 * dispersivity / streamline_length
)
print("Equivalent spreading contributions in volume units [m³]:")
print(f" sigma_v_diff (molecular diffusion): {sigma_v_diff:6.0f} m³")
print(f" sigma_v_disp (mechanical dispersion): {sigma_v_disp:6.0f} m³")
print(f" sigma_v_diff_disp (combined): {sigma_v_diff_disp:6.0f} m³")
Equivalent spreading contributions in volume units [m³]:
sigma_v_diff (molecular diffusion): 18 m³
sigma_v_disp (mechanical dispersion): 1414 m³
sigma_v_diff_disp (combined): 1414 m³
3. Combining Macrodispersion and Microdispersion#
Section 2 computed \(\sigma_{diff,disp}\) [m³]—the spreading from microdispersion and molecular diffusion only.
Now we combine this with the original APVD heterogeneity (macrodispersion) \(\sigma_{apv}\) [m³] to get the total effective standard deviation:
This is the std parameter to use in advection.gamma_infiltration_to_extraction when you want to include microdispersion and molecular diffusion effects in the fast advection module.
When is this valid?
Only when APVD was obtained from streamline analysis (explicit geometry), not from calibration on measurements (where all mixing is already included in the calibrated std). See dispersion scales.
Only for gamma-parameterized (continuous) distributions where the std parameter can be adjusted. For discrete streamline volumes from a flow model, variance addition is not meaningful—use the
diffusionmodule instead.
[4]:
# Total std in volume units combining all sources
sigma_v_total = np.sqrt(std_apv**2 + sigma_v_diff_disp**2)
# Variance fractions
var_total = sigma_v_total**2
frac_apv = std_apv**2 / var_total * 100
frac_diff = sigma_v_diff**2 / var_total * 100
frac_disp = sigma_v_disp**2 / var_total * 100
print("Variance contributions to total spreading:")
print(f" sigma_v_apv = {std_apv:7.0f} m³ ({frac_apv:5.1f}% of variance) - aquifer heterogeneity")
print(f" sigma_v_diff = {sigma_v_diff:7.0f} m³ ({frac_diff:5.1f}% of variance) - molecular diffusion")
print(f" sigma_v_disp = {sigma_v_disp:7.0f} m³ ({frac_disp:5.1f}% of variance) - mechanical dispersion")
print(" " + "-" * 60)
print(f" sigma_v_total= {sigma_v_total:7.0f} m³ (100.0% of variance)")
print("\n" + "=" * 62)
if frac_diff + frac_disp < 5:
print("RECOMMENDATION: Use APVD only (sigma_v_apv)")
print(" -> advection.gamma_* with std=sigma_v_apv")
elif frac_diff < 1:
print("RECOMMENDATION: Use APVD + dispersion")
print(" -> advection.gamma_* with std=sqrt(sigma_v_apv^2 + sigma_v_disp^2)")
elif frac_diff + frac_disp < 25:
print("RECOMMENDATION: Use APVD + diff + disp (constant flow assumed)")
print(" -> advection.gamma_* with std=sigma_v_total")
else:
print("RECOMMENDATION: Use full diffusion module for accuracy")
print(" -> diffusion.infiltration_to_extraction")
print("=" * 62)
Variance contributions to total spreading:
sigma_v_apv = 800 m³ ( 24.2% of variance) - aquifer heterogeneity
sigma_v_diff = 18 m³ ( 0.0% of variance) - molecular diffusion
sigma_v_disp = 1414 m³ ( 75.8% of variance) - mechanical dispersion
------------------------------------------------------------
sigma_v_total= 1625 m³ (100.0% of variance)
==============================================================
RECOMMENDATION: Use APVD + dispersion
-> advection.gamma_* with std=sqrt(sigma_v_apv^2 + sigma_v_disp^2)
==============================================================
Including Microdispersion via Variance Addition (Alternative Fast Approach)#
When using the advection module (instead of the standard diffusion_fast), microdispersion and molecular diffusion can be included by adjusting the gamma std parameter. This works because macrodispersion and microdispersion are independent processes whose variances can be added. See dispersion scales.
# Combined std for advection module:
std_total = np.sqrt(sigma_apv**2 + sigma_diff**2 + sigma_disp**2)
cout = advection.gamma_infiltration_to_extraction(..., std=std_total, ...)
Validity conditions:
APVD from streamline analysis (not calibration — where all mixing is already included)
Gamma-parameterized distribution (not discrete volumes — use
diffusion_fastordiffusioninstead)Flow relatively constant (for \(\sigma_{diff}\) calculation)
4. Numerical Validation#
[5]:
n_days = 350
_tedges = pd.date_range("2019-12-31", periods=n_days + 1, freq="D")
_flow = np.full(n_days, mean_flow)
_cin = np.zeros(n_days)
_cin[50] = 100.0
cin_itedges = np.flatnonzero(np.diff(_cin, prepend=1.0, append=1.0))
flow_itedges = np.flatnonzero(np.diff(_flow, prepend=1.0, append=1.0))
itedges = np.unique(np.concatenate([cin_itedges, flow_itedges]))
tedges = _tedges[itedges]
cin = _cin[itedges[:-1]]
flow = _flow[itedges[:-1]]
cout_tedges = _tedges.copy()
nbins = 5000
streamline_lengths = np.full(nbins, streamline_length)
[6]:
# 1. APVD only (no diffusion/dispersion) - baseline showing aquifer heterogeneity
cout_apvd = advection.gamma_infiltration_to_extraction(
cin=cin,
flow=flow,
tedges=tedges,
cout_tedges=cout_tedges,
mean=mean_apv,
std=std_apv,
n_bins=nbins,
retardation_factor=retardation,
)
# 2. Full diffusion solution with a SINGLE pore volume (no APVD heterogeneity)
# This is the reference for validating the equivalent std approximation
cout_diffusion_single = diffusion.infiltration_to_extraction(
cin=cin,
flow=flow,
tedges=tedges,
cout_tedges=cout_tedges,
aquifer_pore_volumes=np.array([mean_apv]),
streamline_length=np.array([streamline_length]),
molecular_diffusivity=diffusivity_molecular,
longitudinal_dispersivity=dispersivity,
retardation_factor=retardation,
asymptotic_cutoff_sigma=4.0,
)
# 3. APVD approximation using sigma_v_diff_disp (diffusion + dispersion spreading only)
# This should match cout_diffusion_single since both have no APVD heterogeneity
cout_apvd_approx = advection.gamma_infiltration_to_extraction(
cin=cin,
flow=flow,
tedges=tedges,
cout_tedges=cout_tedges,
mean=mean_apv,
std=sigma_v_diff_disp, # Equivalent std from diffusion + dispersion
n_bins=nbins,
retardation_factor=retardation,
)
# 4. Full diffusion solution with MULTIPLE pore volumes (full APVD + diffusion/dispersion)
# This is the slow but physically rigorous approach
from gwtransport import gamma
gamma_bins = gamma.bins(mean=mean_apv, std=std_apv, n_bins=nbins)
pore_volumes = gamma_bins["expected_values"]
cout_diffusion_multi = diffusion.infiltration_to_extraction(
cin=cin,
flow=flow,
tedges=tedges,
cout_tedges=cout_tedges,
aquifer_pore_volumes=pore_volumes,
streamline_length=streamline_lengths,
molecular_diffusivity=diffusivity_molecular,
longitudinal_dispersivity=dispersivity,
retardation_factor=retardation,
asymptotic_cutoff_sigma=4.0,
suppress_dispersion_warning=True, # We know what we're doing here
)
# 5. APVD approximation with combined std (APVD + diffusion + dispersion)
# This is the fast approximation - should match cout_diffusion_multi
cout_apvd_total = advection.gamma_infiltration_to_extraction(
cin=cin,
flow=flow,
tedges=tedges,
cout_tedges=cout_tedges,
mean=mean_apv,
std=sigma_v_total, # Combined std: sqrt(sigma_apv^2 + sigma_diff^2 + sigma_disp^2)
n_bins=nbins,
retardation_factor=retardation,
)
# 6. diffusion_fast with MULTIPLE pore volumes (standard approach)
# flow_out is needed because tedges has non-uniform bins (from signal compression above)
flow_out = np.full(len(cout_tedges) - 1, mean_flow)
cout_diffusion_fast_multi = diffusion_fast.gamma_infiltration_to_extraction(
cin=cin,
flow=flow,
tedges=tedges,
cout_tedges=cout_tedges,
mean=mean_apv,
std=std_apv,
n_bins=nbins,
mean_streamline_length=streamline_length,
mean_molecular_diffusivity=diffusivity_molecular,
mean_longitudinal_dispersivity=dispersivity,
retardation_factor=retardation,
suppress_dispersion_warning=True,
flow_out=flow_out,
)
[7]:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
# Plot around breakthrough
plot_start, plot_end = 100, 349
t_plot = cout_tedges[plot_start:plot_end]
# Left panel: Single pore volume validation
ax1.plot(
t_plot[:-1],
cout_apvd[plot_start : plot_end - 1],
label=f"APVD only (std={std_apv:.0f} m³)",
linewidth=2,
alpha=0.6,
color="gray",
)
ax1.plot(
t_plot[:-1],
cout_diffusion_single[plot_start : plot_end - 1],
label="Diffusion (single pore volume)",
linewidth=2.5,
linestyle="-",
color="black",
)
ax1.plot(
t_plot[:-1],
cout_apvd_approx[plot_start : plot_end - 1],
label=f"Approx: std={sigma_v_diff_disp:.0f} m³",
linewidth=2,
linestyle="--",
color="C0",
)
ax1.set_xlabel("Date")
ax1.set_ylabel("Concentration")
ax1.set_title("Single Pore Volume: Diffusion vs Equivalent Std")
ax1.legend()
ax1.grid(True, alpha=0.3)
# Right panel: Multiple pore volumes validation
ax2.plot(
t_plot[:-1],
cout_apvd[plot_start : plot_end - 1],
label=f"APVD only (std={std_apv:.0f} m³)",
linewidth=2,
alpha=0.6,
color="gray",
)
ax2.plot(
t_plot[:-1],
cout_diffusion_multi[plot_start : plot_end - 1],
label="Diffusion exact (multi PV)",
linewidth=2.5,
linestyle="-",
color="black",
)
ax2.plot(
t_plot[:-1],
cout_diffusion_fast_multi[plot_start : plot_end - 1],
label="diffusion_fast (standard)",
linewidth=2,
linestyle="-",
color="C2",
)
ax2.plot(
t_plot[:-1],
cout_apvd_total[plot_start : plot_end - 1],
label=f"Approx: std={sigma_v_total:.0f} m³",
linewidth=2,
linestyle="--",
color="C1",
)
ax2.set_xlabel("Date")
ax2.set_ylabel("Concentration")
ax2.set_title("Multi Pore Volume: Exact vs diffusion_fast vs Combined Std")
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Summary#
Formulas for equivalent APVD spreading [m³]#
Key takeaways#
Standard approach: Use
diffusion_fastfor transport modeling — it handles macrodispersion, microdispersion, and molecular diffusionMacrodispersion (APVD) depends on aquifer properties and boundary conditions; microdispersion (\(\alpha_L\)) is an aquifer property; molecular diffusion (\(D_m\)) depends on compound
Variance combination (\(\sigma_{total}\)) is an alternative fast approach but only valid for gamma-parameterized APVD from streamline analysis — not for calibrated APVD or discrete volumes
When APVD is calibrated from measurements using
advection, all mixing is already included — do not add on top. When calibrating withdiffusion_fastordiffusion, the components are separated automatically