Coverage for src/gwtransport/logremoval.py: 96%

46 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 21:17 +0000

1""" 

2Log Removal Calculations for First-Order Decay Processes. 

3 

4This module provides utilities to calculate log removal values from first-order decay 

5processes, including pathogen inactivation and radioactive decay. The module supports 

6basic log removal calculations and parallel flow arrangements where multiple flow paths 

7operate simultaneously. 

8 

9First-Order Decay Model 

10----------------------- 

11The log removal from any first-order decay process is: 

12 

13 Log Removal = log10_decay_rate * residence_time 

14 

15where ``log10_decay_rate`` has units [log10/day] and ``residence_time`` has units [days]. 

16This is equivalent to exponential decay ``C_out/C_in = 10^(-mu * t)``, where mu is the 

17log10 decay rate and t is residence time. The natural-log decay rate constant lambda [1/day] 

18is related to mu by ``lambda = mu * ln(10)``. 

19 

20This model applies to any process that follows first-order kinetics: 

21 

22- **Pathogen inactivation**: viruses, bacteria, and protozoa lose infectivity over time 

23- **Radioactive decay**: isotopes used for groundwater dating (tritium, CFC, SF6) 

24- **Chemical degradation**: first-order breakdown of contaminants 

25 

26Pathogen Removal in Bank Filtration 

27------------------------------------ 

28For pathogen removal during soil passage, total removal consists of two distinct mechanisms 

29(Schijven and Hassanizadeh, 2000): 

30 

311. **Inactivation (time-dependent)**: Pathogens lose infectivity over time through biological 

32 decay. This follows first-order kinetics and is modeled by this module as 

33 ``LR_decay = log10_decay_rate * residence_time``. The inactivation rate depends strongly 

34 on temperature and pathogen type. 

35 

362. **Attachment (geometry-dependent)**: Pathogens are physically removed by adsorption to soil 

37 grains and straining. This depends on aquifer geometry, distance, soil properties, and pH, 

38 and is NOT modeled by this module. Users should add this component separately based on 

39 site-specific data. 

40 

41Total log removal = LR_decay (this module) + LR_attachment (user-specified). 

42 

43At the Castricum dune recharge site, Schijven et al. (1999) found that attachment contributed 

44approximately 97% of total MS2 removal, with inactivation contributing only 3%. Inactivation 

45rates for common model viruses at 10 degrees C are typically 0.02-0.11 log10/day (Schijven and 

46Hassanizadeh, 2000, Table 7). 

47 

48Gamma-distribution parameter notation 

49------------------------------------- 

50Several functions are parameterized by a gamma distribution. The parameter prefix marks 

51*which* physical quantity is gamma-distributed, because two distinct quantities appear here: 

52 

53- ``rt_alpha`` / ``rt_beta`` / ``rt_loc`` (or the equivalent ``rt_mean`` / ``rt_std`` / 

54 ``rt_loc``) parameterize the gamma distribution of the **residence time** (used by 

55 :func:`gamma_pdf`, :func:`gamma_cdf`, :func:`gamma_mean`). 

56- ``apv_alpha`` / ``apv_beta`` / ``apv_loc`` (or the equivalent ``apv_mean`` / ``apv_std`` / 

57 ``apv_loc``) parameterize the gamma distribution of the **aquifer pore volume** (used by 

58 :func:`gamma_find_flow_for_target_mean`). 

59 

60These prefixes are intentional and load-bearing: residence time and pore volume are different 

61quantities, so a bare ``alpha`` / ``beta`` / ``loc`` would be ambiguous in this module. Both the 

62shape/scale and the mean/std pairs are validated through :func:`gwtransport.gamma.parse_parameters`, 

63so invalid parameters (e.g. a negative shape) raise ``ValueError`` rather than silently returning 

64an unphysical result. 

65 

66Available functions: 

67 

68- :func:`residence_time_to_log_removal` - Calculate log removal from residence times and 

69 decay rate coefficient. Uses formula: Log Removal = log10_decay_rate * residence_time. 

70 Handles single values, 1D arrays, or multi-dimensional arrays of residence times. Returns 

71 log removal values with same shape as input. 

72 

73- :func:`decay_rate_to_log10_decay_rate` - Convert a natural-log decay rate constant 

74 lambda [1/day] to a log10 decay rate mu [log10/day]. 

75 

76- :func:`log10_decay_rate_to_decay_rate` - Convert a log10 decay rate mu [log10/day] 

77 to a natural-log decay rate constant lambda [1/day]. 

78 

79- :func:`parallel_mean` - Calculate weighted average log removal for parallel flow systems. 

80 Computes overall efficiency when multiple treatment paths operate in parallel with different 

81 log removal values and flow fractions. Uses formula: Total Log Removal = -log10(sum(F_i * 10^(-LR_i))) 

82 where F_i is flow fraction and LR_i is log removal for path i. Supports multi-dimensional 

83 arrays via axis parameter for batch processing. Assumes equal flow distribution if flow_fractions 

84 not provided. 

85 

86- :func:`gamma_pdf` - Compute probability density function (PDF) of log removal given 

87 gamma-distributed residence time. Since R = mu*T and T ~ Gamma(alpha, beta), R follows a 

88 Gamma(alpha, mu*beta) distribution. 

89 

90- :func:`gamma_cdf` - Compute cumulative distribution function (CDF) of log removal given 

91 gamma-distributed residence time. Returns probability that log removal is less than or equal 

92 to specified values. 

93 

94- :func:`gamma_mean` - Compute effective (parallel) mean log removal for gamma-distributed 

95 residence time. Uses the moment generating function of the gamma distribution to compute the 

96 log-weighted average: LR_eff = mu * loc + alpha * log10(1 + beta * mu * ln(10)). 

97 

98- :func:`gamma_find_flow_for_target_mean` - Find flow rate that produces specified target 

99 effective mean log removal given gamma-distributed aquifer pore volume. For ``loc == 0`` this 

100 is the closed-form inverse: flow = beta * mu * ln(10) / (10^(target_mean / alpha) - 1); for 

101 ``loc > 0`` the transcendental equation is solved numerically. 

102 

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

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

105""" 

106 

107import numpy as np 

108import numpy.typing as npt 

109from scipy import optimize, stats 

110 

111from gwtransport.gamma import parse_parameters 

112 

113 

114def residence_time_to_log_removal( 

115 *, residence_times: npt.ArrayLike, log10_decay_rate: float 

116) -> npt.NDArray[np.floating]: 

117 """ 

118 Compute log removal given residence times and a log10 decay rate. 

119 

120 This function calculates the log removal based on residence times 

121 and a log10 decay rate coefficient using first-order decay: 

122 

123 Log Removal = log10_decay_rate * residence_time 

124 

125 This corresponds to exponential decay of pathogen concentration: 

126 C_out/C_in = 10^(-log10_decay_rate * residence_time). 

127 

128 Parameters 

129 ---------- 

130 residence_times : array-like 

131 Residence times in days. The formula evaluates ``log10_decay_rate * 

132 residence_times`` for any real input; negative values produce negative 

133 log removal (mathematical amplification) and the caller is responsible 

134 for sign interpretation. 

135 log10_decay_rate : float 

136 Log10 decay rate coefficient (log10/day). Relates residence time 

137 to log removal efficiency via first-order decay. Negative values 

138 correspond to first-order production rather than decay. 

139 

140 Returns 

141 ------- 

142 log_removals : ndarray 

143 Array of log removal values corresponding to the input residence times. 

144 Same shape as input residence_times. 

145 

146 See Also 

147 -------- 

148 decay_rate_to_log10_decay_rate : Convert natural-log decay rate to log10 decay rate 

149 log10_decay_rate_to_decay_rate : Convert log10 decay rate to natural-log decay rate 

150 gamma_mean : Compute mean log removal for gamma-distributed residence times 

151 gamma_find_flow_for_target_mean : Find flow rate to achieve target log removal 

152 parallel_mean : Calculate weighted average for parallel flow systems 

153 gwtransport.residence_time.full : Compute residence times from flow and pore volume 

154 :ref:`concept-residence-time` : Time in aquifer determines pathogen contact time 

155 

156 Notes 

157 ----- 

158 Log removal is a logarithmic measure of pathogen reduction: 

159 - Log 1 = 90% reduction 

160 - Log 2 = 99% reduction 

161 - Log 3 = 99.9% reduction 

162 

163 The first-order decay model is mathematically identical to radioactive 

164 decay used in tracer dating. To convert a published natural-log decay 

165 rate lambda [1/day] to log10_decay_rate mu [log10/day], use 

166 :func:`decay_rate_to_log10_decay_rate`. 

167 

168 Examples 

169 -------- 

170 >>> import numpy as np 

171 >>> from gwtransport.logremoval import residence_time_to_log_removal 

172 >>> residence_times = np.array([10.0, 20.0, 50.0]) 

173 >>> log10_decay_rate = 0.2 

174 >>> residence_time_to_log_removal( 

175 ... residence_times=residence_times, log10_decay_rate=log10_decay_rate 

176 ... ) # doctest: +NORMALIZE_WHITESPACE 

177 array([ 2., 4., 10.]) 

178 

179 >>> # Single residence time 

180 >>> residence_time_to_log_removal(residence_times=5.0, log10_decay_rate=0.3) 

181 np.float64(1.5) 

182 

183 >>> # 2D array of residence times 

184 >>> residence_times_2d = np.array([[10.0, 20.0], [30.0, 40.0]]) 

185 >>> residence_time_to_log_removal( 

186 ... residence_times=residence_times_2d, log10_decay_rate=0.1 

187 ... ) 

188 array([[1., 2.], 

189 [3., 4.]]) 

190 """ 

191 return log10_decay_rate * np.asarray(residence_times, dtype=float) 

192 

193 

194def decay_rate_to_log10_decay_rate(decay_rate: float) -> float: 

195 """ 

196 Convert a natural-log decay rate constant to a log10 decay rate. 

197 

198 Converts lambda [1/day] to mu [log10/day] using the relationship 

199 mu = lambda / ln(10). 

200 

201 Parameters 

202 ---------- 

203 decay_rate : float 

204 Natural-log first-order decay rate constant lambda (1/day). 

205 For example, from tracer dating: lambda = ln(2) / half_life. 

206 

207 Returns 

208 ------- 

209 log10_decay_rate : float 

210 Log10 decay rate mu (log10/day). 

211 

212 See Also 

213 -------- 

214 log10_decay_rate_to_decay_rate : Inverse conversion 

215 residence_time_to_log_removal : Apply the log10 decay rate 

216 

217 Examples 

218 -------- 

219 >>> from gwtransport.logremoval import decay_rate_to_log10_decay_rate 

220 >>> import numpy as np 

221 >>> # Convert a decay rate of ln(2)/30 (half-life of 30 days) 

222 >>> decay_rate = np.log(2) / 30 

223 >>> decay_rate_to_log10_decay_rate(decay_rate) # doctest: +ELLIPSIS 

224 np.float64(0.01003...) 

225 """ 

226 return decay_rate / np.log(10) 

227 

228 

229def log10_decay_rate_to_decay_rate(log10_decay_rate: float) -> float: 

230 """ 

231 Convert a log10 decay rate to a natural-log decay rate constant. 

232 

233 Converts mu [log10/day] to lambda [1/day] using the relationship 

234 lambda = mu * ln(10). 

235 

236 Parameters 

237 ---------- 

238 log10_decay_rate : float 

239 Log10 decay rate mu (log10/day). 

240 

241 Returns 

242 ------- 

243 decay_rate : float 

244 Natural-log first-order decay rate constant lambda (1/day). 

245 

246 See Also 

247 -------- 

248 decay_rate_to_log10_decay_rate : Inverse conversion 

249 

250 Examples 

251 -------- 

252 >>> from gwtransport.logremoval import log10_decay_rate_to_decay_rate 

253 >>> log10_decay_rate_to_decay_rate(0.2) # doctest: +ELLIPSIS 

254 np.float64(0.4605...) 

255 """ 

256 return log10_decay_rate * np.log(10) 

257 

258 

259def parallel_mean( 

260 *, log_removals: npt.ArrayLike, flow_fractions: npt.ArrayLike | None = None, axis: int | None = None 

261) -> np.floating | npt.NDArray[np.floating]: 

262 """ 

263 Calculate the weighted average log removal for a system with parallel flows. 

264 

265 This function computes the overall log removal efficiency of a parallel 

266 filtration system. If flow_fractions is not provided, it assumes equal 

267 distribution of flow across all paths. 

268 

269 The calculation uses the formula: 

270 

271 Total Log Removal = -log10(sum(F_i * 10^(-LR_i))) 

272 

273 Where: 

274 - F_i = fraction of flow through system i (decimal, sum to 1.0) 

275 - LR_i = log removal of system i 

276 

277 Parameters 

278 ---------- 

279 log_removals : array-like 

280 Array of log removal values for each parallel flow. 

281 Each value represents the log10 reduction of pathogens. 

282 For multi-dimensional arrays, the parallel mean is computed along 

283 the specified axis. 

284 

285 flow_fractions : array-like, optional 

286 Array of flow fractions for each parallel flow. 

287 Must sum to 1.0 along the specified axis and have compatible shape 

288 with log_removals. If None, equal flow distribution is assumed 

289 (default is None). 

290 

291 axis : int, optional 

292 Axis along which to compute the parallel mean for multi-dimensional 

293 arrays. If None, the reduction matches the way ``np.mean`` / ``np.sum`` 

294 treat ``axis=None``: the parallel mean is computed over the flattened 

295 input (default is None). 

296 

297 Returns 

298 ------- 

299 np.floating or ndarray 

300 The combined log removal value for the parallel system. Returns a 

301 scalar when axis=None, otherwise an array with the specified axis 

302 removed. 

303 

304 Raises 

305 ------ 

306 ValueError 

307 If ``flow_fractions`` does not sum to 1.0 along the specified axis. 

308 

309 See Also 

310 -------- 

311 residence_time_to_log_removal : Compute log removal from residence times 

312 

313 Notes 

314 ----- 

315 Log removal is a logarithmic measure of pathogen reduction: 

316 

317 - Log 1 = 90% reduction 

318 - Log 2 = 99% reduction 

319 - Log 3 = 99.9% reduction 

320 

321 For parallel flows, the combined removal is typically less effective 

322 than the best individual removal but better than the worst. 

323 For systems in series, log removals would be summed directly. 

324 

325 Examples 

326 -------- 

327 >>> import numpy as np 

328 >>> from gwtransport.logremoval import parallel_mean 

329 >>> # Three parallel streams with equal flow and log removals of 3, 4, and 5 

330 >>> log_removals = np.array([3, 4, 5]) 

331 >>> parallel_mean(log_removals=log_removals) 

332 np.float64(3.431798275933005) 

333 

334 >>> # Two parallel streams with weighted flow 

335 >>> log_removals = np.array([3, 5]) 

336 >>> flow_fractions = np.array([0.7, 0.3]) 

337 >>> parallel_mean(log_removals=log_removals, flow_fractions=flow_fractions) 

338 np.float64(3.153044674980176) 

339 

340 >>> # Multi-dimensional array: parallel mean along axis 1 

341 >>> log_removals_2d = np.array([[3, 4, 5], [2, 3, 4]]) 

342 >>> parallel_mean(log_removals=log_removals_2d, axis=1) 

343 array([3.43179828, 2.43179828]) 

344 """ 

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

346 decimal_reductions = 10.0 ** (-log_removals) 

347 if flow_fractions is None: 

348 return -np.log10(np.mean(decimal_reductions, axis=axis)) 

349 flow_fractions = np.asarray(flow_fractions, dtype=float) 

350 if not np.all(np.isclose(np.sum(flow_fractions, axis=axis), 1.0)): 

351 msg = "flow_fractions must sum to 1.0 (along the specified axis)" 

352 raise ValueError(msg) 

353 return -np.log10(np.sum(flow_fractions * decimal_reductions, axis=axis)) 

354 

355 

356def gamma_pdf( 

357 *, 

358 r: npt.ArrayLike, 

359 rt_alpha: float | None = None, 

360 rt_beta: float | None = None, 

361 rt_loc: float = 0.0, 

362 rt_mean: float | None = None, 

363 rt_std: float | None = None, 

364 log10_decay_rate: float, 

365) -> npt.NDArray[np.floating]: 

366 """ 

367 Compute the PDF of log removal given (shifted) gamma-distributed residence time. 

368 

369 With residence time ``T = T0 + rt_loc`` where ``T0 ~ Gamma(rt_alpha, rt_beta)``, 

370 the log removal ``R = mu * T`` follows a shifted gamma distribution with shape 

371 ``rt_alpha``, scale ``mu * rt_beta``, and location ``mu * rt_loc``. 

372 

373 The residence-time distribution is specified with either ``(rt_alpha, rt_beta)`` or 

374 ``(rt_mean, rt_std)`` (optionally shifted by ``rt_loc``); both are routed through 

375 :func:`gwtransport.gamma.parse_parameters`. 

376 

377 Parameters 

378 ---------- 

379 r : array-like 

380 Log removal values at which to compute the PDF. 

381 rt_alpha : float, optional 

382 Shape parameter of the gamma distribution for residence time. Must be positive. 

383 rt_beta : float, optional 

384 Scale parameter of the gamma distribution for residence time (days). Must be positive. 

385 rt_loc : float, optional 

386 Location (minimum residence time, days) of the residence time distribution. 

387 Must be non-negative. Default is ``0.0``. 

388 rt_mean : float, optional 

389 Mean residence time (days). Alternative to ``rt_alpha``; supply with ``rt_std``. 

390 Must be strictly greater than ``rt_loc``. 

391 rt_std : float, optional 

392 Standard deviation of the residence time (days). Alternative to ``rt_beta``; 

393 supply with ``rt_mean``. Must be positive. 

394 log10_decay_rate : float 

395 Log10 decay rate mu (log10/day). Relates residence time to 

396 log removal via R = mu * T. 

397 

398 Returns 

399 ------- 

400 pdf : ndarray 

401 PDF values corresponding to the input r values. 

402 

403 Raises 

404 ------ 

405 ValueError 

406 If parameter validation in :func:`gwtransport.gamma.parse_parameters` fails 

407 (e.g. ``rt_loc`` negative, non-positive shape/scale, or neither/both 

408 parameter pairs supplied). 

409 

410 See Also 

411 -------- 

412 gamma_cdf : Cumulative distribution function of log removal 

413 gamma_mean : Mean of the log removal distribution 

414 """ 

415 rt_alpha, rt_beta, rt_loc = parse_parameters(mean=rt_mean, std=rt_std, loc=rt_loc, alpha=rt_alpha, beta=rt_beta) 

416 return stats.gamma.pdf(r, a=rt_alpha, loc=log10_decay_rate * rt_loc, scale=log10_decay_rate * rt_beta) 

417 

418 

419def gamma_cdf( 

420 *, 

421 r: npt.ArrayLike, 

422 rt_alpha: float | None = None, 

423 rt_beta: float | None = None, 

424 rt_loc: float = 0.0, 

425 rt_mean: float | None = None, 

426 rt_std: float | None = None, 

427 log10_decay_rate: float, 

428) -> npt.NDArray[np.floating]: 

429 """ 

430 Compute the CDF of log removal given (shifted) gamma-distributed residence time. 

431 

432 With residence time ``T = T0 + rt_loc`` where ``T0 ~ Gamma(rt_alpha, rt_beta)``, 

433 the CDF is ``P(R <= r) = P(mu*(T0 + rt_loc) <= r) = 

434 P(T0 <= (r - mu*rt_loc)/mu)`` which is the CDF of a shifted gamma distribution 

435 with location ``mu * rt_loc``. 

436 

437 The residence-time distribution is specified with either ``(rt_alpha, rt_beta)`` or 

438 ``(rt_mean, rt_std)`` (optionally shifted by ``rt_loc``); both are routed through 

439 :func:`gwtransport.gamma.parse_parameters`. 

440 

441 Parameters 

442 ---------- 

443 r : array-like 

444 Log removal values at which to compute the CDF. 

445 rt_alpha : float, optional 

446 Shape parameter of the gamma distribution for residence time. Must be positive. 

447 rt_beta : float, optional 

448 Scale parameter of the gamma distribution for residence time (days). Must be positive. 

449 rt_loc : float, optional 

450 Location (minimum residence time, days) of the residence time distribution. 

451 Must be non-negative. Default is ``0.0``. 

452 rt_mean : float, optional 

453 Mean residence time (days). Alternative to ``rt_alpha``; supply with ``rt_std``. 

454 Must be strictly greater than ``rt_loc``. 

455 rt_std : float, optional 

456 Standard deviation of the residence time (days). Alternative to ``rt_beta``; 

457 supply with ``rt_mean``. Must be positive. 

458 log10_decay_rate : float 

459 Log10 decay rate mu (log10/day). Relates residence time to 

460 log removal via R = mu * T. 

461 

462 Returns 

463 ------- 

464 cdf : ndarray 

465 CDF values corresponding to the input r values. 

466 

467 Raises 

468 ------ 

469 ValueError 

470 If parameter validation in :func:`gwtransport.gamma.parse_parameters` fails 

471 (e.g. ``rt_loc`` negative, non-positive shape/scale, or neither/both 

472 parameter pairs supplied). 

473 

474 See Also 

475 -------- 

476 gamma_pdf : Probability density function of log removal 

477 gamma_mean : Mean of the log removal distribution 

478 """ 

479 rt_alpha, rt_beta, rt_loc = parse_parameters(mean=rt_mean, std=rt_std, loc=rt_loc, alpha=rt_alpha, beta=rt_beta) 

480 return stats.gamma.cdf(r, a=rt_alpha, loc=log10_decay_rate * rt_loc, scale=log10_decay_rate * rt_beta) 

481 

482 

483def gamma_mean( 

484 *, 

485 rt_alpha: float | None = None, 

486 rt_beta: float | None = None, 

487 rt_loc: float = 0.0, 

488 rt_mean: float | None = None, 

489 rt_std: float | None = None, 

490 log10_decay_rate: float, 

491) -> float: 

492 """ 

493 Compute the effective (parallel) mean log removal for (shifted) gamma-distributed residence time. 

494 

495 When water travels through multiple flow paths with gamma-distributed 

496 residence times, the effective log removal is determined by mixing the 

497 output concentrations (not by averaging individual log removals). For a 

498 shifted gamma distribution ``T = T0 + rt_loc`` with ``T0 ~ Gamma(alpha, beta)``, 

499 factoring the moment generating function gives: 

500 

501 LR_eff = -log10(E[10^(-mu*T)]) 

502 = -log10(10^(-mu*rt_loc) * E[10^(-mu*T0)]) 

503 = mu * rt_loc + alpha * log10(1 + beta * mu * ln(10)) 

504 

505 The ``rt_loc`` term shifts the whole log-removal distribution by a constant 

506 ``mu * rt_loc``; the alpha/beta term is unchanged. This is always less than 

507 the arithmetic mean ``mu * (alpha * beta + rt_loc)`` because short residence 

508 time paths contribute disproportionately to the output concentration. 

509 

510 The residence-time distribution is specified with either ``(rt_alpha, rt_beta)`` or 

511 ``(rt_mean, rt_std)`` (optionally shifted by ``rt_loc``); both are routed through 

512 :func:`gwtransport.gamma.parse_parameters`. 

513 

514 Parameters 

515 ---------- 

516 rt_alpha : float, optional 

517 Shape parameter of the gamma distribution for residence time. Must be positive. 

518 rt_beta : float, optional 

519 Scale parameter of the gamma distribution for residence time (days). Must be positive. 

520 rt_loc : float, optional 

521 Location (minimum residence time, days) of the residence time distribution. 

522 Must be non-negative. Default is ``0.0``. 

523 rt_mean : float, optional 

524 Mean residence time (days). Alternative to ``rt_alpha``; supply with ``rt_std``. 

525 Must be strictly greater than ``rt_loc``. 

526 rt_std : float, optional 

527 Standard deviation of the residence time (days). Alternative to ``rt_beta``; 

528 supply with ``rt_mean``. Must be positive. 

529 log10_decay_rate : float 

530 Log10 decay rate mu (log10/day). 

531 

532 Returns 

533 ------- 

534 mean : float 

535 Effective (parallel) mean log removal value. 

536 

537 Raises 

538 ------ 

539 ValueError 

540 If parameter validation in :func:`gwtransport.gamma.parse_parameters` fails 

541 (e.g. ``rt_loc`` negative, non-positive shape/scale, or neither/both 

542 parameter pairs supplied). 

543 

544 See Also 

545 -------- 

546 gamma_find_flow_for_target_mean : Find flow for target mean log removal 

547 parallel_mean : Discrete version of this calculation 

548 gamma_pdf : PDF of the log removal distribution 

549 gamma_cdf : CDF of the log removal distribution 

550 :ref:`concept-pore-volume-distribution` : Why residence times are distributed 

551 """ 

552 rt_alpha, rt_beta, rt_loc = parse_parameters(mean=rt_mean, std=rt_std, loc=rt_loc, alpha=rt_alpha, beta=rt_beta) 

553 return log10_decay_rate * rt_loc + rt_alpha * np.log1p(rt_beta * log10_decay_rate * np.log(10)) / np.log(10) 

554 

555 

556def gamma_find_flow_for_target_mean( 

557 *, 

558 target_mean: float, 

559 apv_alpha: float | None = None, 

560 apv_beta: float | None = None, 

561 apv_loc: float = 0.0, 

562 apv_mean: float | None = None, 

563 apv_std: float | None = None, 

564 log10_decay_rate: float, 

565) -> float: 

566 """ 

567 Find the flow rate that produces a target effective mean log removal. 

568 

569 Given a (shifted) gamma-distributed aquifer pore volume with parameters 

570 ``(apv_alpha, apv_beta, apv_loc)``, the residence time distribution at flow 

571 ``Q`` is a shifted gamma with shape ``apv_alpha``, scale ``apv_beta/Q``, and 

572 location ``apv_loc/Q``. From :func:`gamma_mean`: 

573 

574 LR_eff = mu * apv_loc / Q + apv_alpha * log10(1 + (apv_beta/Q) * mu * ln(10)) 

575 

576 For ``apv_loc == 0`` this is closed-form: 

577 

578 Q = apv_beta * mu * ln(10) / (10^(target_mean / apv_alpha) - 1) 

579 

580 For ``apv_loc > 0`` the equation is transcendental and solved numerically 

581 with :func:`scipy.optimize.brentq` by bracketing the root in ``1/Q``. 

582 

583 The pore-volume distribution is specified with either ``(apv_alpha, apv_beta)`` or 

584 ``(apv_mean, apv_std)`` (optionally shifted by ``apv_loc``); both are routed through 

585 :func:`gwtransport.gamma.parse_parameters`. 

586 

587 Parameters 

588 ---------- 

589 target_mean : float 

590 Target effective mean log removal value. Must be positive. 

591 apv_alpha : float, optional 

592 Shape parameter of the gamma distribution for aquifer pore volume. Must be positive. 

593 apv_beta : float, optional 

594 Scale parameter of the gamma distribution for aquifer pore volume. Must be positive. 

595 apv_loc : float, optional 

596 Location (minimum aquifer pore volume) of the gamma distribution. 

597 Must be non-negative. Default is ``0.0``. 

598 apv_mean : float, optional 

599 Mean aquifer pore volume. Alternative to ``apv_alpha``; supply with ``apv_std``. 

600 Must be strictly greater than ``apv_loc``. 

601 apv_std : float, optional 

602 Standard deviation of the aquifer pore volume. Alternative to ``apv_beta``; 

603 supply with ``apv_mean``. Must be positive. 

604 log10_decay_rate : float 

605 Log10 decay rate mu (log10/day). 

606 

607 Returns 

608 ------- 

609 flow : float 

610 Flow rate (same units as apv_beta per day) that produces the 

611 target mean log removal. 

612 

613 Raises 

614 ------ 

615 ValueError 

616 If ``target_mean`` is not positive, if ``log10_decay_rate`` is not positive 

617 (no decay can never produce a positive target log removal), or if parameter 

618 validation in :func:`gwtransport.gamma.parse_parameters` fails (e.g. ``apv_loc`` 

619 negative, non-positive shape/scale, or neither/both parameter pairs supplied). 

620 

621 See Also 

622 -------- 

623 gamma_mean : Compute effective mean log removal for given parameters 

624 """ 

625 apv_alpha, apv_beta, apv_loc = parse_parameters( 

626 mean=apv_mean, std=apv_std, loc=apv_loc, alpha=apv_alpha, beta=apv_beta 

627 ) 

628 if target_mean <= 0: 

629 msg = "target_mean must be positive" 

630 raise ValueError(msg) 

631 if log10_decay_rate <= 0: 

632 # Without decay, the effective mean log removal is identically zero 

633 # regardless of flow, so no finite flow can attain a positive target. 

634 msg = "log10_decay_rate must be positive to attain a positive target_mean" 

635 raise ValueError(msg) 

636 

637 ln10 = np.log(10) 

638 flow_closed_form = apv_beta * log10_decay_rate * ln10 / (10 ** (target_mean / apv_alpha) - 1) 

639 

640 if apv_loc == 0.0: 

641 return float(flow_closed_form) 

642 

643 # Solve target = mu*apv_loc*u + apv_alpha*log10(1 + apv_beta*mu*ln(10)*u) for u = 1/flow. 

644 # Both terms are monotonically increasing in u, so f(u) - target is monotonic with a 

645 # unique positive root. Bracket: at u = 1/flow_closed_form the alpha/beta term alone 

646 # equals target_mean, so the full f overshoots by exactly mu*apv_loc*u_upper > 0. 

647 u_upper = 1.0 / flow_closed_form 

648 

649 def residual(u: float) -> float: 

650 return float( 

651 log10_decay_rate * apv_loc * u 

652 + apv_alpha * np.log1p(apv_beta * log10_decay_rate * ln10 * u) / ln10 

653 - target_mean 

654 ) 

655 

656 u_root = optimize.brentq(residual, 0.0, u_upper) 

657 return 1.0 / float(u_root) # type: ignore[arg-type]