Coverage for src / gwtransport / deposition.py: 75%

138 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-27 06:32 +0000

1""" 

2Deposition Analysis for 1D Aquifer Systems. 

3 

4This module models compound *source enrichment* in 1D groundwater flow: a deposition rate 

5[ng/m²/day] supplies mass from the aquifer matrix into the water along the flow path, 

6increasing the extracted concentration. The model is a *source* term (positive deposition 

7adds mass to the water); it does NOT model removal processes such as pathogen attachment, 

8particle filtration, or chemical precipitation, which would remove mass from the water and 

9require an opposite sign convention. Example physical scenarios: dissolution of a sparingly 

10soluble mineral coating from the matrix, leaching of a stored solute, mass release from a 

11distributed contaminant source on the matrix surface. The module follows advection module 

12patterns for consistency in forward (deposition to extraction) and reverse (extraction to 

13deposition) calculations. 

14 

15Available functions: 

16 

17- :func:`deposition_to_extraction` - Compute concentrations from deposition rates (convolution). 

18 Given deposition rate time series [ng/m²/day], computes resulting concentration changes in 

19 extracted water [ng/m³]. Uses flow-weighted integration over contact area between water and 

20 aquifer matrix. Accounts for aquifer geometry (porosity, thickness) and residence time 

21 distribution. 

22 

23- :func:`extraction_to_deposition` - Compute deposition rates from concentration changes 

24 (deconvolution). Given concentration change time series in extracted water [ng/m³], estimates 

25 deposition rate history [ng/m²/day] that produced those changes. Uses Tikhonov regularization 

26 toward a physically motivated target (transpose-and-normalize of the forward matrix). Handles 

27 NaN values in concentration data by excluding corresponding time periods. 

28 

29- :func:`extraction_to_deposition_full` - Full-featured inverse solver exposing all options of 

30 the nullspace-based solver (:func:`~gwtransport.utils.solve_underdetermined_system`). Allows 

31 choosing between different nullspace objectives (``'squared_differences'``, 

32 ``'summed_differences'``, or custom callables) and optimization methods. 

33 

34- :func:`compute_deposition_weights` - Internal helper function. Compute weight matrix relating 

35 deposition rates to concentration changes. Used by both deposition_to_extraction (forward) and 

36 extraction_to_deposition (reverse). Calculates contact area between water parcels and aquifer 

37 matrix based on streamline geometry and residence times. 

38 

39- :func:`spinup_duration` - Compute spinup duration for deposition modeling. Returns the 

40 earliest extraction time at which the extracted water was infiltrated at the start of the 

41 flow series (equivalently, the time at which cumulative flow first reaches 

42 ``retardation_factor * aquifer_pore_volume``). Before this duration the extracted 

43 concentration lacks complete deposition history. Useful for determining the valid analysis 

44 period and identifying when boundary effects are negligible. 

45 

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

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

48""" 

49 

50import warnings 

51from collections.abc import Callable 

52 

53import numpy as np 

54import numpy.typing as npt 

55import pandas as pd 

56 

57from gwtransport.deposition_utils import compute_average_heights 

58from gwtransport.residence_time import residence_time 

59from gwtransport.utils import compute_reverse_target, linear_interpolate, solve_tikhonov, solve_underdetermined_system 

60 

61 

62def compute_deposition_weights( 

63 *, 

64 flow: npt.ArrayLike, 

65 tedges: pd.DatetimeIndex, 

66 cout_tedges: pd.DatetimeIndex, 

67 aquifer_pore_volume: float, 

68 porosity: float, 

69 thickness: float, 

70 retardation_factor: float = 1.0, 

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

72 """Compute deposition weights for concentration-deposition convolution. 

73 

74 Parameters 

75 ---------- 

76 flow : array-like 

77 Flow rates in aquifer [m3/day]. Length must equal len(tedges) - 1. 

78 tedges : pandas.DatetimeIndex 

79 Time bin edges for flow data. 

80 cout_tedges : pandas.DatetimeIndex 

81 Time bin edges for output concentration data. 

82 aquifer_pore_volume : float 

83 Aquifer pore volume [m3]. 

84 porosity : float 

85 Aquifer porosity [dimensionless]. 

86 thickness : float 

87 Aquifer thickness [m]. 

88 retardation_factor : float, optional 

89 Compound retardation factor, by default 1.0. 

90 

91 Returns 

92 ------- 

93 numpy.ndarray 

94 Deposition weights matrix with shape (len(cout_tedges) - 1, len(tedges) - 1). 

95 May contain NaN values where residence time cannot be computed. 

96 

97 Notes 

98 ----- 

99 The returned weights matrix may contain NaN values in locations where the 

100 residence time calculation fails or is undefined. This typically occurs 

101 when flow conditions result in invalid or non-physical residence times. 

102 """ 

103 # Convert to days relative to first time edge 

104 t0 = tedges[0] 

105 tedges_days = ((tedges - t0) / pd.Timedelta(days=1)).values 

106 cout_tedges_days = ((cout_tedges - t0) / pd.Timedelta(days=1)).values 

107 

108 # Compute residence times and cumulative flow 

109 flow_values = np.asarray(flow) 

110 cout_rt_at_edges = residence_time( 

111 flow=flow_values, 

112 flow_tedges=tedges, 

113 index=cout_tedges, 

114 aquifer_pore_volumes=float(aquifer_pore_volume), 

115 retardation_factor=retardation_factor, 

116 direction="extraction_to_infiltration", 

117 ) 

118 # residence_time returns shape (n_pore_volumes, n_index); we pass a single 

119 # pore volume so select row 0 explicitly. 

120 cout_tedges_days_infiltration = cout_tedges_days - cout_rt_at_edges[0, :] 

121 

122 flow_cum = np.concatenate(([0.0], np.cumsum(flow_values * np.diff(tedges_days)))) 

123 

124 # Interpolate volumes at concentration time edges 

125 start_vol = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days_infiltration) 

126 end_vol = linear_interpolate(x_ref=tedges_days, y_ref=flow_cum, x_query=cout_tedges_days) 

127 

128 # Compute deposition weights 

129 flow_cum_cout = flow_cum[None, :] - start_vol[:, None] 

130 volume_array = compute_average_heights( 

131 x_edges=tedges_days, y_edges=flow_cum_cout, y_lower=0.0, y_upper=retardation_factor * float(aquifer_pore_volume) 

132 ) 

133 area_array = volume_array / (porosity * thickness) 

134 extracted_volume = np.diff(end_vol) 

135 # Zero-flow cout bins have extracted_volume == 0 (no water extracted), so 

136 # the weight row collapses to 0: no extraction contributes no deposition 

137 # signature to cout. Use a sentinel divisor on those rows to avoid the 

138 # division-by-zero warning before np.where discards them. 

139 numerator = area_array * np.diff(tedges_days)[None, :] 

140 mask = extracted_volume > 0 

141 safe_denom = np.where(mask, extracted_volume, 1.0)[:, None] 

142 return np.where(mask[:, None], numerator / safe_denom, 0.0) 

143 

144 

145def deposition_to_extraction( 

146 *, 

147 dep: npt.ArrayLike, 

148 flow: npt.ArrayLike, 

149 tedges: pd.DatetimeIndex | np.ndarray, 

150 cout_tedges: pd.DatetimeIndex | np.ndarray, 

151 aquifer_pore_volume: float, 

152 porosity: float, 

153 thickness: float, 

154 retardation_factor: float = 1.0, 

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

156 """Compute concentrations from deposition rates (convolution). 

157 

158 Parameters 

159 ---------- 

160 dep : array-like 

161 Deposition rates [ng/m2/day]. Length must equal len(tedges) - 1. 

162 flow : array-like 

163 Flow rates in aquifer [m3/day]. Length must equal len(tedges) - 1. The model 

164 assumes this value is constant over each interval ``[tedges[i], tedges[i+1])``. 

165 tedges : pandas.DatetimeIndex 

166 Time bin edges for deposition and flow data. 

167 cout_tedges : pandas.DatetimeIndex 

168 Time bin edges for output concentration data. 

169 aquifer_pore_volume : float 

170 Aquifer pore volume [m3]. 

171 porosity : float 

172 Aquifer porosity [dimensionless]. 

173 thickness : float 

174 Aquifer thickness [m]. 

175 retardation_factor : float, optional 

176 Compound retardation factor, by default 1.0. 

177 

178 Returns 

179 ------- 

180 numpy.ndarray 

181 Concentration changes [ng/m3] with length len(cout_tedges) - 1. 

182 

183 Raises 

184 ------ 

185 ValueError 

186 If tedges does not have one more element than dep or flow, if input 

187 arrays contain NaN values, or if physical parameters are out of 

188 valid range (porosity not in (0, 1), non-positive thickness or 

189 aquifer pore volume). 

190 

191 See Also 

192 -------- 

193 extraction_to_deposition : Inverse operation (deconvolution) 

194 gwtransport.advection.infiltration_to_extraction : For concentration transport without deposition 

195 :ref:`concept-transport-equation` : Flow-weighted averaging approach 

196 

197 Examples 

198 -------- 

199 >>> import pandas as pd 

200 >>> import numpy as np 

201 >>> from gwtransport.deposition import deposition_to_extraction 

202 >>> dates = pd.date_range("2020-01-01", "2020-01-10", freq="D") 

203 >>> tedges = pd.date_range("2019-12-31 12:00", "2020-01-10 12:00", freq="D") 

204 >>> cout_tedges = pd.date_range("2020-01-03 12:00", "2020-01-12 12:00", freq="D") 

205 >>> dep = np.ones(len(dates)) 

206 >>> flow = np.full(len(dates), 100.0) 

207 >>> cout = deposition_to_extraction( 

208 ... dep=dep, 

209 ... flow=flow, 

210 ... tedges=tedges, 

211 ... cout_tedges=cout_tedges, 

212 ... aquifer_pore_volume=500.0, 

213 ... porosity=0.3, 

214 ... thickness=10.0, 

215 ... ) 

216 """ 

217 tedges, cout_tedges = pd.DatetimeIndex(tedges), pd.DatetimeIndex(cout_tedges) 

218 dep_values, flow_values = np.asarray(dep), np.asarray(flow) 

219 

220 # Validate input dimensions and values 

221 if len(tedges) != len(dep_values) + 1: 

222 msg = "tedges must have one more element than dep" 

223 raise ValueError(msg) 

224 if len(tedges) != len(flow_values) + 1: 

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

226 raise ValueError(msg) 

227 if np.any(np.isnan(dep_values)) or np.any(np.isnan(flow_values)): 

228 msg = "Input arrays cannot contain NaN values" 

229 raise ValueError(msg) 

230 if np.any(flow_values < 0): 

231 msg = "flow must be non-negative (negative flow not supported)" 

232 raise ValueError(msg) 

233 

234 # Validate physical parameters 

235 if not 0 < porosity < 1: 

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

237 raise ValueError(msg) 

238 if thickness <= 0: 

239 msg = f"Thickness must be positive, got {thickness}" 

240 raise ValueError(msg) 

241 if aquifer_pore_volume <= 0: 

242 msg = f"Aquifer pore volume must be positive, got {aquifer_pore_volume}" 

243 raise ValueError(msg) 

244 

245 # Compute deposition weights 

246 deposition_weights = compute_deposition_weights( 

247 flow=flow_values, 

248 tedges=tedges, 

249 cout_tedges=cout_tedges, 

250 aquifer_pore_volume=aquifer_pore_volume, 

251 porosity=porosity, 

252 thickness=thickness, 

253 retardation_factor=retardation_factor, 

254 ) 

255 

256 return deposition_weights.dot(dep_values) 

257 

258 

259def extraction_to_deposition( 

260 *, 

261 cout: npt.ArrayLike, 

262 flow: npt.ArrayLike, 

263 tedges: pd.DatetimeIndex | np.ndarray, 

264 cout_tedges: pd.DatetimeIndex | np.ndarray, 

265 aquifer_pore_volume: float, 

266 porosity: float, 

267 thickness: float, 

268 retardation_factor: float = 1.0, 

269 regularization_strength: float = 1e-10, 

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

271 """Compute deposition rates from concentration changes (deconvolution). 

272 

273 Inverts the forward model by solving ``W @ dep = cout`` where ``W`` is 

274 the weight matrix from :func:`compute_deposition_weights`. Uses Tikhonov 

275 regularization to smoothly blend data fitting with a physically motivated 

276 target (transpose-and-normalize of the forward matrix). 

277 

278 Well-determined modes (large singular values relative to ``sqrt(λ)``) are 

279 dominated by the data; poorly-determined modes are pulled toward the 

280 target. 

281 

282 Parameters 

283 ---------- 

284 cout : array-like 

285 Concentration changes in extracted water [ng/m3]. Length must equal 

286 len(cout_tedges) - 1. May contain NaN values, which will be excluded 

287 from the computation along with corresponding rows in the weight matrix. 

288 The model assumes this value is constant over each interval 

289 ``[cout_tedges[i], cout_tedges[i+1])``. 

290 flow : array-like 

291 Flow rates in aquifer [m3/day]. Length must equal len(tedges) - 1. 

292 Must not contain NaN values. The model assumes this value is constant 

293 over each interval ``[tedges[i], tedges[i+1])``. 

294 tedges : pandas.DatetimeIndex 

295 Time bin edges for deposition and flow data. Length must equal 

296 len(flow) + 1. 

297 cout_tedges : pandas.DatetimeIndex 

298 Time bin edges for output concentration data. Length must equal 

299 len(cout) + 1. 

300 aquifer_pore_volume : float 

301 Aquifer pore volume [m3]. 

302 porosity : float 

303 Aquifer porosity [dimensionless]. 

304 thickness : float 

305 Aquifer thickness [m]. 

306 retardation_factor : float, optional 

307 Compound retardation factor, by default 1.0. Values > 1.0 indicate 

308 slower transport due to sorption/interaction. 

309 regularization_strength : float, optional 

310 Tikhonov regularization parameter λ. Controls the tradeoff between 

311 fitting the data (``||W dep - cout||²``) and staying close to the 

312 regularization target (``λ ||dep - dep_target||²``). The target is 

313 the transpose-and-normalize of the forward matrix applied to cout. 

314 

315 Larger values trust the target more (smoother, more biased); smaller 

316 values trust the data more (noisier, less biased). Default is 1e-10. 

317 

318 Returns 

319 ------- 

320 numpy.ndarray 

321 Mean deposition rates [ng/m2/day] between tedges. Length equals 

322 len(tedges) - 1. 

323 

324 Raises 

325 ------ 

326 ValueError 

327 If input dimensions are incompatible or if flow contains NaN values. 

328 

329 Warns 

330 ----- 

331 UserWarning 

332 When the weight matrix is rank-deficient. This occurs with constant 

333 flow when the residence time is an integer multiple of the time step 

334 width. The deposition weight matrix then acts as a uniform moving 

335 average whose transfer function has exact zeros, making certain 

336 deposition patterns invisible in the concentration signal. To fix, 

337 adjust ``aquifer_pore_volume`` slightly (e.g., multiply by 1.001). 

338 

339 See Also 

340 -------- 

341 deposition_to_extraction : Forward operation (convolution) 

342 extraction_to_deposition_full : Full solver with nullspace options 

343 gwtransport.advection.extraction_to_infiltration : For concentration transport without deposition 

344 gwtransport.utils.solve_tikhonov : Solver used for inversion 

345 :ref:`concept-transport-equation` : Flow-weighted averaging approach 

346 

347 Notes 

348 ----- 

349 The forward model is ``W @ dep = cout``, where the weight matrix ``W`` 

350 encodes the physical relationship between deposition rates and 

351 concentrations. Unlike advection (where rows sum to ~1), deposition rows 

352 sum to ``residence_time / (porosity * thickness)``. The system is 

353 row-normalized before solving so that each observation contributes equally 

354 and ``compute_reverse_target`` gives the correct scale for the 

355 regularization target. Rows where the residence time cannot be computed 

356 (spin-up period) contain NaN and are excluded automatically. NaN values 

357 in ``cout`` are also excluded. 

358 

359 Examples 

360 -------- 

361 >>> import pandas as pd 

362 >>> import numpy as np 

363 >>> from gwtransport.deposition import extraction_to_deposition 

364 >>> 

365 >>> dates = pd.date_range("2020-01-01", "2020-01-10", freq="D") 

366 >>> tedges = pd.date_range("2019-12-31 12:00", "2020-01-10 12:00", freq="D") 

367 >>> cout_tedges = pd.date_range("2020-01-03 12:00", "2020-01-12 12:00", freq="D") 

368 >>> 

369 >>> flow = np.full(len(dates), 100.0) # m3/day 

370 >>> cout = np.ones(len(cout_tedges) - 1) * 10.0 # ng/m3 

371 >>> 

372 >>> dep = extraction_to_deposition( 

373 ... cout=cout, 

374 ... flow=flow, 

375 ... tedges=tedges, 

376 ... cout_tedges=cout_tedges, 

377 ... aquifer_pore_volume=500.0, 

378 ... porosity=0.3, 

379 ... thickness=10.0, 

380 ... ) 

381 >>> print(f"Deposition rates shape: {dep.shape}") 

382 Deposition rates shape: (10,) 

383 >>> print(f"Mean deposition rate: {np.nanmean(dep):.2f} ng/m2/day") 

384 Mean deposition rate: 6.00 ng/m2/day 

385 """ 

386 tedges, cout_tedges = pd.DatetimeIndex(tedges), pd.DatetimeIndex(cout_tedges) 

387 cout_values, flow_values = np.asarray(cout), np.asarray(flow) 

388 

389 # Validate input dimensions and values 

390 if len(cout_tedges) != len(cout_values) + 1: 

391 msg = "cout_tedges must have one more element than cout" 

392 raise ValueError(msg) 

393 if len(tedges) != len(flow_values) + 1: 

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

395 raise ValueError(msg) 

396 if np.any(np.isnan(flow_values)): 

397 msg = "flow array cannot contain NaN values" 

398 raise ValueError(msg) 

399 if np.any(flow_values < 0): 

400 msg = "flow must be non-negative (negative flow not supported)" 

401 raise ValueError(msg) 

402 

403 # Validate physical parameters 

404 if not 0 < porosity < 1: 

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

406 raise ValueError(msg) 

407 if thickness <= 0: 

408 msg = f"Thickness must be positive, got {thickness}" 

409 raise ValueError(msg) 

410 if aquifer_pore_volume <= 0: 

411 msg = f"Aquifer pore volume must be positive, got {aquifer_pore_volume}" 

412 raise ValueError(msg) 

413 

414 # Compute deposition weights 

415 deposition_weights = compute_deposition_weights( 

416 flow=flow_values, 

417 tedges=tedges, 

418 cout_tedges=cout_tedges, 

419 aquifer_pore_volume=aquifer_pore_volume, 

420 porosity=porosity, 

421 thickness=thickness, 

422 retardation_factor=retardation_factor, 

423 ) 

424 

425 n_dep = len(tedges) - 1 

426 

427 # Normalize weight matrix rows to sum to 1. The deposition weight matrix 

428 # W has row sums equal to residence_time/(porosity*thickness), not 1 like 

429 # advection. Normalizing makes each observation equally important and 

430 # gives compute_reverse_target the correct scale for the target. 

431 # NaN rows (spin-up), all-zero rows (zero-flow cout bins; carry no info), 

432 # and NaN values in cout are excluded by solve_tikhonov. 

433 valid_rows = ~np.isnan(deposition_weights).any(axis=1) & (np.abs(deposition_weights).sum(axis=1) > 0) 

434 valid_weights = deposition_weights[valid_rows] 

435 row_sums = valid_weights.sum(axis=1, keepdims=True) 

436 col_active = np.sum(np.abs(valid_weights), axis=0) > 0 

437 

438 if not np.any(col_active): 

439 return np.full(n_dep, np.nan) 

440 

441 # Build normalized system: W_norm @ dep = cout_norm 

442 w_norm = valid_weights / row_sums 

443 cout_norm = cout_values[valid_rows] / row_sums.ravel() 

444 

445 # Check for rank deficiency. With constant flow and integer RT/dt, the 

446 # deposition weight matrix acts as a uniform moving average whose transfer 

447 # function has exact zeros, making certain deposition patterns invisible. 

448 n_active = int(col_active.sum()) 

449 rank = np.linalg.matrix_rank(w_norm[:, col_active]) 

450 if rank < n_active: 

451 warnings.warn( 

452 f"Weight matrix is rank-deficient (rank {rank} < {n_active} active " 

453 f"columns). This occurs with constant flow when the residence time " 

454 f"is an integer multiple of the time step width, creating deposition " 

455 f"patterns that are invisible in the concentration signal. The " 

456 f"underdetermined modes will be pulled toward the regularization " 

457 f"target instead of being determined by data. To achieve full rank, " 

458 f"adjust aquifer_pore_volume slightly (e.g., multiply by 1.001).", 

459 stacklevel=2, 

460 ) 

461 

462 # Reconstruct full arrays with NaN for invalid rows 

463 full_w_norm = np.full_like(deposition_weights, np.nan) 

464 full_w_norm[valid_rows] = w_norm 

465 full_cout_norm = np.full(len(cout_values), np.nan) 

466 full_cout_norm[valid_rows] = cout_norm 

467 

468 x_target = compute_reverse_target(coeff_matrix=w_norm, rhs_vector=cout_norm) 

469 

470 dep_solved = solve_tikhonov( 

471 coefficient_matrix=full_w_norm, 

472 rhs_vector=full_cout_norm, 

473 x_target=x_target, 

474 regularization_strength=regularization_strength, 

475 ) 

476 

477 out = np.full(n_dep, np.nan) 

478 out[col_active] = dep_solved[col_active] 

479 return out 

480 

481 

482def extraction_to_deposition_full( 

483 *, 

484 cout: npt.ArrayLike, 

485 flow: npt.ArrayLike, 

486 tedges: pd.DatetimeIndex | np.ndarray, 

487 cout_tedges: pd.DatetimeIndex | np.ndarray, 

488 aquifer_pore_volume: float, 

489 porosity: float, 

490 thickness: float, 

491 retardation_factor: float = 1.0, 

492 nullspace_objective: str | Callable = "squared_differences", 

493 optimization_method: str = "BFGS", 

494 rcond: float | None = None, 

495 x_target: npt.NDArray[np.floating] | None = None, 

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

497 """Compute deposition rates from concentration changes using nullspace solver. 

498 

499 Full-featured inverse solver exposing all options of 

500 :func:`~gwtransport.utils.solve_underdetermined_system`. For most use 

501 cases, prefer :func:`extraction_to_deposition` which uses Tikhonov 

502 regularization. 

503 

504 Parameters 

505 ---------- 

506 cout : array-like 

507 Concentration changes in extracted water [ng/m3]. Length must equal 

508 len(cout_tedges) - 1. May contain NaN values, which will be excluded 

509 from the computation along with corresponding rows in the weight matrix. 

510 flow : array-like 

511 Flow rates in aquifer [m3/day]. Length must equal len(tedges) - 1. 

512 Must not contain NaN values. 

513 tedges : pandas.DatetimeIndex 

514 Time bin edges for deposition and flow data. Length must equal 

515 len(flow) + 1. 

516 cout_tedges : pandas.DatetimeIndex 

517 Time bin edges for output concentration data. Length must equal 

518 len(cout) + 1. 

519 aquifer_pore_volume : float 

520 Aquifer pore volume [m3]. 

521 porosity : float 

522 Aquifer porosity [dimensionless]. 

523 thickness : float 

524 Aquifer thickness [m]. 

525 retardation_factor : float, optional 

526 Compound retardation factor, by default 1.0. 

527 nullspace_objective : str or callable, optional 

528 Objective function to minimize in the nullspace. Options: 

529 

530 * ``"squared_differences"`` : Minimize sum of squared differences 

531 between adjacent deposition rates (default, smooth solutions). 

532 * ``"summed_differences"`` : Minimize sum of absolute differences 

533 (sparse/piecewise constant solutions). 

534 * callable : Custom objective ``f(coeffs, x_ls, nullspace_basis)``. 

535 

536 optimization_method : str, optional 

537 Scipy optimization method. Default is ``"BFGS"``. 

538 rcond : float or None, optional 

539 Cutoff for small singular values in the least-squares step. 

540 Default is None (uses numpy default). 

541 x_target : numpy.ndarray or None, optional 

542 Optional target solution for the nullspace optimization. 

543 Default is None. 

544 

545 Returns 

546 ------- 

547 numpy.ndarray 

548 Mean deposition rates [ng/m2/day] between tedges. Length equals 

549 len(tedges) - 1. 

550 

551 Raises 

552 ------ 

553 ValueError 

554 If cout_tedges does not have one more element than cout, if tedges 

555 does not have one more element than flow, if flow contains NaN 

556 values, or if physical parameters are out of valid range (porosity 

557 not in (0, 1), non-positive thickness or aquifer pore volume). 

558 

559 See Also 

560 -------- 

561 extraction_to_deposition : Recommended solver using Tikhonov regularization. 

562 gwtransport.utils.solve_underdetermined_system : Underlying solver. 

563 """ 

564 tedges, cout_tedges = pd.DatetimeIndex(tedges), pd.DatetimeIndex(cout_tedges) 

565 cout_values, flow_values = np.asarray(cout), np.asarray(flow) 

566 

567 # Validate input dimensions and values 

568 if len(cout_tedges) != len(cout_values) + 1: 

569 msg = "cout_tedges must have one more element than cout" 

570 raise ValueError(msg) 

571 if len(tedges) != len(flow_values) + 1: 

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

573 raise ValueError(msg) 

574 if np.any(np.isnan(flow_values)): 

575 msg = "flow array cannot contain NaN values" 

576 raise ValueError(msg) 

577 if np.any(flow_values < 0): 

578 msg = "flow must be non-negative (negative flow not supported)" 

579 raise ValueError(msg) 

580 

581 # Validate physical parameters 

582 if not 0 < porosity < 1: 

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

584 raise ValueError(msg) 

585 if thickness <= 0: 

586 msg = f"Thickness must be positive, got {thickness}" 

587 raise ValueError(msg) 

588 if aquifer_pore_volume <= 0: 

589 msg = f"Aquifer pore volume must be positive, got {aquifer_pore_volume}" 

590 raise ValueError(msg) 

591 

592 # Compute deposition weights 

593 deposition_weights = compute_deposition_weights( 

594 flow=flow_values, 

595 tedges=tedges, 

596 cout_tedges=cout_tedges, 

597 aquifer_pore_volume=aquifer_pore_volume, 

598 porosity=porosity, 

599 thickness=thickness, 

600 retardation_factor=retardation_factor, 

601 ) 

602 

603 return solve_underdetermined_system( 

604 coefficient_matrix=deposition_weights, 

605 rhs_vector=cout_values, 

606 nullspace_objective=nullspace_objective, 

607 optimization_method=optimization_method, 

608 rcond=rcond, 

609 x_target=x_target, 

610 ) 

611 

612 

613def spinup_duration( 

614 *, 

615 flow: np.ndarray, 

616 flow_tedges: pd.DatetimeIndex, 

617 aquifer_pore_volume: float, 

618 retardation_factor: float, 

619) -> float: 

620 """ 

621 Compute the spinup duration for deposition modeling. 

622 

623 The spinup duration is the smallest extraction time ``t*`` (relative to 

624 ``flow_tedges[0]``) at which the extracted water was infiltrated exactly 

625 at ``flow_tedges[0]``: equivalently, the time at which the cumulative 

626 flow first reaches ``retardation_factor * aquifer_pore_volume``. For 

627 extraction times earlier than ``t*`` the extracted concentration lacks 

628 complete deposition history. Under constant flow this equals 

629 ``aquifer_pore_volume * retardation_factor / flow``. 

630 

631 Parameters 

632 ---------- 

633 flow : numpy.ndarray 

634 Flow rate of water in the aquifer [m3/day]. 

635 flow_tedges : pandas.DatetimeIndex 

636 Time edges for the flow data. 

637 aquifer_pore_volume : float 

638 Pore volume of the aquifer [m3]. 

639 retardation_factor : float 

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

641 

642 Returns 

643 ------- 

644 float 

645 Spinup duration in days. 

646 

647 Raises 

648 ------ 

649 ValueError 

650 If the cumulative flow over the entire ``flow_tedges`` window does 

651 not reach ``retardation_factor * aquifer_pore_volume``, indicating 

652 the flow timeseries is too short to fully characterise the aquifer. 

653 """ 

654 # Spin-up is the residence time of water *currently being extracted*: how 

655 # far back in history we must know deposition to fully characterise the 

656 # extracted concentration. This uses the ``extraction_to_infiltration`` 

657 # direction. Under variable flow this differs from 

658 # ``infiltration_to_extraction`` (which would describe how long ahead 

659 # water infiltrated at the first time step will take to be extracted, a 

660 # forward-in-time question that is not what spin-up means). 

661 # 

662 # The smallest extraction time t* at which the extracted water was 

663 # infiltrated exactly at flow_tedges[0] satisfies 

664 # ``flow_cum(t*) = R * V_pore``; the spin-up duration is then 

665 # ``t* - 0 = t*``. Inverting the cumulative flow gives this value 

666 # exactly (no quantisation to flow_tedges spacing). Under constant flow 

667 # this matches V*R/Q. 

668 flow_arr = np.asarray(flow) 

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

670 flow_cum = np.concatenate(([0.0], np.cumsum(flow_arr * np.diff(flow_tedges_days)))) 

671 target_cum = retardation_factor * float(aquifer_pore_volume) 

672 if not flow_cum[-1] >= target_cum: 

673 msg = "Residence time at the first time step is NaN. This indicates that the aquifer is not fully informed: flow timeseries too short." 

674 raise ValueError(msg) 

675 rt_value = float(linear_interpolate(x_ref=flow_cum, y_ref=flow_tedges_days, x_query=target_cum)) 

676 if np.isnan(rt_value): 

677 msg = "Residence time at the first time step is NaN. This indicates that the aquifer is not fully informed: flow timeseries too short." 

678 raise ValueError(msg) 

679 return rt_value