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

37 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-04 18:51 +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 

48Available functions: 

49 

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

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

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

53 log removal values with same shape as input. 

54 

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

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

57 

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

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

60 

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

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

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

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

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

66 not provided. 

67 

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

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

70 Gamma(alpha, mu*beta) distribution. 

71 

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

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

74 to specified values. 

75 

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

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

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

79 

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

81 effective mean log removal given gamma-distributed aquifer pore volume. Solves inverse problem: 

82 flow = apv_beta * mu * ln(10) / (10^(target_mean / apv_alpha) - 1). 

83 

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

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

86""" 

87 

88import numpy as np 

89import numpy.typing as npt 

90from scipy import stats 

91 

92 

93def residence_time_to_log_removal( 

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

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

96 """ 

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

98 

99 This function calculates the log removal based on residence times 

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

101 

102 Log Removal = log10_decay_rate * residence_time 

103 

104 This corresponds to exponential decay of pathogen concentration: 

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

106 

107 Parameters 

108 ---------- 

109 residence_times : array-like 

110 Array of residence times in days. Must be positive values. 

111 log10_decay_rate : float 

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

113 to log removal efficiency via first-order decay. 

114 

115 Returns 

116 ------- 

117 log_removals : numpy.ndarray 

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

119 Same shape as input residence_times. 

120 

121 See Also 

122 -------- 

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

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

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

126 gamma_find_flow_for_target_mean : Find flow rate to achieve target log removal 

127 parallel_mean : Calculate weighted average for parallel flow systems 

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

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

130 

131 Notes 

132 ----- 

133 Log removal is a logarithmic measure of pathogen reduction: 

134 - Log 1 = 90% reduction 

135 - Log 2 = 99% reduction 

136 - Log 3 = 99.9% reduction 

137 

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

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

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

141 :func:`decay_rate_to_log10_decay_rate`. 

142 

143 Examples 

144 -------- 

145 >>> import numpy as np 

146 >>> from gwtransport.logremoval import residence_time_to_log_removal 

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

148 >>> log10_decay_rate = 0.2 

149 >>> residence_time_to_log_removal( 

150 ... residence_times=residence_times, log10_decay_rate=log10_decay_rate 

151 ... ) # doctest: +NORMALIZE_WHITESPACE 

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

153 

154 >>> # Single residence time 

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

156 np.float64(1.5) 

157 

158 >>> # 2D array of residence times 

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

160 >>> residence_time_to_log_removal( 

161 ... residence_times=residence_times_2d, log10_decay_rate=0.1 

162 ... ) 

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

164 [3., 4.]]) 

165 """ 

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

167 

168 

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

170 """ 

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

172 

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

174 mu = lambda / ln(10). 

175 

176 Parameters 

177 ---------- 

178 decay_rate : float 

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

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

181 

182 Returns 

183 ------- 

184 log10_decay_rate : float 

185 Log10 decay rate mu (log10/day). 

186 

187 See Also 

188 -------- 

189 log10_decay_rate_to_decay_rate : Inverse conversion 

190 residence_time_to_log_removal : Apply the log10 decay rate 

191 

192 Examples 

193 -------- 

194 >>> from gwtransport.logremoval import decay_rate_to_log10_decay_rate 

195 >>> import numpy as np 

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

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

198 >>> decay_rate_to_log10_decay_rate(decay_rate) # doctest: +SKIP 

199 0.01003... 

200 """ 

201 return decay_rate / np.log(10) 

202 

203 

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

205 """ 

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

207 

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

209 lambda = mu * ln(10). 

210 

211 Parameters 

212 ---------- 

213 log10_decay_rate : float 

214 Log10 decay rate mu (log10/day). 

215 

216 Returns 

217 ------- 

218 decay_rate : float 

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

220 

221 See Also 

222 -------- 

223 decay_rate_to_log10_decay_rate : Inverse conversion 

224 

225 Examples 

226 -------- 

227 >>> from gwtransport.logremoval import log10_decay_rate_to_decay_rate 

228 >>> log10_decay_rate_to_decay_rate(0.2) # doctest: +SKIP 

229 0.4605... 

230 """ 

231 return log10_decay_rate * np.log(10) 

232 

233 

234def parallel_mean( 

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

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

237 """ 

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

239 

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

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

242 distribution of flow across all paths. 

243 

244 The calculation uses the formula: 

245 

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

247 

248 Where: 

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

250 - LR_i = log removal of system i 

251 

252 Parameters 

253 ---------- 

254 log_removals : array-like 

255 Array of log removal values for each parallel flow. 

256 Each value represents the log10 reduction of pathogens. 

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

258 the specified axis. 

259 

260 flow_fractions : array-like, optional 

261 Array of flow fractions for each parallel flow. 

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

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

264 (default is None). 

265 

266 axis : int, optional 

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

268 arrays. If None, the array is treated as 1-dimensional 

269 (default is None). 

270 

271 Returns 

272 ------- 

273 float or array-like 

274 The combined log removal value for the parallel system. 

275 If log_removals is multi-dimensional and axis is specified, 

276 returns an array with the specified axis removed. 

277 

278 Notes 

279 ----- 

280 Log removal is a logarithmic measure of pathogen reduction: 

281 

282 - Log 1 = 90% reduction 

283 - Log 2 = 99% reduction 

284 - Log 3 = 99.9% reduction 

285 

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

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

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

289 

290 Examples 

291 -------- 

292 >>> import numpy as np 

293 >>> from gwtransport.logremoval import parallel_mean 

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

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

296 >>> parallel_mean(log_removals=log_removals) 

297 np.float64(3.431798275933005) 

298 

299 >>> # Two parallel streams with weighted flow 

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

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

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

303 np.float64(3.153044674980176) 

304 

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

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

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

308 array([3.43179828, 2.43179828]) 

309 

310 See Also 

311 -------- 

312 residence_time_to_log_removal : Compute log removal from residence times 

313 """ 

314 # Convert log_removals to numpy array if it isn't already 

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

316 

317 # If flow_fractions is not provided, assume equal distribution 

318 if flow_fractions is None: 

319 if axis is None: 

320 # 1D case: calculate the number of parallel flows 

321 n = len(log_removals) 

322 # Create equal flow fractions (avoid division by zero) 

323 flow_fractions = np.full(n, 1.0 / n) if n > 0 else np.array([]) 

324 else: 

325 # Multi-dimensional case: create equal flow fractions along the specified axis 

326 n = log_removals.shape[axis] 

327 shape = [1] * log_removals.ndim 

328 shape[axis] = n 

329 flow_fractions = np.full(shape, 1.0 / n) 

330 else: 

331 # Convert flow_fractions to numpy array 

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

333 

334 fraction_sums = np.sum(flow_fractions, axis=axis) 

335 if not np.all(np.isclose(fraction_sums, 1.0)): 

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

337 raise ValueError(msg) 

338 

339 # Convert log removal to decimal reduction values 

340 decimal_reductions = 10 ** (-log_removals) 

341 

342 # Calculate weighted average decimal reduction 

343 weighted_decimal_reduction = np.sum(flow_fractions * decimal_reductions, axis=axis) 

344 

345 # Convert back to log scale 

346 return -np.log10(weighted_decimal_reduction) 

347 

348 

349def gamma_pdf( 

350 *, r: npt.ArrayLike, rt_alpha: float, rt_beta: float, log10_decay_rate: float 

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

352 """ 

353 Compute the PDF of log removal given gamma-distributed residence time. 

354 

355 Since log removal R = mu*T and T ~ Gamma(alpha, beta), the log removal R 

356 follows a Gamma(alpha, mu*beta) distribution. 

357 

358 Parameters 

359 ---------- 

360 r : array-like 

361 Log removal values at which to compute the PDF. 

362 rt_alpha : float 

363 Shape parameter of the gamma distribution for residence time. 

364 rt_beta : float 

365 Scale parameter of the gamma distribution for residence time (days). 

366 log10_decay_rate : float 

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

368 log removal via R = mu * T. 

369 

370 Returns 

371 ------- 

372 pdf : numpy.ndarray 

373 PDF values corresponding to the input r values. 

374 

375 See Also 

376 -------- 

377 gamma_cdf : Cumulative distribution function of log removal 

378 gamma_mean : Mean of the log removal distribution 

379 """ 

380 r = np.asarray(r) 

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

382 

383 

384def gamma_cdf( 

385 *, r: npt.ArrayLike, rt_alpha: float, rt_beta: float, log10_decay_rate: float 

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

387 """ 

388 Compute the CDF of log removal given gamma-distributed residence time. 

389 

390 Since log removal R = mu*T and T ~ Gamma(alpha, beta), the CDF is 

391 P(R <= r) = P(T <= r/mu) = Gamma_CDF(r/mu; alpha, beta). 

392 

393 Parameters 

394 ---------- 

395 r : array-like 

396 Log removal values at which to compute the CDF. 

397 rt_alpha : float 

398 Shape parameter of the gamma distribution for residence time. 

399 rt_beta : float 

400 Scale parameter of the gamma distribution for residence time (days). 

401 log10_decay_rate : float 

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

403 log removal via R = mu * T. 

404 

405 Returns 

406 ------- 

407 cdf : numpy.ndarray 

408 CDF values corresponding to the input r values. 

409 

410 See Also 

411 -------- 

412 gamma_pdf : Probability density function of log removal 

413 gamma_mean : Mean of the log removal distribution 

414 """ 

415 r = np.asarray(r) 

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

417 

418 

419def gamma_mean(*, rt_alpha: float, rt_beta: float, log10_decay_rate: float) -> float: 

420 """ 

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

422 

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

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

425 output concentrations (not by averaging individual log removals). This 

426 uses the moment generating function of the gamma distribution: 

427 

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

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

430 

431 This is always less than the arithmetic mean (mu * alpha * beta) 

432 because short residence time paths contribute disproportionately 

433 to the output concentration. 

434 

435 Parameters 

436 ---------- 

437 rt_alpha : float 

438 Shape parameter of the gamma distribution for residence time. 

439 rt_beta : float 

440 Scale parameter of the gamma distribution for residence time (days). 

441 log10_decay_rate : float 

442 Log10 decay rate mu (log10/day). 

443 

444 Returns 

445 ------- 

446 mean : float 

447 Effective (parallel) mean log removal value. 

448 

449 See Also 

450 -------- 

451 gamma_find_flow_for_target_mean : Find flow for target mean log removal 

452 parallel_mean : Discrete version of this calculation 

453 gamma_pdf : PDF of the log removal distribution 

454 gamma_cdf : CDF of the log removal distribution 

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

456 """ 

457 return rt_alpha * np.log10(1 + rt_beta * log10_decay_rate * np.log(10)) 

458 

459 

460def gamma_find_flow_for_target_mean( 

461 *, target_mean: float, apv_alpha: float, apv_beta: float, log10_decay_rate: float 

462) -> float: 

463 """ 

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

465 

466 Given a gamma-distributed aquifer pore volume with parameters (apv_alpha, apv_beta), 

467 the residence time distribution is Gamma(apv_alpha, apv_beta/flow). The effective 

468 mean log removal (from :func:`gamma_mean`) is: 

469 

470 LR_eff = apv_alpha * log10(1 + (apv_beta/flow) * mu * ln(10)) 

471 

472 Solving for flow: 

473 

474 flow = apv_beta * mu * ln(10) / (10^(target_mean / apv_alpha) - 1) 

475 

476 Parameters 

477 ---------- 

478 target_mean : float 

479 Target effective mean log removal value. 

480 apv_alpha : float 

481 Shape parameter of the gamma distribution for aquifer pore volume. 

482 apv_beta : float 

483 Scale parameter of the gamma distribution for aquifer pore volume. 

484 log10_decay_rate : float 

485 Log10 decay rate mu (log10/day). 

486 

487 Returns 

488 ------- 

489 flow : float 

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

491 target mean log removal. 

492 

493 See Also 

494 -------- 

495 gamma_mean : Compute effective mean log removal for given parameters 

496 """ 

497 return apv_beta * log10_decay_rate * np.log(10) / (10 ** (target_mean / apv_alpha) - 1)