Coverage for src / gwtransport / gamma.py: 87%
60 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 18:51 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 18:51 +0000
1"""
2Gamma Distribution Utilities for Aquifer Pore Volume Heterogeneity.
4This module provides utilities for working with gamma distributions to model heterogeneous
5aquifer pore volumes in groundwater transport analysis. The gamma distribution offers a
6flexible two-parameter model for representing the natural variability in flow path lengths
7and residence times within aquifer systems. In heterogeneous aquifers, water travels through
8multiple flow paths with different pore volumes, and the gamma distribution provides a
9realistic representation of this heterogeneity.
11Available functions:
13- :func:`parse_parameters` - Parse and validate gamma distribution parameters from either
14 (alpha, beta) or (mean, std). Ensures exactly one parameter pair is provided and validates
15 positivity constraints.
17- :func:`mean_std_to_alpha_beta` - Convert physically intuitive (mean, std) parameters to
18 gamma shape/scale parameters. Uses formulas: alpha = mean^2 / std^2 and beta = std^2 / mean.
20- :func:`alpha_beta_to_mean_std` - Convert gamma (alpha, beta) parameters back to (mean, std)
21 for physical interpretation. Uses formulas: mean = alpha * beta and std = sqrt(alpha) * beta.
23- :func:`bins` - Primary function for transport modeling. Creates discrete probability bins from
24 continuous gamma distribution with equal-probability bins (default) or custom quantile edges.
25 Returns bin edges, expected values (mean pore volume within each bin), and probability masses
26 (weight in transport calculations).
28- :func:`bin_masses` - Calculate probability mass for custom bin edges using incomplete gamma
29 function. Lower-level function used internally by bins().
31This file is part of gwtransport which is released under AGPL-3.0 license.
32See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details.
33"""
35import numpy as np
36import numpy.typing as npt
37from scipy.special import gammainc
38from scipy.stats import gamma as gamma_dist
41def parse_parameters(
42 *,
43 alpha: float | None = None,
44 beta: float | None = None,
45 mean: float | None = None,
46 std: float | None = None,
47) -> tuple[float, float]:
48 """
49 Parse parameters for gamma distribution.
51 Either alpha and beta or mean and std must be provided.
53 Parameters
54 ----------
55 alpha : float, optional
56 Shape parameter of gamma distribution (must be > 0)
57 beta : float, optional
58 Scale parameter of gamma distribution (must be > 0)
59 mean : float, optional
60 Mean of the gamma distribution.
61 std : float, optional
62 Standard deviation of the gamma distribution. See
63 :ref:`concept-dispersion-scales` for what std represents depending
64 on APVD source.
66 Returns
67 -------
68 alpha : float
69 Shape parameter of gamma distribution
70 beta : float
71 Scale parameter of gamma distribution
73 Raises
74 ------
75 ValueError
76 If both alpha and beta are None or if both mean and std are None.
77 If alpha or beta are not positive.
78 """
79 if (alpha is None) != (beta is None):
80 msg = "alpha and beta must both be provided or both be None."
81 raise ValueError(msg)
83 if alpha is None or beta is None:
84 if mean is None or std is None:
85 msg = "Either (alpha, beta) or (mean, std) must be provided."
86 raise ValueError(msg)
88 alpha, beta = mean_std_to_alpha_beta(mean=mean, std=std)
90 if alpha <= 0 or beta <= 0:
91 msg = "Alpha and beta must be positive"
92 raise ValueError(msg)
94 return alpha, beta
97def mean_std_to_alpha_beta(*, mean: float, std: float) -> tuple[float, float]:
98 """
99 Convert mean and standard deviation of gamma distribution to shape and scale parameters.
101 Parameters
102 ----------
103 mean : float
104 Mean of the gamma distribution.
105 std : float
106 Standard deviation of the gamma distribution. See
107 :ref:`concept-dispersion-scales` for what std represents depending
108 on APVD source.
110 Returns
111 -------
112 alpha : float
113 Shape parameter of gamma distribution
114 beta : float
115 Scale parameter of gamma distribution
117 See Also
118 --------
119 alpha_beta_to_mean_std : Convert shape and scale parameters to mean and std
120 parse_parameters : Parse and validate gamma distribution parameters
122 Examples
123 --------
124 >>> from gwtransport.gamma import mean_std_to_alpha_beta
125 >>> mean_pore_volume = 30000.0 # m³
126 >>> std_pore_volume = 8100.0 # m³
127 >>> alpha, beta = mean_std_to_alpha_beta(mean=mean_pore_volume, std=std_pore_volume)
128 >>> print(f"Shape parameter (alpha): {alpha:.2f}")
129 Shape parameter (alpha): 13.72
130 >>> print(f"Scale parameter (beta): {beta:.2f}")
131 Scale parameter (beta): 2187.00
132 """
133 if std <= 0:
134 msg = "std must be positive"
135 raise ValueError(msg)
136 if mean <= 0:
137 msg = "mean must be positive"
138 raise ValueError(msg)
140 alpha = mean**2 / std**2
141 beta = std**2 / mean
142 return alpha, beta
145def alpha_beta_to_mean_std(*, alpha: float, beta: float) -> tuple[float, float]:
146 """
147 Convert shape and scale parameters of gamma distribution to mean and standard deviation.
149 Parameters
150 ----------
151 alpha : float
152 Shape parameter of the gamma distribution.
153 beta : float
154 Scale parameter of the gamma distribution.
156 Returns
157 -------
158 mean : float
159 Mean of the gamma distribution.
160 std : float
161 Standard deviation of the gamma distribution.
163 See Also
164 --------
165 mean_std_to_alpha_beta : Convert mean and std to shape and scale parameters
166 parse_parameters : Parse and validate gamma distribution parameters
168 Examples
169 --------
170 >>> from gwtransport.gamma import alpha_beta_to_mean_std
171 >>> alpha = 13.72 # shape parameter
172 >>> beta = 2187.0 # scale parameter
173 >>> mean, std = alpha_beta_to_mean_std(alpha=alpha, beta=beta)
174 >>> print(f"Mean pore volume: {mean:.0f} m³") # doctest: +ELLIPSIS
175 Mean pore volume: 3000... m³
176 >>> print(f"Std pore volume: {std:.0f} m³") # doctest: +ELLIPSIS
177 Std pore volume: 810... m³
178 """
179 mean = alpha * beta
180 std = np.sqrt(alpha) * beta
181 return mean, std
184def bins(
185 *,
186 alpha: float | None = None,
187 beta: float | None = None,
188 mean: float | None = None,
189 std: float | None = None,
190 n_bins: int = 100,
191 quantile_edges: np.ndarray | None = None,
192) -> dict[str, npt.NDArray[np.floating]]:
193 """
194 Divide gamma distribution into bins and compute various bin properties.
196 If n_bins is provided, the gamma distribution is divided into n_bins equal-mass bins.
197 If quantile_edges is provided, the gamma distribution is divided into bins defined by
198 the quantile edges. The quantile edges must be in the range [0, 1] and of size n_bins + 1.
199 The first and last quantile edges must be 0 and 1, respectively.
201 Parameters
202 ----------
203 alpha : float, optional
204 Shape parameter of gamma distribution (must be > 0)
205 beta : float, optional
206 Scale parameter of gamma distribution (must be > 0)
207 mean : float, optional
208 Mean of the gamma distribution.
209 std : float, optional
210 Standard deviation of the gamma distribution.
211 n_bins : int, optional
212 Number of bins to divide the gamma distribution (must be > 1). Default is 100.
213 quantile_edges : array-like, optional
214 Quantile edges for binning. Must be in the range [0, 1] and of size n_bins + 1.
215 The first and last quantile edges must be 0 and 1, respectively.
216 If provided, n_bins is ignored.
218 Returns
219 -------
220 dict
221 Dictionary with keys of type str and values of type numpy.ndarray:
223 - ``lower_bound``: lower bounds of bins (first one is 0)
224 - ``upper_bound``: upper bounds of bins (last one is inf)
225 - ``edges``: bin edges (lower_bound[0], upper_bound[0], ..., upper_bound[-1])
226 - ``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
227 - ``probability_mass``: probability mass in bins
229 See Also
230 --------
231 bin_masses : Calculate probability mass for bins
232 mean_std_to_alpha_beta : Convert mean/std to alpha/beta parameters
233 gwtransport.advection.gamma_infiltration_to_extraction : Use bins for transport modeling
234 :ref:`concept-gamma-distribution` : Two-parameter pore volume model
235 :ref:`concept-dispersion-scales` : What ``std`` represents (macrodispersion vs total spreading)
236 :ref:`assumption-gamma-distribution` : When gamma distribution is adequate
238 Examples
239 --------
240 Create equal-mass bins for a gamma distribution:
242 >>> from gwtransport.gamma import bins
243 >>> # Define gamma distribution using mean and std
244 >>> result = bins(mean=30000.0, std=8100.0, n_bins=5)
246 Create bins with custom quantile edges:
248 >>> import numpy as np
249 >>> quantiles = np.array([0.0, 0.25, 0.5, 0.75, 1.0])
250 >>> result = bins(mean=30000.0, std=8100.0, quantile_edges=quantiles)
251 >>> print(f"Number of bins: {len(result['probability_mass'])}")
252 Number of bins: 4
253 """
254 alpha, beta = parse_parameters(alpha=alpha, beta=beta, mean=mean, std=std)
256 # Calculate boundaries for equal mass bins
257 # If quantile_edges is provided, use it (n_bins is ignored)
258 # Otherwise, use n_bins (which defaults to 100)
259 if quantile_edges is not None:
260 n_bins = len(quantile_edges) - 1
261 else:
262 quantile_edges = np.linspace(0, 1, n_bins + 1) # includes 0 and 1
264 if n_bins <= 1:
265 msg = "Number of bins must be greater than 1"
266 raise ValueError(msg)
268 bin_edges = gamma_dist.ppf(quantile_edges, alpha, scale=beta)
269 probability_mass = np.diff(quantile_edges) # probability mass for each bin
271 # Calculate expected value for each bin
272 diff_alpha_plus_1 = bin_masses(alpha=alpha + 1, beta=beta, bin_edges=bin_edges)
273 expected_values = beta * alpha * diff_alpha_plus_1 / probability_mass
275 return {
276 "lower_bound": bin_edges[:-1],
277 "upper_bound": bin_edges[1:],
278 "edges": bin_edges,
279 "expected_values": expected_values,
280 "probability_mass": probability_mass,
281 }
284def bin_masses(*, alpha: float, beta: float, bin_edges: npt.ArrayLike) -> npt.NDArray[np.floating]:
285 """
286 Calculate probability mass for each bin in gamma distribution.
288 Is the area under the gamma distribution PDF between the bin edges.
290 Parameters
291 ----------
292 alpha : float
293 Shape parameter of gamma distribution (must be > 0)
294 beta : float
295 Scale parameter of gamma distribution (must be > 0)
296 bin_edges : array-like
297 Bin edges. Array of increasing values of size len(bins) + 1.
298 Must be > 0.
300 Returns
301 -------
302 numpy.ndarray
303 Probability mass for each bin
304 """
305 # Convert inputs to numpy arrays
306 bin_edges = np.asarray(bin_edges)
308 # Validate inputs
309 if alpha <= 0 or beta <= 0:
310 msg = "Alpha and beta must be positive"
311 raise ValueError(msg)
312 if len(bin_edges) < 2: # noqa: PLR2004
313 msg = "Bin edges must contain at least two values"
314 raise ValueError(msg)
315 if np.any(np.diff(bin_edges) < 0):
316 msg = "Bin edges must be increasing"
317 raise ValueError(msg)
318 if np.any(bin_edges < 0):
319 msg = "Bin edges must be positive"
320 raise ValueError(msg)
321 val = gammainc(alpha, bin_edges / beta)
322 return val[1:] - val[:-1]