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

67 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 21:17 +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 

33Available functions: 

34 

35- :func:`parse_parameters` - Parse and validate gamma distribution parameters from either 

36 (mean, std, loc) or (alpha, beta, loc). Requires exactly one parameter pair and raises 

37 ``ValueError`` if both are supplied; validates positivity and ordering constraints. 

38 

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

40 to gamma shape/scale parameters. 

41 

42- :func:`alpha_beta_loc_to_mean_std` - Convert gamma (alpha, beta, loc) parameters back to 

43 (mean, std) for physical interpretation. 

44 

45- :func:`bins` - Primary function for transport modeling. Creates discrete probability bins 

46 from the (optionally shifted) gamma distribution with equal-probability bins (default) or 

47 custom quantile edges. Returns bin edges, expected values (mean pore volume within each 

48 bin), and probability masses (weight in transport calculations). 

49 

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

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

52""" 

53 

54import numpy as np 

55import numpy.typing as npt 

56from scipy.stats import gamma as gamma_dist 

57 

58 

59def parse_parameters( 

60 *, 

61 mean: float | None = None, 

62 std: float | None = None, 

63 loc: float = 0.0, 

64 alpha: float | None = None, 

65 beta: float | None = None, 

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

67 """ 

68 Parse parameters for gamma distribution. 

69 

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

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

72 

73 Parameters 

74 ---------- 

75 mean : float, optional 

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

77 std : float, optional 

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

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

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

81 loc : float, optional 

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

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

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

85 alpha : float, optional 

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

87 beta : float, optional 

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

89 

90 Returns 

91 ------- 

92 alpha : float 

93 Shape parameter of gamma distribution. 

94 beta : float 

95 Scale parameter of gamma distribution. 

96 loc : float 

97 Location parameter of gamma distribution. 

98 

99 Raises 

100 ------ 

101 ValueError 

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

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

104 not positive, if ``loc`` is negative, or if ``mean <= loc``. 

105 """ 

106 if loc < 0: 

107 msg = "loc must be non-negative" 

108 raise ValueError(msg) 

109 

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

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

112 raise ValueError(msg) 

113 

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

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

116 raise ValueError(msg) 

117 

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

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

120 if alpha is None or beta is None: 

121 if mean is None or std is None: 

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

123 raise ValueError(msg) 

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

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

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

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

128 msg = "Alpha and beta must be positive" 

129 raise ValueError(msg) 

130 

131 return alpha, beta, loc 

132 

133 

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

135 """ 

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

137 

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

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

140 

141 Parameters 

142 ---------- 

143 mean : float 

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

145 std : float 

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

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

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

149 loc : float, optional 

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

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

152 

153 Returns 

154 ------- 

155 alpha : float 

156 Shape parameter of gamma distribution. 

157 beta : float 

158 Scale parameter of gamma distribution. 

159 

160 Raises 

161 ------ 

162 ValueError 

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

164 

165 See Also 

166 -------- 

167 alpha_beta_loc_to_mean_std : Convert shape/scale/loc parameters to mean and std. 

168 parse_parameters : Parse and validate gamma distribution parameters. 

169 

170 Examples 

171 -------- 

172 >>> from gwtransport.gamma import mean_std_loc_to_alpha_beta 

173 >>> mean_pore_volume = 30000.0 # m³ 

174 >>> std_pore_volume = 8100.0 # m³ 

175 >>> alpha, beta = mean_std_loc_to_alpha_beta( 

176 ... mean=mean_pore_volume, std=std_pore_volume 

177 ... ) 

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

179 Shape parameter (alpha): 13.72 

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

181 Scale parameter (beta): 2187.00 

182 

183 With a 5000 m³ minimum pore volume: 

184 

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

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

187 Shape parameter (alpha): 9.53 

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

189 Scale parameter (beta): 2624.40 

190 """ 

191 if std <= 0: 

192 msg = "std must be positive" 

193 raise ValueError(msg) 

194 if loc < 0: 

195 msg = "loc must be non-negative" 

196 raise ValueError(msg) 

197 if mean <= loc: 

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

199 raise ValueError(msg) 

200 

201 mean_excess = mean - loc 

202 alpha = mean_excess**2 / std**2 

203 beta = std**2 / mean_excess 

204 return alpha, beta 

205 

206 

207def alpha_beta_loc_to_mean_std(*, alpha: float, beta: float, loc: float = 0.0) -> tuple[float, float]: 

208 """ 

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

210 

211 Parameters are validated via :func:`parse_parameters`, which raises ``ValueError`` if 

212 ``alpha`` or ``beta`` are non-positive or ``loc`` is negative. 

213 

214 Parameters 

215 ---------- 

216 alpha : float 

217 Shape parameter of the gamma distribution. Must be positive. 

218 beta : float 

219 Scale parameter of the gamma distribution. Must be positive. 

220 loc : float, optional 

221 Location (horizontal shift) of the gamma distribution. Must be non-negative. 

222 Default is ``0.0``. 

223 

224 Returns 

225 ------- 

226 mean : float 

227 Mean of the gamma distribution, equal to ``alpha * beta + loc``. 

228 std : float 

229 Standard deviation of the gamma distribution, equal to ``sqrt(alpha) * beta``. 

230 ``std`` is invariant under the ``loc`` shift. 

231 

232 See Also 

233 -------- 

234 mean_std_loc_to_alpha_beta : Convert mean/std/loc to shape and scale parameters. 

235 parse_parameters : Parse and validate gamma distribution parameters. 

236 

237 Examples 

238 -------- 

239 >>> from gwtransport.gamma import alpha_beta_loc_to_mean_std 

240 >>> alpha = 13.72 # shape parameter 

241 >>> beta = 2187.0 # scale parameter 

242 >>> mean, std = alpha_beta_loc_to_mean_std(alpha=alpha, beta=beta) 

243 >>> print(f"Mean pore volume: {mean:.0f} m³") 

244 Mean pore volume: 30006 m³ 

245 >>> print(f"Std pore volume: {std:.0f} m³") 

246 Std pore volume: 8101 m³ 

247 """ 

248 parse_parameters(alpha=alpha, beta=beta, loc=loc) 

249 return alpha * beta + loc, np.sqrt(alpha) * beta 

250 

251 

252def bins( 

253 *, 

254 mean: float | None = None, 

255 std: float | None = None, 

256 loc: float = 0.0, 

257 alpha: float | None = None, 

258 beta: float | None = None, 

259 n_bins: int = 100, 

260 quantile_edges: npt.ArrayLike | None = None, 

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

262 """ 

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

264 

265 If ``n_bins`` is provided, the gamma distribution is divided into ``n_bins`` 

266 equal-mass bins. If ``quantile_edges`` is provided, the distribution is divided 

267 into bins defined by those quantile edges. The quantile edges must be a strictly 

268 increasing 1-D array of at least 3 entries (>= 2 bins) in ``[0, 1]``, with the 

269 first and last entries exactly 0 and 1; ``n_bins`` is then ignored. 

270 

271 Parameters 

272 ---------- 

273 mean : float, optional 

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

275 std : float, optional 

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

277 loc : float, optional 

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

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

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

281 alpha : float, optional 

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

283 beta : float, optional 

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

285 n_bins : int, optional 

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

287 quantile_edges : array-like, optional 

288 Quantile edges for binning. Must be a strictly increasing 1-D array of at least 

289 3 entries (>= 2 bins), all in ``[0, 1]``, with the first and last entries exactly 

290 0 and 1. If provided, ``n_bins`` is ignored. 

291 

292 Returns 

293 ------- 

294 dict 

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

296 

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

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

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

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

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

302 considered samples that fall within that particular bin. 

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

304 

305 Raises 

306 ------ 

307 ValueError 

308 If ``n_bins`` is not greater than 1, if ``quantile_edges`` is not a strictly 

309 increasing 1-D array in ``[0, 1]`` with endpoints exactly 0 and 1, or if 

310 parameter validation in :func:`parse_parameters` fails. 

311 

312 See Also 

313 -------- 

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

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

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

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

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

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

320 

321 Examples 

322 -------- 

323 Create equal-mass bins for a gamma distribution: 

324 

325 >>> from gwtransport.gamma import bins 

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

327 

328 With a location parameter representing a minimum pore volume: 

329 

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

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

332 5000.0 

333 

334 Create bins with custom quantile edges: 

335 

336 >>> import numpy as np 

337 >>> quantiles = np.array([0.0, 0.25, 0.5, 0.75, 1.0]) 

338 >>> result = bins(mean=30000.0, std=8100.0, quantile_edges=quantiles) 

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

340 Number of bins: 4 

341 """ 

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

343 

344 if quantile_edges is not None: 

345 quantile_edges = np.asarray(quantile_edges, dtype=float) 

346 if quantile_edges.ndim != 1: 

347 msg = "quantile_edges must be a 1-D array." 

348 raise ValueError(msg) 

349 if quantile_edges.size == 0: 

350 msg = "quantile_edges must not be empty." 

351 raise ValueError(msg) 

352 if not np.all(np.diff(quantile_edges) > 0): 

353 msg = "quantile_edges must be strictly increasing." 

354 raise ValueError(msg) 

355 if quantile_edges[0] != 0.0 or quantile_edges[-1] != 1.0: 

356 msg = "quantile_edges must start at 0 and end at 1." 

357 raise ValueError(msg) 

358 n_bins = len(quantile_edges) - 1 

359 else: 

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

361 

362 if n_bins <= 1: 

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

364 raise ValueError(msg) 

365 

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

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

368 bin_edges = unshifted_edges + loc 

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

370 

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

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

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

374 # where F_{alpha+1} is the CDF of Gamma(alpha+1, beta). 

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

376 diff_alpha_plus_1 = np.diff(cdf_alpha_plus_1) 

377 expected_values = beta * alpha * diff_alpha_plus_1 / probability_mass + loc 

378 

379 return { 

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

381 "upper_bound": bin_edges[1:], 

382 "edges": bin_edges, 

383 "expected_values": expected_values, 

384 "probability_mass": probability_mass, 

385 }