Coverage for src / gwtransport / residence_time.py: 91%

91 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-04 18:51 +0000

1""" 

2Residence Time Calculations for Retarded Compound Transport. 

3 

4This module provides functions to compute residence times for compounds traveling through 

5aquifer systems, accounting for flow variability, pore volume, and retardation due to 

6physical or chemical interactions with the aquifer matrix. Residence time represents the 

7duration a compound spends traveling from infiltration to extraction points, depending on 

8flow rate (higher flow yields shorter residence time), pore volume (larger volume yields 

9longer residence time), and retardation factor (interaction with matrix yields longer 

10residence time). 

11 

12Available functions: 

13 

14- :func:`residence_time` - Compute residence times at specific time indices. Supports both 

15 forward (infiltration to extraction) and reverse (extraction to infiltration) directions. 

16 Handles single or multiple pore volumes (2D output for multiple volumes). Returns residence 

17 times in days using cumulative flow integration for accurate time-varying flow handling. 

18 

19- :func:`residence_time_mean` - Compute mean residence times over time intervals. Calculates 

20 average residence time between specified time edges using linear averaging to properly weight 

21 time-varying residence times. Supports same directional options as residence_time. Particularly 

22 useful for time-binned analysis. 

23 

24- :func:`fraction_explained` - Compute fraction of aquifer pore volumes with valid residence 

25 times. Indicates how many pore volumes have sufficient flow history to compute residence time. 

26 Returns values in [0, 1] where 1.0 means all volumes are fully informed. Useful for assessing 

27 spin-up periods and data coverage. NaN residence times indicate insufficient flow history. 

28 

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

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

31""" 

32 

33import numpy as np 

34import numpy.typing as npt 

35import pandas as pd 

36 

37from gwtransport.utils import linear_average, linear_interpolate 

38 

39 

40def residence_time( 

41 *, 

42 flow: npt.ArrayLike, 

43 flow_tedges: pd.DatetimeIndex | np.ndarray, 

44 aquifer_pore_volumes: npt.ArrayLike, 

45 index: pd.DatetimeIndex | np.ndarray | None = None, 

46 direction: str = "extraction_to_infiltration", 

47 retardation_factor: float = 1.0, 

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

49 """ 

50 Compute the residence time of retarded compound in the water in the aquifer. 

51 

52 Parameters 

53 ---------- 

54 flow : array-like 

55 Flow rate of water in the aquifer [m3/day]. The length of `flow` should match the length of `flow_tedges` minus one. 

56 flow_tedges : pandas.DatetimeIndex 

57 Time edges for the flow data. Used to compute the cumulative flow. 

58 Has a length of one more than `flow`. 

59 aquifer_pore_volumes : float or array-like of float 

60 Pore volume(s) of the aquifer [m3]. Can be a single value or an array 

61 of pore volumes representing different flow paths. 

62 index : pandas.DatetimeIndex, optional 

63 Index at which to compute the residence time. If left to None, flow_tedges is used. 

64 Default is None. 

65 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

66 Direction of the flow calculation: 

67 * 'extraction_to_infiltration': Extraction to infiltration modeling - how many days ago was the extracted water infiltrated 

68 * 'infiltration_to_extraction': Infiltration to extraction modeling - how many days until the infiltrated water is extracted 

69 Default is 'extraction_to_infiltration'. 

70 retardation_factor : float, optional 

71 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0. 

72 

73 Returns 

74 ------- 

75 numpy.ndarray 

76 Residence time of the retarded compound in the aquifer [days]. 

77 

78 See Also 

79 -------- 

80 residence_time_mean : Compute mean residence time over time intervals 

81 gwtransport.advection.gamma_infiltration_to_extraction : Use residence times for transport 

82 gwtransport.logremoval.residence_time_to_log_removal : Convert residence time to log removal 

83 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction 

84 :ref:`concept-retardation-factor` : Slower movement due to sorption 

85 """ 

86 aquifer_pore_volumes = np.atleast_1d(aquifer_pore_volumes) 

87 flow_tedges = pd.DatetimeIndex(flow_tedges) 

88 

89 # Convert to arrays for type safety 

90 flow = np.asarray(flow) 

91 aquifer_pore_volumes = np.asarray(aquifer_pore_volumes) 

92 

93 if len(flow_tedges) != len(flow) + 1: 

94 msg = "tedges must have one more element than flow" 

95 raise ValueError(msg) 

96 

97 # Check for negative flow values - physically invalid 

98 if np.any(flow < 0): 

99 # Return NaN array with correct shape 

100 n_output = len(flow_tedges) - 1 if index is None else len(index) 

101 n_pore_volumes = len(aquifer_pore_volumes) 

102 return np.full((n_pore_volumes, n_output), np.nan) 

103 

104 flow_tedges_days = np.asarray((flow_tedges - flow_tedges[0]) / np.timedelta64(1, "D")) 

105 flow_tdelta = np.diff(flow_tedges_days) 

106 flow_cum = np.concatenate(( 

107 [0.0], 

108 (flow * flow_tdelta).cumsum(), 

109 )) # at flow_tedges and flow_tedges_days. First value is 0. 

110 

111 if index is None: 

112 # If index is not provided return the residence time that matches with the index of the flow; at the center of the flow bin. 

113 index_dates_days_extraction = (flow_tedges_days[:-1] + flow_tedges_days[1:]) / 2 

114 flow_cum_at_index = (flow_cum[:-1] + flow_cum[1:]) / 2 # at the center of the flow bin 

115 else: 

116 index_dates_days_extraction = np.asarray((index - flow_tedges[0]) / np.timedelta64(1, "D")) 

117 flow_cum_at_index = linear_interpolate( 

118 x_ref=flow_tedges_days, y_ref=flow_cum, x_query=index_dates_days_extraction, left=np.nan, right=np.nan 

119 ) 

120 

121 if direction == "extraction_to_infiltration": 

122 # How many days ago was the extraced water infiltrated 

123 a = flow_cum_at_index[None, :] - retardation_factor * aquifer_pore_volumes[:, None] 

124 days = linear_interpolate(x_ref=flow_cum, y_ref=flow_tedges_days, x_query=a, left=np.nan, right=np.nan) 

125 data = index_dates_days_extraction - days 

126 elif direction == "infiltration_to_extraction": 

127 # In how many days the water that is infiltrated now be extracted 

128 a = flow_cum_at_index[None, :] + retardation_factor * aquifer_pore_volumes[:, None] 

129 days = linear_interpolate(x_ref=flow_cum, y_ref=flow_tedges_days, x_query=a, left=np.nan, right=np.nan) 

130 data = days - index_dates_days_extraction 

131 else: 

132 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'" 

133 raise ValueError(msg) 

134 

135 return data 

136 

137 

138def residence_time_mean( 

139 *, 

140 flow: npt.ArrayLike, 

141 flow_tedges: pd.DatetimeIndex | np.ndarray, 

142 tedges_out: pd.DatetimeIndex | np.ndarray, 

143 aquifer_pore_volumes: npt.ArrayLike, 

144 direction: str = "extraction_to_infiltration", 

145 retardation_factor: float = 1.0, 

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

147 """ 

148 Compute the mean residence time of a retarded compound in the aquifer between specified time edges. 

149 

150 This function calculates the average residence time of a retarded compound in the aquifer 

151 between specified time intervals. It can compute both extraction to infiltration modeling (extraction direction: 

152 when was extracted water infiltrated) and infiltration to extraction modeling (infiltration direction: when will 

153 infiltrated water be extracted). 

154 

155 The function handles time series data by computing the cumulative flow and using linear 

156 interpolation and averaging to determine mean residence times between the specified time edges. 

157 

158 Parameters 

159 ---------- 

160 flow : array-like 

161 Flow rate of water in the aquifer [m3/day]. Should be an array of flow values 

162 corresponding to the intervals defined by flow_tedges. 

163 flow_tedges : array-like 

164 Time edges for the flow data, as datetime64 objects. These define the time 

165 intervals for which the flow values are provided. 

166 tedges_out : array-like 

167 Output time edges as datetime64 objects. These define the intervals for which 

168 the mean residence times will be calculated. 

169 aquifer_pore_volumes : float or array-like 

170 Pore volume(s) of the aquifer [m3]. Can be a single value or an array of values 

171 for multiple pore volume scenarios. 

172 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

173 Direction of the flow calculation: 

174 * 'extraction_to_infiltration': Extraction to infiltration modeling - how many days ago was the extracted water infiltrated 

175 * 'infiltration_to_extraction': Infiltration to extraction modeling - how many days until the infiltrated water is extracted 

176 Default is 'extraction_to_infiltration'. 

177 retardation_factor : float, optional 

178 Retardation factor of the compound in the aquifer [dimensionless]. 

179 A value greater than 1.0 indicates that the compound moves slower than water. 

180 Default is 1.0 (no retardation). 

181 

182 Returns 

183 ------- 

184 numpy.ndarray 

185 Mean residence time of the retarded compound in the aquifer [days] for each interval 

186 defined by tedges_out. The first dimension corresponds to the different pore volumes 

187 and the second to the residence times between tedges_out. 

188 

189 Notes 

190 ----- 

191 - The function converts datetime objects to days since the start of the time series. 

192 - For extraction_to_infiltration direction, the function computes how many days ago water was infiltrated. 

193 - For infiltration_to_extraction direction, the function computes how many days until water will be extracted. 

194 - The function uses linear interpolation for computing residence times at specific points 

195 and linear averaging for computing mean values over intervals. 

196 

197 Examples 

198 -------- 

199 >>> import pandas as pd 

200 >>> import numpy as np 

201 >>> from gwtransport.residence_time import residence_time_mean 

202 >>> # Create sample flow data 

203 >>> flow_dates = pd.date_range(start="2023-01-01", end="2023-01-10", freq="D") 

204 >>> flow_values = np.full(len(flow_dates) - 1, 100.0) # Constant flow of 100 m³/day 

205 >>> pore_volume = 200.0 # Aquifer pore volume in m³ 

206 >>> # Calculate mean residence times 

207 >>> mean_times = residence_time_mean( 

208 ... flow=flow_values, 

209 ... flow_tedges=flow_dates, 

210 ... tedges_out=flow_dates, 

211 ... aquifer_pore_volumes=pore_volume, 

212 ... direction="extraction_to_infiltration", 

213 ... ) 

214 >>> # With constant flow of 100 m³/day and pore volume of 200 m³, 

215 >>> # mean residence time should be approximately 2 days 

216 >>> print(mean_times) # doctest: +NORMALIZE_WHITESPACE 

217 [[nan nan 2. 2. 2. 2. 2. 2. 2.]] 

218 

219 See Also 

220 -------- 

221 residence_time : Compute residence time at specific time indices 

222 :ref:`concept-residence-time` : Time in aquifer between infiltration and extraction 

223 """ 

224 flow = np.asarray(flow) 

225 flow_tedges = pd.DatetimeIndex(flow_tedges) 

226 tedges_out = pd.DatetimeIndex(tedges_out) 

227 aquifer_pore_volumes = np.atleast_1d(aquifer_pore_volumes) 

228 

229 # Check for negative flow values - physically invalid 

230 if np.any(flow < 0): 

231 # Return NaN array with correct shape 

232 n_pore_volumes = len(aquifer_pore_volumes) 

233 n_output_bins = len(tedges_out) - 1 

234 return np.full((n_pore_volumes, n_output_bins), np.nan) 

235 

236 flow_tedges_days = np.asarray((flow_tedges - flow_tedges[0]) / np.timedelta64(1, "D")) 

237 tedges_out_days = np.asarray((tedges_out - flow_tedges[0]) / np.timedelta64(1, "D")) 

238 

239 # compute cumulative flow at flow_tedges 

240 flow_cum = np.concatenate(([0.0], np.cumsum(flow * np.diff(flow_tedges_days)))) 

241 

242 if direction == "extraction_to_infiltration": 

243 # How many days ago was the extraced water infiltrated 

244 a = flow_cum[None, :] - retardation_factor * aquifer_pore_volumes[:, None] 

245 days = linear_interpolate(x_ref=flow_cum, y_ref=flow_tedges_days, x_query=a, left=np.nan, right=np.nan) 

246 data_edges = flow_tedges_days - days 

247 # Process each pore volume (row) separately. Although linear_average supports 2D x_edges, 

248 # our use case is different: multiple time series (different y_data) with same edges, 

249 # rather than same time series with multiple edge sets. 

250 data_avg = np.array([ 

251 linear_average(x_data=flow_tedges_days, y_data=y, x_edges=tedges_out_days)[0] for y in data_edges 

252 ]) 

253 elif direction == "infiltration_to_extraction": 

254 # In how many days the water that is infiltrated now be extracted 

255 a = flow_cum[None, :] + retardation_factor * aquifer_pore_volumes[:, None] 

256 days = linear_interpolate(x_ref=flow_cum, y_ref=flow_tedges_days, x_query=a, left=np.nan, right=np.nan) 

257 data_edges = days - flow_tedges_days 

258 # Process each pore volume (row) separately. Although linear_average supports 2D x_edges, 

259 # our use case is different: multiple time series (different y_data) with same edges, 

260 # rather than same time series with multiple edge sets. 

261 data_avg = np.array([ 

262 linear_average(x_data=flow_tedges_days, y_data=y, x_edges=tedges_out_days)[0] for y in data_edges 

263 ]) 

264 else: 

265 msg = "direction should be 'extraction_to_infiltration' or 'infiltration_to_extraction'" 

266 raise ValueError(msg) 

267 return data_avg 

268 

269 

270def fraction_explained( 

271 *, 

272 rt: npt.NDArray[np.floating] | None = None, 

273 flow: npt.ArrayLike | None = None, 

274 flow_tedges: pd.DatetimeIndex | np.ndarray | None = None, 

275 aquifer_pore_volumes: npt.ArrayLike | None = None, 

276 index: pd.DatetimeIndex | np.ndarray | None = None, 

277 direction: str = "extraction_to_infiltration", 

278 retardation_factor: float = 1.0, 

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

280 """ 

281 Compute the fraction of the aquifer that is informed with respect to the retarded flow. 

282 

283 Parameters 

284 ---------- 

285 rt : numpy.ndarray, optional 

286 Pre-computed residence time array [days]. If not provided, it will be computed. 

287 flow : array-like, optional 

288 Flow rate of water in the aquifer [m3/day]. The length of `flow` should match the length of `flow_tedges` minus one. 

289 flow_tedges : pandas.DatetimeIndex, optional 

290 Time edges for the flow data. Used to compute the cumulative flow. 

291 Has a length of one more than `flow`. Inbetween neighboring time edges, the flow is assumed constant. 

292 aquifer_pore_volumes : float or array-like of float, optional 

293 Pore volume(s) of the aquifer [m3]. Can be a single value or an array 

294 of pore volumes representing different flow paths. 

295 index : pandas.DatetimeIndex, optional 

296 Index at which to compute the fraction. If left to None, the index of `flow` is used. 

297 Default is None. 

298 direction : {'extraction_to_infiltration', 'infiltration_to_extraction'}, optional 

299 Direction of the flow calculation: 

300 * 'extraction_to_infiltration': Extraction to infiltration modeling - how many days ago was the extracted water infiltrated 

301 * 'infiltration_to_extraction': Infiltration to extraction modeling - how many days until the infiltrated water is extracted 

302 Default is 'extraction_to_infiltration'. 

303 retardation_factor : float, optional 

304 Retardation factor of the compound in the aquifer [dimensionless]. Default is 1.0. 

305 

306 Returns 

307 ------- 

308 numpy.ndarray 

309 Fraction of the aquifer that is informed with respect to the retarded flow. 

310 """ 

311 if rt is None: 

312 # Validate that required parameters are provided for computing rt 

313 if flow is None: 

314 msg = "Either rt or flow must be provided" 

315 raise ValueError(msg) 

316 if flow_tedges is None: 

317 msg = "Either rt or flow_tedges must be provided" 

318 raise ValueError(msg) 

319 if aquifer_pore_volumes is None: 

320 msg = "Either rt or aquifer_pore_volumes must be provided" 

321 raise ValueError(msg) 

322 

323 rt = residence_time( 

324 flow=flow, 

325 flow_tedges=flow_tedges, 

326 aquifer_pore_volumes=aquifer_pore_volumes, 

327 index=index, 

328 direction=direction, 

329 retardation_factor=retardation_factor, 

330 ) 

331 

332 _expected_ndim = 2 

333 if rt.ndim != _expected_ndim: 

334 msg = f"rt must be 2D with shape (n_pore_volumes, n_times), got {rt.ndim}D" 

335 raise ValueError(msg) 

336 

337 n_aquifer_pore_volume = rt.shape[0] 

338 return (n_aquifer_pore_volume - np.isnan(rt).sum(axis=0)) / n_aquifer_pore_volume 

339 

340 

341def freundlich_retardation( 

342 *, 

343 concentration: npt.ArrayLike, 

344 freundlich_k: float, 

345 freundlich_n: float, 

346 bulk_density: float, 

347 porosity: float, 

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

349 """ 

350 Compute concentration-dependent retardation factors using Freundlich isotherm. 

351 

352 The Freundlich isotherm relates sorbed concentration S to aqueous concentration C: 

353 S = rho_f * C^n 

354 

355 The retardation factor is computed as: 

356 R = 1 + (rho_b/θ) * dS/dC = 1 + (rho_b/θ) * rho_f * n * C^(n-1) 

357 

358 Parameters 

359 ---------- 

360 concentration : array-like 

361 Concentration of compound in water [mass/volume]. 

362 Length should match flow (i.e., len(flow_tedges) - 1). 

363 freundlich_k : float 

364 Freundlich coefficient [(m³/kg)^(1/n)]. 

365 freundlich_n : float 

366 Freundlich sorption exponent [dimensionless]. 

367 bulk_density : float 

368 Bulk density of aquifer material [mass/volume]. 

369 porosity : float 

370 Porosity of aquifer [dimensionless, 0-1]. 

371 

372 Returns 

373 ------- 

374 numpy.ndarray 

375 Retardation factors for each flow interval. 

376 Length equals len(concentration) for use as retardation_factor in residence_time. 

377 

378 Examples 

379 -------- 

380 >>> concentration = np.array([0.1, 0.2, 0.3]) # same length as flow 

381 >>> R = freundlich_retardation( 

382 ... concentration=concentration, 

383 ... freundlich_k=0.5, 

384 ... freundlich_n=0.9, 

385 ... bulk_density=1600, # kg/m3 

386 ... porosity=0.35, 

387 ... ) 

388 >>> # Use R in residence_time as retardation_factor 

389 

390 See Also 

391 -------- 

392 residence_time : Compute residence times with retardation 

393 gwtransport.advection.infiltration_to_extraction_front_tracking : Transport with nonlinear sorption 

394 :ref:`concept-nonlinear-sorption` : Freundlich isotherm and concentration-dependent retardation 

395 """ 

396 concentration = np.asarray(concentration) 

397 

398 # Validate physical parameters 

399 if not 0 < porosity < 1: 

400 msg = f"Porosity must be in (0, 1), got {porosity}" 

401 raise ValueError(msg) 

402 if bulk_density <= 0: 

403 msg = f"Bulk density must be positive, got {bulk_density}" 

404 raise ValueError(msg) 

405 if freundlich_k < 0: 

406 msg = f"Freundlich K must be non-negative, got {freundlich_k}" 

407 raise ValueError(msg) 

408 

409 concentration_safe = np.maximum(concentration, 1e-12) # Avoid zero concentration issues 

410 return 1.0 + (bulk_density / porosity) * freundlich_k * freundlich_n * np.power( 

411 concentration_safe, freundlich_n - 1 

412 )