Coverage for src/gwtransport/gamma.py: 0%

63 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 20:54 +0000

1""" 

2Gamma Distribution Utilities for Aquifer Pore Volume Heterogeneity. 

3 

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 three-parameter model (shape, scale, location) for representing the natural 

7variability in flow path lengths and residence times within aquifer systems. In 

8heterogeneous aquifers, water travels through multiple flow paths with different pore 

9volumes; the location parameter additionally represents a guaranteed minimum pore volume 

10(for example, immobile porosity or a geometric minimum travel distance). 

11 

12Parameterizations 

13----------------- 

14Two equivalent parameterizations are supported, each optionally with a location shift: 

15 

16- **(mean, std, loc)** — physically intuitive. ``mean`` is the total expected value, 

17 ``std`` is the spread (invariant under shift), and ``loc`` is the lower bound of 

18 support. Constraint: ``0 <= loc < mean``. 

19- **(alpha, beta, loc)** — scipy-style. ``alpha`` is shape, ``beta`` is scale, and 

20 ``loc`` is the lower bound of support. Constraint: ``alpha > 0``, ``beta > 0``, 

21 ``loc >= 0``. 

22 

23Conversion formulas (with constraint ``mean > loc``): 

24 

25 alpha = ((mean - loc) / std) ** 2 

26 beta = std ** 2 / (mean - loc) 

27 mean = alpha * beta + loc 

28 std = sqrt(alpha) * beta 

29 

30When ``loc == 0`` the three-parameter model reduces to the standard two-parameter 

31gamma distribution. 

32 

33Streamtube discretization by :func:`bins` is always into bins of **equal probability mass**. 

34 

35Available functions: 

36 

37- :func:`parse_parameters` - Resolve either ``(mean, std)`` or ``(alpha, beta)``, together with an 

38 optional ``loc``, into the validated ``(alpha, beta, loc)`` triple the rest of the package works with. 

39 Exactly one of the two pairs must be supplied; positivity, ``0 <= loc < mean`` and finiteness are 

40 enforced, otherwise ``ValueError`` is raised. 

41 

42- :func:`mean_std_loc_to_alpha_beta` - Convert the physically intuitive ``(mean, std, loc)`` parameters 

43 to gamma shape and scale from the excess-over-``loc`` moments, 

44 ``alpha = ((mean - loc) / std) ** 2`` and ``beta = std ** 2 / (mean - loc)``. 

45 

46- :func:`bins` - Split the (shifted) gamma distribution at the ``n_bins + 1`` uniform quantile edges, so 

47 every bin (streamtube) carries probability mass ``1 / n_bins``. Returns a dict of arrays: the bin edges 

48 (``lower_bound``, ``upper_bound``, ``edges``; the first lower bound is ``loc`` and the last upper bound 

49 is infinite), the per-bin conditional mean pore volume (``expected_values``) that serves as the 

50 streamtube pore volume in transport calculations, and the per-bin ``probability_mass``. 

51 

52This file is part of gwtransport which is released under AGPL-3.0 license. 

53See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details. 

54""" 

55 

56import numpy as np 

57import numpy.typing as npt 

58from scipy.stats import gamma as gamma_dist 

59 

60# Numerical-envelope guard threshold for bins(): when alpha*eps exceeds this fraction of the 

61# equal-mass quantile gap 1/n_bins, alpha + 1 == alpha to machine precision and the per-bin 

62# expected values degrade to noise. 

63_HUGE_ALPHA_GAP_FRACTION = 0.01 

64 

65 

66def parse_parameters( 

67 *, 

68 mean: float | None = None, 

69 std: float | None = None, 

70 loc: float = 0.0, 

71 alpha: float | None = None, 

72 beta: float | None = None, 

73) -> tuple[float, float, float]: 

74 """ 

75 Parse parameters for gamma distribution. 

76 

77 Either ``(mean, std)`` or ``(alpha, beta)`` must be provided. ``loc`` is optional 

78 and defaults to 0, which recovers the standard two-parameter gamma distribution. 

79 

80 Parameters 

81 ---------- 

82 mean : float, optional 

83 Mean of the gamma distribution. Must be strictly greater than ``loc``. 

84 std : float, optional 

85 Standard deviation of the gamma distribution. Must be positive. See 

86 :ref:`concept-dispersion-scales` for what std represents depending 

87 on APVD source. ``std`` is invariant under the ``loc`` shift. 

88 loc : float, optional 

89 Location (horizontal shift) of the gamma distribution; the lower bound of 

90 support. Must satisfy ``loc >= 0`` and, when ``mean`` is supplied, 

91 ``loc < mean``. Default is ``0.0``. 

92 alpha : float, optional 

93 Shape parameter of gamma distribution (must be > 0). 

94 beta : float, optional 

95 Scale parameter of gamma distribution (must be > 0). 

96 

97 Returns 

98 ------- 

99 alpha : float 

100 Shape parameter of gamma distribution. 

101 beta : float 

102 Scale parameter of gamma distribution. 

103 loc : float 

104 Location parameter of gamma distribution. 

105 

106 Raises 

107 ------ 

108 ValueError 

109 If neither ``(mean, std)`` nor ``(alpha, beta)`` is provided, if both pairs 

110 are provided, if only one of a pair is provided, if ``alpha`` or ``beta`` are 

111 not positive, if ``loc`` is negative, if the resolved ``alpha``, ``beta``, or 

112 ``loc`` is not finite, or if ``mean <= loc``. 

113 """ 

114 if loc < 0: 

115 msg = "loc must be non-negative" 

116 raise ValueError(msg) 

117 

118 if (alpha is None) != (beta is None): 

119 msg = "alpha and beta must both be provided or both be None." 

120 raise ValueError(msg) 

121 

122 if alpha is not None and (mean is not None or std is not None): 

123 msg = "Provide either (alpha, beta) or (mean, std), not both." 

124 raise ValueError(msg) 

125 

126 if (mean is None) != (std is None): 

127 msg = "mean and std must both be provided or both be None." 

128 raise ValueError(msg) 

129 

130 # The ``or beta is None`` is redundant at runtime (the check above pairs them) but lets the 

131 # type checker narrow ``beta`` to a float on the fall-through return. 

132 if alpha is None or beta is None: 

133 if mean is None or std is None: 

134 msg = "Either (alpha, beta) or (mean, std) must be provided." 

135 raise ValueError(msg) 

136 # mean_std_loc_to_alpha_beta enforces std>0 and mean>loc, which together with 

137 # loc>=0 guarantee alpha=(mean-loc)**2/std**2 > 0 and beta=std**2/(mean-loc) > 0. 

138 alpha, beta = mean_std_loc_to_alpha_beta(mean=mean, std=std, loc=loc) 

139 elif alpha <= 0 or beta <= 0: 

140 msg = "Alpha and beta must be positive" 

141 raise ValueError(msg) 

142 

143 # A non-finite alpha/beta/loc slips past the comparisons above (``nan <= 0`` and ``nan < 0`` are 

144 # both False), producing an all-NaN distribution instead of a clear error. Reject it loudly. 

145 if not (np.isfinite(alpha) and np.isfinite(beta) and np.isfinite(loc)): 

146 msg = "alpha, beta, and loc must be finite." 

147 raise ValueError(msg) 

148 

149 return alpha, beta, loc 

150 

151 

152def mean_std_loc_to_alpha_beta(*, mean: float, std: float, loc: float = 0.0) -> tuple[float, float]: 

153 """ 

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

155 

156 The two-parameter shape/scale representation (``alpha``, ``beta``) is derived from 

157 the excess-over-``loc`` moments: ``mean_excess = mean - loc``, ``std_excess = std``. 

158 

159 Parameters 

160 ---------- 

161 mean : float 

162 Mean of the gamma distribution. Must be strictly greater than ``loc``. 

163 std : float 

164 Standard deviation of the gamma distribution. Must be positive. See 

165 :ref:`concept-dispersion-scales` for what std represents depending 

166 on APVD source. ``std`` is invariant under the ``loc`` shift. 

167 loc : float, optional 

168 Location (horizontal shift) of the gamma distribution. Must satisfy 

169 ``0 <= loc < mean``. Default is ``0.0``. 

170 

171 Returns 

172 ------- 

173 alpha : float 

174 Shape parameter of gamma distribution. 

175 beta : float 

176 Scale parameter of gamma distribution. 

177 

178 Raises 

179 ------ 

180 ValueError 

181 If ``std`` is not positive, if ``loc`` is negative, or if ``mean <= loc``. 

182 

183 See Also 

184 -------- 

185 parse_parameters : Parse and validate gamma distribution parameters. 

186 

187 Examples 

188 -------- 

189 >>> from gwtransport.gamma import mean_std_loc_to_alpha_beta 

190 >>> mean_pore_volume = 30000.0 # m³ 

191 >>> std_pore_volume = 8100.0 # m³ 

192 >>> alpha, beta = mean_std_loc_to_alpha_beta( 

193 ... mean=mean_pore_volume, std=std_pore_volume 

194 ... ) 

195 >>> print(f"Shape parameter (alpha): {alpha:.2f}") 

196 Shape parameter (alpha): 13.72 

197 >>> print(f"Scale parameter (beta): {beta:.2f}") 

198 Scale parameter (beta): 2187.00 

199 

200 With a 5000 m³ minimum pore volume: 

201 

202 >>> alpha, beta = mean_std_loc_to_alpha_beta(mean=30000.0, std=8100.0, loc=5000.0) 

203 >>> print(f"Shape parameter (alpha): {alpha:.2f}") 

204 Shape parameter (alpha): 9.53 

205 >>> print(f"Scale parameter (beta): {beta:.2f}") 

206 Scale parameter (beta): 2624.40 

207 """ 

208 if std <= 0: 

209 msg = "std must be positive" 

210 raise ValueError(msg) 

211 if loc < 0: 

212 msg = "loc must be non-negative" 

213 raise ValueError(msg) 

214 if mean <= loc: 

215 msg = "mean must be strictly greater than loc" 

216 raise ValueError(msg) 

217 

218 mean_excess = mean - loc 

219 alpha = mean_excess**2 / std**2 

220 beta = std**2 / mean_excess 

221 return alpha, beta 

222 

223 

224def bins( 

225 *, 

226 mean: float | None = None, 

227 std: float | None = None, 

228 loc: float = 0.0, 

229 alpha: float | None = None, 

230 beta: float | None = None, 

231 n_bins: int = 100, 

232) -> dict[str, npt.NDArray[np.floating]]: 

233 """ 

234 Divide a (shifted) gamma distribution into equal-probability-mass bins and compute bin properties. 

235 

236 The distribution is split at the ``n_bins + 1`` uniform quantile edges, so every bin 

237 (streamtube) carries probability mass ``1 / n_bins``. 

238 

239 Parameters 

240 ---------- 

241 mean : float, optional 

242 Mean of the gamma distribution. Must be strictly greater than ``loc``. 

243 std : float, optional 

244 Standard deviation of the gamma distribution. Must be positive. 

245 loc : float, optional 

246 Location (horizontal shift) of the gamma distribution; the lower bound of 

247 support. Must satisfy ``0 <= loc < mean`` (or ``loc >= 0`` when using 

248 alpha/beta). Default is ``0.0``. 

249 alpha : float, optional 

250 Shape parameter of gamma distribution (must be > 0). 

251 beta : float, optional 

252 Scale parameter of gamma distribution (must be > 0). 

253 n_bins : int, optional 

254 Number of bins to divide the gamma distribution (must be >= 2). Default is 100. 

255 

256 Returns 

257 ------- 

258 dict 

259 Dictionary with keys of type str and values of type numpy.ndarray: 

260 

261 - ``lower_bound``: lower bounds of bins (first one equals ``loc``) 

262 - ``upper_bound``: upper bounds of bins (last one is inf) 

263 - ``edges``: bin edges (lower_bound[0], upper_bound[0], ..., upper_bound[-1]) 

264 - ``expected_values``: expected values in bins. Is what you would expect to 

265 observe if you repeatedly sampled from the probability distribution, but only 

266 considered samples that fall within that particular bin. 

267 - ``probability_mass``: probability mass in bins (invariant under ``loc`` shift). 

268 

269 Raises 

270 ------ 

271 ValueError 

272 If ``n_bins`` is not greater than 1, or if parameter validation in 

273 :func:`parse_parameters` fails. Also raised for numerically-degenerate requests that 

274 would otherwise return silently-wrong structure: an ``alpha`` so large that 

275 ``alpha + 1 == alpha`` in float64 relative to the ``1 / n_bins`` quantile gap (the 

276 distribution is numerically a point mass), or a bin whose expected value underflows 

277 to ``loc``. 

278 

279 See Also 

280 -------- 

281 mean_std_loc_to_alpha_beta : Convert mean/std/loc to alpha/beta parameters. 

282 gwtransport.advection.gamma_infiltration_to_extraction : Use bins for transport modeling. 

283 :ref:`concept-gamma-distribution` : Two-parameter pore volume model. 

284 :ref:`concept-gamma-loc` : Shifted gamma with minimum pore volume. 

285 :ref:`concept-dispersion-scales` : What ``std`` represents (macrodispersion vs total spreading). 

286 :ref:`assumption-gamma-distribution` : When gamma distribution is adequate. 

287 

288 Examples 

289 -------- 

290 Create equal-mass bins for a gamma distribution: 

291 

292 >>> from gwtransport.gamma import bins 

293 >>> result = bins(mean=30000.0, std=8100.0, n_bins=5) 

294 >>> print(f"Number of bins: {len(result['probability_mass'])}") 

295 Number of bins: 5 

296 

297 With a location parameter representing a minimum pore volume: 

298 

299 >>> result = bins(mean=30000.0, std=8100.0, loc=5000.0, n_bins=5) 

300 >>> float(result["edges"][0]) 

301 5000.0 

302 """ 

303 alpha, beta, loc = parse_parameters(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta) 

304 

305 if n_bins <= 1: 

306 # Validate before np.linspace: a negative n_bins would otherwise surface as 

307 # numpy's opaque "Number of samples ... must be non-negative" error. 

308 msg = "Number of bins must be greater than 1" 

309 raise ValueError(msg) 

310 

311 quantile_edges = np.linspace(0, 1, n_bins + 1) 

312 

313 # Guard the numerical cliff of the closed-form conditional mean below: an alpha so large that 

314 # alpha*eps exceeds ~1% of the equal-mass quantile gap 1/n_bins makes alpha+1 == alpha to machine 

315 # precision, so the conditional means degrade to noise. Only reachable with a near-degenerate 

316 # (delta-like) distribution. 

317 if alpha * np.finfo(float).eps * n_bins > _HUGE_ALPHA_GAP_FRACTION: 

318 msg = ( 

319 f"alpha ({alpha:.3g}) is too large for float64 bin resolution: alpha*eps exceeds 1% of " 

320 f"the equal-mass quantile gap (1/{n_bins}), so alpha+1 == alpha to machine precision and " 

321 "the per-bin expected values are numerical noise. The distribution is effectively a point " 

322 "mass at alpha*beta + loc; use a larger std/(mean-loc) or fewer bins." 

323 ) 

324 raise ValueError(msg) 

325 

326 # Unshifted bin edges for the standard Gamma(alpha, beta) distribution, then shift 

327 unshifted_edges = gamma_dist.ppf(quantile_edges, alpha, scale=beta) 

328 bin_edges = unshifted_edges + loc 

329 probability_mass = np.diff(quantile_edges) # probability mass for each bin 

330 

331 # Conditional mean within each bin for the unshifted distribution, then shift by loc. 

332 # E[X | a <= X < b] for X ~ Gamma(alpha, beta) uses the identity 

333 # E[X * 1_{a<=X<b}] = alpha * beta * (F_{alpha+1}(b) - F_{alpha+1}(a)) 

334 # where F_{alpha+1} is the CDF of Gamma(alpha+1, scale=beta) (equivalently the regularized 

335 # lower incomplete gamma P(alpha+1, b/beta) - P(alpha+1, a/beta)). 

336 cdf_alpha_plus_1 = gamma_dist.cdf(unshifted_edges, alpha + 1, scale=beta) 

337 diff_alpha_plus_1 = np.diff(cdf_alpha_plus_1) 

338 

339 # Pre-shift conditional mean of the excess over loc. Every positive-mass bin of a Gamma(alpha, beta) 

340 # has a strictly positive conditional mean, so this must be > 0; a value <= 0 signals an underflow / 

341 # cancellation of the CDF difference (very small alpha or extremely fine bins) that would emit a 

342 # numerically-zero or negative pore volume. Test this pre-shift quantity rather than the shifted 

343 # expected value: for loc > 0 a benign ``loc + tiny_positive_excess == loc`` rounding would otherwise 

344 # be misread as underflow and reject correct, usable output for shifted heterogeneous APVDs. 

345 cond_mean_excess = beta * alpha * diff_alpha_plus_1 / probability_mass 

346 if np.any(cond_mean_excess <= 0.0): 

347 msg = ( 

348 "A bin's conditional expected value underflowed to loc (its excess conditional mean over loc " 

349 "is not strictly positive). This happens for very small alpha or extremely fine bins where " 

350 "the CDF difference underflows. Use fewer bins or a larger alpha." 

351 ) 

352 raise ValueError(msg) 

353 

354 expected_values = cond_mean_excess + loc 

355 

356 return { 

357 "lower_bound": bin_edges[:-1], 

358 "upper_bound": bin_edges[1:], 

359 "edges": bin_edges, 

360 "expected_values": expected_values, 

361 "probability_mass": probability_mass, 

362 }