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

177 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 21:13 +0000

1""" 

2Advective Transport Modeling Along Aquifer Pore Volumes. 

3 

4Water infiltrates and is transported in parallel along multiple aquifer pore volumes to 

5extraction. For each aquifer pore volume, transport is 1D advection with linear or non-linear 

6sorption; there is no microdispersion or molecular diffusion, while the spread across aquifer 

7pore volumes provides macrodispersion. Forward and backward modeling are supported. No assumption 

8is made about whether the flow is radial or orthogonal. 

9 

10Note on dispersion: The spreading from the pore volume distribution (APVD) represents 

11macrodispersion—aquifer-scale velocity heterogeneity that depends on both aquifer 

12properties and hydrological boundary conditions. To add microdispersion and molecular 

13diffusion separately (when APVD comes from streamline analysis), use :mod:`gwtransport.diffusion`. 

14See :ref:`concept-dispersion-scales` for details. 

15 

16Note on cross-compound calibration: When APVD is calibrated from measurements of one 

17compound (e.g., temperature with D_m ~ 0.1 m²/day) and used to predict another (e.g., a 

18solute with D_m ~ 1e-4 m²/day), the molecular diffusion contribution is baked into the 

19calibrated std. The cleanest fix is to calibrate with :mod:`gwtransport.diffusion_fast` 

20instead, which keeps the three contributions separate. 

21 

22The aquifer pore volume distribution (APVD) enters in one of two forms. The ``gamma_*`` pair takes 

23the APVD in closed form as a (shifted) gamma distribution, parameterized by either (mean, std, loc) 

24or (alpha, beta, loc), and discretizes it internally into ``n_bins`` equal-probability-mass 

25streamtubes. The plain pair takes ``aquifer_pore_volumes``: an explicit array of pore volumes [m³], 

26one per streamtube, treated as equally weighted at the outlet. 

27 

28Available functions: 

29 

30- :func:`infiltration_to_extraction` - Compute the concentration (or temperature) of the extracted 

31 water from an infiltration series and an explicit array of aquifer pore volumes. Each 

32 ``cout_tedges`` bin gets the flow-weighted average of ``cin`` over each streamtube's 

33 back-projected source window, averaged over the streamtubes; units are those of ``cin``. Sorption 

34 is linear, through a constant ``retardation_factor``. The ``spinup`` policy (default 

35 ``"constant"``) warm-starts the record before ``tedges[0]``; ``spinup=None`` instead returns NaN 

36 for every cout bin whose streamtube source windows are not fully inside the cin range. 

37 

38- :func:`extraction_to_infiltration` - Reverse operation: reconstruct the infiltrating 

39 concentration from measured extraction concentrations by inverting the forward weight matrix 

40 with Tikhonov regularization (``regularization_strength``). Returns one value per ``tedges`` 

41 bin. NaN in ``cout`` marks measurement gaps; those rows are excluded from the solve, and cin 

42 bins constrained only by gapped cout bins come back as NaN. 

43 

44- :func:`gamma_infiltration_to_extraction` - Forward transport as in 

45 :func:`infiltration_to_extraction`, with the APVD supplied as gamma parameters rather than as an 

46 explicit array of pore volumes. 

47 

48- :func:`gamma_extraction_to_infiltration` - Deconvolution as in 

49 :func:`extraction_to_infiltration`, with the APVD supplied as gamma parameters rather than as an 

50 explicit array of pore volumes. 

51 

52- :func:`infiltration_to_extraction_nonlinear_sorption` - Forward transport with a 

53 concentration-dependent isotherm -- Freundlich, Langmuir, or a constant retardation factor -- 

54 solved by front tracking along each pore volume. Returns a tuple: the flow-weighted bin-averaged 

55 extraction concentration, and one diagnostic structure per pore volume holding the waves, events, 

56 first arrival and final solver state. Cout bins whose source window leaves the flow record are 

57 returned as ``0.0`` rather than NaN, while bins with zero throughflow are NaN. Forward only: 

58 non-linear sorption forms shocks, and there is no extraction-to-infiltration counterpart. 

59 

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

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

62""" 

63 

64from collections import Counter 

65 

66import numpy as np 

67import numpy.typing as npt 

68import pandas as pd 

69 

70from gwtransport import gamma 

71from gwtransport._time import tedges_to_days 

72from gwtransport._validation import ( 

73 _validate_no_nan, 

74 _validate_non_negative_array, 

75 _validate_positive_array, 

76 _validate_retardation_factor, 

77 _validate_tedges_parity, 

78) 

79from gwtransport.advection_utils import ( 

80 _infiltration_to_extraction_weights, 

81 _resolve_spinup_inputs, 

82 _resolve_spinup_mask, 

83) 

84from gwtransport.fronttracking.math import ( 

85 EPSILON_FREUNDLICH_N, 

86 ConstantRetardation, 

87 FreundlichSorption, 

88 LangmuirSorption, 

89 SorptionModel, 

90) 

91from gwtransport.fronttracking.output import compute_bin_averaged_concentration_exact 

92from gwtransport.fronttracking.solver import FrontTracker, find_unresolved_interaction 

93from gwtransport.fronttracking.waves import CharacteristicWave, RarefactionWave, ShockWave 

94from gwtransport.utils import solve_inverse_transport_banded 

95 

96 

97def _validate_advection_inputs( 

98 *, 

99 tedges: pd.DatetimeIndex, 

100 flow: np.ndarray, 

101 retardation_factor: float, 

102 aquifer_pore_volumes: npt.ArrayLike, 

103 cin_values: np.ndarray | None = None, 

104 cout_values: np.ndarray | None = None, 

105 cout_tedges: pd.DatetimeIndex | None = None, 

106) -> None: 

107 """Validate inputs common to advection forward / reverse entry points. 

108 

109 Path selection via mutually-exclusive kwargs: 

110 

111 - ``cin_values`` provided => forward path. ``tedges`` parities cin and flow. 

112 - ``cout_values`` + ``cout_tedges`` provided => reverse path. ``tedges`` parities 

113 flow; ``cout_tedges`` parities cout. 

114 

115 All shared checks fire on both paths. ``flow >= 0`` is enforced in both 

116 directions, and ``aquifer_pore_volumes`` must be non-empty, finite and 

117 strictly positive -- a negative or zero volume would source a cout bin from 

118 future infiltration, and an empty distribution carries no water at all. 

119 

120 Raises 

121 ------ 

122 ValueError 

123 If any check fails. The message identifies which invariant was violated. 

124 """ 

125 if cin_values is not None: 

126 _validate_tedges_parity(tedges, cin_values, tedges_name="tedges", values_name="cin") 

127 _validate_tedges_parity(tedges, flow, tedges_name="tedges", values_name="flow") 

128 _validate_no_nan(cin_values, name="cin") 

129 elif cout_values is not None and cout_tedges is not None: 

130 _validate_tedges_parity(tedges, flow, tedges_name="tedges", values_name="flow") 

131 # Reverse cout may contain NaN (measurement gaps); gapped rows are excluded from the solve. 

132 _validate_tedges_parity(cout_tedges, cout_values, tedges_name="cout_tedges", values_name="cout") 

133 else: 

134 msg = "must provide cin_values (forward) or both cout_values and cout_tedges (reverse)" 

135 raise ValueError(msg) 

136 # Non-monotonic tedges would silently corrupt the cumulative-volume mapping. 

137 if np.any(np.diff(tedges.asi8) <= 0): 

138 msg = "tedges must be strictly increasing" 

139 raise ValueError(msg) 

140 _validate_no_nan(flow, name="flow") 

141 _validate_non_negative_array(flow, name="flow", message="flow must be non-negative (negative flow not supported)") 

142 _validate_retardation_factor(retardation_factor) 

143 apv = np.asarray(aquifer_pore_volumes, dtype=float) 

144 if apv.size == 0: 

145 msg = "aquifer_pore_volumes must not be empty" 

146 raise ValueError(msg) 

147 # A negative or zero pore volume back-projects a cout bin to *future* 

148 # infiltration (anti-causal); a non-finite one poisons the whole solve. 

149 _validate_positive_array(apv, name="aquifer_pore_volumes") 

150 

151 

152def gamma_infiltration_to_extraction( 

153 *, 

154 cin: npt.ArrayLike, 

155 flow: npt.ArrayLike, 

156 tedges: pd.DatetimeIndex, 

157 cout_tedges: pd.DatetimeIndex, 

158 mean: float | None = None, 

159 std: float | None = None, 

160 loc: float = 0.0, 

161 alpha: float | None = None, 

162 beta: float | None = None, 

163 n_bins: int = 100, 

164 retardation_factor: float = 1.0, 

165 spinup: str | None = "constant", 

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

167 """ 

168 Compute the concentration of the extracted water by shifting cin with its residence time. 

169 

170 The compound is retarded in the aquifer with a retardation factor. The residence 

171 time is computed based on the flow rate of the water in the aquifer and the pore volume 

172 of the aquifer. The aquifer pore volume is approximated by a (shifted) gamma distribution 

173 parameterized by either (mean, std, loc) or (alpha, beta, loc). 

174 

175 This function represents infiltration to extraction modeling by flow-weighted averaging. 

176 

177 Provide either (mean, std) or (alpha, beta); ``loc`` is optional and defaults to 0. 

178 

179 Parameters 

180 ---------- 

181 cin : array-like 

182 Concentration of the compound in infiltrating water or temperature of infiltrating 

183 water. The model assumes this value is constant over each interval 

184 ``[tedges[i], tedges[i+1])``. 

185 flow : array-like 

186 Flow rate of water in the aquifer [m³/day]. The model assumes this value is 

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

188 tedges : pandas.DatetimeIndex 

189 Time edges for both cin and flow data. Used to compute the cumulative concentration. 

190 Has a length of one more than `cin` and `flow`. 

191 cout_tedges : pandas.DatetimeIndex 

192 Time edges for the output data. Used to compute the cumulative concentration. 

193 Has a length of one more than the desired output length. 

194 mean : float, optional 

195 Mean of the gamma distribution of the aquifer pore volume. Must be strictly 

196 greater than ``loc``. 

197 std : float, optional 

198 Standard deviation of the gamma distribution of the aquifer pore volume 

199 (invariant under the ``loc`` shift). 

200 loc : float, optional 

201 Location (minimum pore volume) of the gamma distribution. Must satisfy 

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

203 alpha : float, optional 

204 Shape parameter of gamma distribution of the aquifer pore volume (must be > 0). 

205 beta : float, optional 

206 Scale parameter of gamma distribution of the aquifer pore volume (must be > 0). 

207 n_bins : int, optional 

208 Number of bins to discretize the gamma distribution. Default 100. 

209 retardation_factor : float, optional 

210 Retardation factor of the compound in the aquifer (default 1.0). 

211 Values > 1.0 indicate slower transport due to sorption/interaction. 

212 spinup : {"constant"} | None, optional 

213 Forwarded to :func:`infiltration_to_extraction`. Default 

214 ``"constant"`` warm-starts the system before ``tedges[0]``. 

215 

216 Returns 

217 ------- 

218 numpy.ndarray 

219 Concentration of the compound in the extracted water, or temperature. Same units as cin. 

220 

221 See Also 

222 -------- 

223 infiltration_to_extraction : Transport with explicit pore volume distribution 

224 gamma_extraction_to_infiltration : Reverse operation (deconvolution) 

225 gwtransport.gamma.bins : Create gamma distribution bins 

226 gwtransport.residence_time.full : Compute residence times 

227 gwtransport.diffusion.infiltration_to_extraction : Add microdispersion and molecular diffusion 

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

229 :ref:`assumption-gamma-distribution` : When gamma distribution is adequate 

230 

231 Notes 

232 ----- 

233 The APVD is only time-invariant under the steady-streamlines assumption 

234 (see :ref:`assumption-steady-streamlines`). 

235 

236 The spreading from the gamma-distributed pore volumes represents macrodispersion 

237 (aquifer-scale heterogeneity). When ``std`` comes from calibration on measurements, 

238 it absorbs all mixing: macrodispersion, microdispersion, and an average molecular 

239 diffusion contribution. When calibrating with the diffusion module, these three 

240 components are taken into account separately. When ``std`` comes from streamline 

241 analysis, it represents macrodispersion only; microdispersion and molecular diffusion 

242 can be added via :mod:`gwtransport.diffusion_fast` or :mod:`gwtransport.diffusion`. 

243 

244 For cross-compound prediction (calibrating on temperature and predicting a solute), 

245 calibrate with :mod:`gwtransport.diffusion_fast` so the three contributions are 

246 tracked separately rather than lumped into a single calibrated ``std``. 

247 See :ref:`concept-dispersion-scales` for background. 

248 

249 Examples 

250 -------- 

251 Basic usage with alpha and beta parameters: 

252 

253 >>> import pandas as pd 

254 >>> import numpy as np 

255 >>> from gwtransport.utils import compute_time_edges 

256 >>> from gwtransport.advection import gamma_infiltration_to_extraction 

257 >>> 

258 >>> # Create input data with aligned time edges 

259 >>> dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") 

260 >>> tedges = compute_time_edges( 

261 ... tedges=None, tstart=None, tend=dates, number_of_bins=len(dates) 

262 ... ) 

263 >>> 

264 >>> # Create output time edges (can be different alignment) 

265 >>> cout_dates = pd.date_range(start="2020-01-05", end="2020-01-15", freq="D") 

266 >>> cout_tedges = compute_time_edges( 

267 ... tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates) 

268 ... ) 

269 >>> 

270 >>> # Input concentration and flow (same length, aligned with tedges) 

271 >>> cin = pd.Series(np.ones(len(dates)), index=dates) 

272 >>> flow = pd.Series(np.ones(len(dates)) * 100, index=dates) # 100 m³/day 

273 >>> 

274 >>> # Run gamma_infiltration_to_extraction with alpha/beta parameters 

275 >>> cout = gamma_infiltration_to_extraction( 

276 ... cin=cin, 

277 ... flow=flow, 

278 ... tedges=tedges, 

279 ... cout_tedges=cout_tedges, 

280 ... alpha=10.0, 

281 ... beta=10.0, 

282 ... n_bins=5, 

283 ... ) 

284 >>> cout.shape 

285 (11,) 

286 

287 Using mean and std parameters instead: 

288 

289 >>> cout = gamma_infiltration_to_extraction( 

290 ... cin=cin, 

291 ... flow=flow, 

292 ... tedges=tedges, 

293 ... cout_tedges=cout_tedges, 

294 ... mean=100.0, 

295 ... std=20.0, 

296 ... n_bins=5, 

297 ... ) 

298 """ 

299 bins = gamma.bins(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta, n_bins=n_bins) 

300 return infiltration_to_extraction( 

301 cin=cin, 

302 flow=flow, 

303 tedges=tedges, 

304 cout_tedges=cout_tedges, 

305 aquifer_pore_volumes=bins["expected_values"], 

306 retardation_factor=retardation_factor, 

307 spinup=spinup, 

308 ) 

309 

310 

311def gamma_extraction_to_infiltration( 

312 *, 

313 cout: npt.ArrayLike, 

314 flow: npt.ArrayLike, 

315 tedges: pd.DatetimeIndex, 

316 cout_tedges: pd.DatetimeIndex, 

317 mean: float | None = None, 

318 std: float | None = None, 

319 loc: float = 0.0, 

320 alpha: float | None = None, 

321 beta: float | None = None, 

322 n_bins: int = 100, 

323 retardation_factor: float = 1.0, 

324 regularization_strength: float = 1e-10, 

325 spinup: str | None = "constant", 

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

327 """ 

328 Compute the concentration of the infiltrating water from extracted water (deconvolution). 

329 

330 The compound is retarded in the aquifer with a retardation factor. The residence 

331 time is computed based on the flow rate of the water in the aquifer and the pore volume 

332 of the aquifer. The aquifer pore volume is approximated by a (shifted) gamma distribution 

333 parameterized by either (mean, std, loc) or (alpha, beta, loc). 

334 

335 This function inverts the forward flow-weighted averaging (deconvolution). 

336 It is symmetric to gamma_infiltration_to_extraction. 

337 

338 Provide either (mean, std) or (alpha, beta); ``loc`` is optional and defaults to 0. 

339 

340 Parameters 

341 ---------- 

342 cout : array-like 

343 Concentration of the compound in extracted water or temperature of extracted 

344 water. The model assumes this value is constant over each interval 

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

346 flow : array-like 

347 Flow rate of water in the aquifer [m³/day]. The model assumes this value is 

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

349 tedges : pandas.DatetimeIndex 

350 Time edges for cin (output) and flow data. 

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

352 cout_tedges : pandas.DatetimeIndex 

353 Time edges for the cout data. 

354 Has a length of one more than `cout`. 

355 mean : float, optional 

356 Mean of the gamma distribution of the aquifer pore volume. Must be strictly 

357 greater than ``loc``. 

358 std : float, optional 

359 Standard deviation of the gamma distribution of the aquifer pore volume 

360 (invariant under the ``loc`` shift). 

361 loc : float, optional 

362 Location (minimum pore volume) of the gamma distribution. Must satisfy 

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

364 alpha : float, optional 

365 Shape parameter of gamma distribution of the aquifer pore volume (must be > 0). 

366 beta : float, optional 

367 Scale parameter of gamma distribution of the aquifer pore volume (must be > 0). 

368 n_bins : int, optional 

369 Number of bins to discretize the gamma distribution. Default 100. 

370 retardation_factor : float, optional 

371 Retardation factor of the compound in the aquifer (default 1.0). 

372 Values > 1.0 indicate slower transport due to sorption/interaction. 

373 regularization_strength : float, optional 

374 Tikhonov regularization parameter λ. See 

375 :func:`extraction_to_infiltration` for details. Default is 1e-10. 

376 spinup : {"constant"} | None, optional 

377 Forwarded to :func:`extraction_to_infiltration`. Default 

378 ``"constant"`` warm-starts the system before ``tedges[0]``. 

379 

380 Returns 

381 ------- 

382 numpy.ndarray 

383 Concentration of the compound in the infiltrating water, or temperature. Same units as cout. 

384 

385 See Also 

386 -------- 

387 extraction_to_infiltration : Deconvolution with explicit pore volume distribution 

388 gamma_infiltration_to_extraction : Forward operation (flow-weighted averaging) 

389 gwtransport.gamma.bins : Create gamma distribution bins 

390 gwtransport.diffusion.extraction_to_infiltration : Deconvolution with microdispersion and molecular diffusion 

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

392 :ref:`assumption-gamma-distribution` : When gamma distribution is adequate 

393 

394 Notes 

395 ----- 

396 The APVD is only time-invariant under the steady-streamlines assumption 

397 (see :ref:`assumption-steady-streamlines`). 

398 

399 The spreading from the gamma-distributed pore volumes represents macrodispersion 

400 (aquifer-scale heterogeneity). When ``std`` comes from calibration on measurements, 

401 it absorbs all mixing: macrodispersion, microdispersion, and an average molecular 

402 diffusion contribution. When calibrating with the diffusion module, these three 

403 components are taken into account separately. When ``std`` comes from streamline 

404 analysis, it represents macrodispersion only; microdispersion and molecular diffusion 

405 can be added via :mod:`gwtransport.diffusion_fast` or :mod:`gwtransport.diffusion`. 

406 

407 For cross-compound prediction (calibrating on temperature and predicting a solute), 

408 calibrate with :mod:`gwtransport.diffusion_fast` so the three contributions are 

409 tracked separately rather than lumped into a single calibrated ``std``. 

410 See :ref:`concept-dispersion-scales` for background. 

411 

412 Examples 

413 -------- 

414 Basic usage with alpha and beta parameters: 

415 

416 >>> import pandas as pd 

417 >>> import numpy as np 

418 >>> from gwtransport.utils import compute_time_edges 

419 >>> from gwtransport.advection import gamma_extraction_to_infiltration 

420 >>> 

421 >>> # Create cin/flow time edges 

422 >>> cin_dates = pd.date_range(start="2019-12-25", end="2020-01-15", freq="D") 

423 >>> tedges = compute_time_edges( 

424 ... tedges=None, tstart=None, tend=cin_dates, number_of_bins=len(cin_dates) 

425 ... ) 

426 >>> 

427 >>> # Create cout time edges 

428 >>> cout_dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") 

429 >>> cout_tedges = compute_time_edges( 

430 ... tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates) 

431 ... ) 

432 >>> 

433 >>> # Input concentration and flow 

434 >>> cout = np.ones(len(cout_dates)) 

435 >>> flow = np.ones(len(cin_dates)) * 100 # 100 m³/day 

436 >>> 

437 >>> # Run gamma_extraction_to_infiltration with alpha/beta parameters 

438 >>> cin = gamma_extraction_to_infiltration( 

439 ... cout=cout, 

440 ... flow=flow, 

441 ... tedges=tedges, 

442 ... cout_tedges=cout_tedges, 

443 ... alpha=10.0, 

444 ... beta=10.0, 

445 ... n_bins=5, 

446 ... ) 

447 >>> cin.shape 

448 (22,) 

449 

450 Using mean and std parameters instead: 

451 

452 >>> cin = gamma_extraction_to_infiltration( 

453 ... cout=cout, 

454 ... flow=flow, 

455 ... tedges=tedges, 

456 ... cout_tedges=cout_tedges, 

457 ... mean=100.0, 

458 ... std=20.0, 

459 ... n_bins=5, 

460 ... ) 

461 """ 

462 bins = gamma.bins(mean=mean, std=std, loc=loc, alpha=alpha, beta=beta, n_bins=n_bins) 

463 return extraction_to_infiltration( 

464 cout=cout, 

465 flow=flow, 

466 tedges=tedges, 

467 cout_tedges=cout_tedges, 

468 aquifer_pore_volumes=bins["expected_values"], 

469 retardation_factor=retardation_factor, 

470 regularization_strength=regularization_strength, 

471 spinup=spinup, 

472 ) 

473 

474 

475def infiltration_to_extraction( 

476 *, 

477 cin: npt.ArrayLike, 

478 flow: npt.ArrayLike, 

479 tedges: pd.DatetimeIndex, 

480 cout_tedges: pd.DatetimeIndex, 

481 aquifer_pore_volumes: npt.ArrayLike, 

482 retardation_factor: float = 1.0, 

483 spinup: str | None = "constant", 

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

485 """ 

486 Compute the concentration of the extracted water using flow-weighted advection. 

487 

488 This function implements an infiltration to extraction advection model where cin and flow values 

489 correspond to the same aligned time bins defined by tedges. 

490 

491 Pure advection is volume-stationary, so the weights are built on the 

492 cumulative-throughflow-volume axis rather than by inverting residence times: 

493 

494 1. Map the cin and cout time edges to cumulative throughflow volume. 

495 2. Back-project each cout bin by every retarded pore volume to its 

496 infiltration-time source window. The window spans one cout bin's worth of 

497 volume, so it overlaps only a narrow band of cin bins. 

498 3. Compute the flow-weighted time overlap of each window with those cin bins, 

499 normalize per streamtube (each row sums to 1), and average over the 

500 streamtubes whose source window lies fully inside the cin range. 

501 

502 

503 Parameters 

504 ---------- 

505 cin : array-like 

506 Concentration values of infiltrating water or temperature [concentration units]. 

507 Length must match the number of time bins defined by tedges. The model assumes 

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

509 flow : array-like 

510 Flow rate values in the aquifer [m³/day]. 

511 Length must match cin and the number of time bins defined by tedges. The model 

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

513 tedges : pandas.DatetimeIndex 

514 Time edges defining bins for both cin and flow data. Has length of 

515 len(cin) + 1 and len(flow) + 1. 

516 cout_tedges : pandas.DatetimeIndex 

517 Time edges for output data bins. Has length of desired output + 1. 

518 Can have different time alignment and resolution than tedges. 

519 aquifer_pore_volumes : array-like 

520 Array of aquifer pore volumes [m³] representing the distribution 

521 of residence times in the aquifer system. 

522 retardation_factor : float, optional 

523 Retardation factor of the compound in the aquifer (default 1.0). 

524 Values > 1.0 indicate slower transport due to sorption/interaction. 

525 spinup : {"constant"} | None, optional 

526 How to treat cout bins where one or more streamtube source windows 

527 fall outside the cin time range. Default is ``"constant"``. 

528 

529 - ``"constant"`` — warm-start: shift ``tedges[0]`` backward by 

530 ``retardation_factor * max(aquifer_pore_volumes) / flow[0]`` and 

531 treat cin and flow as constant at their first value over the 

532 extended window. The forward strict-validity logic then has no 

533 NaN cout bins from spin-up; right-edge spin-up (cout extending 

534 past the cin range) is unchanged. 

535 - ``None`` — strict mass-conservation: NaN whenever any streamtube 

536 has not fully broken through into the cin range, or extraction 

537 flow during the bin is zero. Bundle row sums to 1 across cin. 

538 

539 Returns 

540 ------- 

541 numpy.ndarray 

542 Flow-weighted concentration in the extracted water. Same units 

543 as cin. Length equals ``len(cout_tedges) - 1``. NaN values mark 

544 cout bins where the chosen ``spinup`` policy is not satisfied: 

545 the default ``"constant"`` leaves NaN for any cout bin extending 

546 past the end of the flow record (a cout edge beyond 

547 ``tedges[-1]``, whose back-projected source window leaves the cin 

548 range) and for zero-throughflow bins; ``spinup=None`` additionally 

549 NaNs left-edge spin-up bins. 

550 

551 Raises 

552 ------ 

553 ValueError 

554 If tedges length doesn't match cin/flow arrays plus one, or if 

555 infiltration time edges become non-monotonic (invalid input conditions). 

556 

557 See Also 

558 -------- 

559 gamma_infiltration_to_extraction : Transport with gamma-distributed pore volumes 

560 extraction_to_infiltration : Reverse operation (deconvolution) 

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

562 gwtransport.residence_time.freundlich_retardation : Compute concentration-dependent retardation 

563 :ref:`concept-pore-volume-distribution` : Background on aquifer heterogeneity modeling 

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

565 

566 Examples 

567 -------- 

568 Basic usage with pandas Series: 

569 

570 >>> import pandas as pd 

571 >>> import numpy as np 

572 >>> from gwtransport.utils import compute_time_edges 

573 >>> from gwtransport.advection import infiltration_to_extraction 

574 >>> 

575 >>> # Create input data 

576 >>> dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") 

577 >>> tedges = compute_time_edges( 

578 ... tedges=None, tstart=None, tend=dates, number_of_bins=len(dates) 

579 ... ) 

580 >>> 

581 >>> # Create output time edges (different alignment) 

582 >>> cout_dates = pd.date_range(start="2020-01-05", end="2020-01-15", freq="D") 

583 >>> cout_tedges = compute_time_edges( 

584 ... tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates) 

585 ... ) 

586 >>> 

587 >>> # Input concentration and flow 

588 >>> cin = pd.Series(np.ones(len(dates)), index=dates) 

589 >>> flow = pd.Series(np.ones(len(dates)) * 100, index=dates) # 100 m³/day 

590 >>> 

591 >>> # Define distribution of aquifer pore volumes 

592 >>> aquifer_pore_volumes = np.array([50, 100, 200]) # m³ 

593 >>> 

594 >>> # Run infiltration_to_extraction 

595 >>> cout = infiltration_to_extraction( 

596 ... cin=cin, 

597 ... flow=flow, 

598 ... tedges=tedges, 

599 ... cout_tedges=cout_tedges, 

600 ... aquifer_pore_volumes=aquifer_pore_volumes, 

601 ... ) 

602 >>> cout.shape 

603 (11,) 

604 

605 With constant retardation factor (linear sorption): 

606 

607 >>> cout = infiltration_to_extraction( 

608 ... cin=cin, 

609 ... flow=flow, 

610 ... tedges=tedges, 

611 ... cout_tedges=cout_tedges, 

612 ... aquifer_pore_volumes=aquifer_pore_volumes, 

613 ... retardation_factor=2.0, # Compound moves twice as slowly 

614 ... ) 

615 

616 Note: For concentration-dependent retardation (nonlinear sorption), 

617 use `infiltration_to_extraction_nonlinear_sorption` instead, as this 

618 function only supports constant (float) retardation factors. 

619 """ 

620 tedges = pd.DatetimeIndex(tedges) 

621 cout_tedges = pd.DatetimeIndex(cout_tedges) 

622 

623 # Convert to arrays for vectorized operations 

624 cin = np.asarray(cin) 

625 flow = np.asarray(flow) 

626 aquifer_pore_volumes = np.asarray(aquifer_pore_volumes) 

627 

628 _validate_advection_inputs( 

629 tedges=tedges, 

630 flow=flow, 

631 retardation_factor=retardation_factor, 

632 aquifer_pore_volumes=aquifer_pore_volumes, 

633 cin_values=cin, 

634 ) 

635 

636 weight_tedges, weight_flow, weight_cin, _, _ = _resolve_spinup_inputs( 

637 spinup, 

638 tedges=tedges, 

639 flow=flow, 

640 aquifer_pore_volumes=aquifer_pore_volumes, 

641 retardation_factor=retardation_factor, 

642 cin=cin, 

643 ) 

644 assert weight_cin is not None # noqa: S101 -- narrowed: cin was passed in 

645 band_vals, col_start, contributing_bins, zero_flow_cout = _infiltration_to_extraction_weights( 

646 tedges=weight_tedges, 

647 cout_tedges=cout_tedges, 

648 aquifer_pore_volumes=aquifer_pore_volumes, 

649 flow=weight_flow, 

650 retardation_factor=retardation_factor, 

651 ) 

652 weights, invalid_mask = _resolve_spinup_mask( 

653 band_vals=band_vals, 

654 contributing_bins=contributing_bins, 

655 zero_flow_cout=zero_flow_cout, 

656 n_pv=len(aquifer_pore_volumes), 

657 ) 

658 

659 # Banded flow-weighted average: row k contributes cin over its narrow band only. 

660 # Out-of-range band slots carry zero weight, so the clipped gather is harmless. 

661 n_cin = len(weight_cin) 

662 cols = np.clip(col_start[:, None] + np.arange(weights.shape[1]), 0, n_cin - 1) 

663 out = np.einsum("kb,kb->k", weights, weight_cin[cols]) 

664 

665 # Invalid rows (cout bins where the spin-up policy is not satisfied or 

666 # where extraction flow was zero) become NaN. 

667 out[invalid_mask] = np.nan 

668 

669 return out 

670 

671 

672def extraction_to_infiltration( 

673 *, 

674 cout: npt.ArrayLike, 

675 flow: npt.ArrayLike, 

676 tedges: pd.DatetimeIndex, 

677 cout_tedges: pd.DatetimeIndex, 

678 aquifer_pore_volumes: npt.ArrayLike, 

679 retardation_factor: float = 1.0, 

680 regularization_strength: float = 1e-10, 

681 spinup: str | float | None = "constant", 

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

683 """ 

684 Compute the concentration of the infiltrating water from extracted water (deconvolution). 

685 

686 Inverts the forward transport model by solving the linear system 

687 ``W_forward @ cin = cout`` where ``W_forward`` is the weight matrix from 

688 :func:`infiltration_to_extraction`. Uses Tikhonov regularization to 

689 smoothly blend data fitting with a physically motivated target 

690 (transpose-and-normalize of the forward matrix). 

691 

692 Well-determined modes (large singular values relative to √λ) are 

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

694 target. This avoids edge oscillations and is less sensitive to the 

695 regularization parameter than truncated SVD (``rcond``). 

696 

697 Parameters 

698 ---------- 

699 cout : array-like 

700 Concentration values of extracted water [concentration units]. 

701 Length must match the number of time bins defined by cout_tedges. The model 

702 assumes this value is constant over each interval 

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

704 flow : array-like 

705 Flow rate values in the aquifer [m³/day]. 

706 Length must match the number of time bins defined by tedges. The model assumes 

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

708 tedges : pandas.DatetimeIndex 

709 Time edges defining bins for both cin (output) and flow data. Has length of 

710 len(flow) + 1. Output cin has length len(tedges) - 1. 

711 cout_tedges : pandas.DatetimeIndex 

712 Time edges for cout data bins. Has length of len(cout) + 1. 

713 Can have different time alignment and resolution than tedges. 

714 aquifer_pore_volumes : array-like 

715 Array of aquifer pore volumes [m³] representing the distribution 

716 of residence times in the aquifer system. 

717 retardation_factor : float, optional 

718 Retardation factor of the compound in the aquifer (default 1.0). 

719 Values > 1.0 indicate slower transport due to sorption/interaction. 

720 regularization_strength : float, optional 

721 Tikhonov regularization parameter λ. Controls the tradeoff between 

722 fitting the data (``||W cin - cout||²``) and staying close to the 

723 regularization target (``λ ||cin - cin_target||²``). The target is 

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

725 

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

727 values trust the data more (noisier, less biased). The solution 

728 varies continuously with λ. Default is 1e-10. 

729 

730 A good starting value for noisy data is 

731 ``λ ≈ (noise_std / signal_amplitude)²``. For example, temperature 

732 data with 0.05 °C noise and ~10 °C seasonal amplitude suggests 

733 ``regularization_strength ≈ (0.05 / 10)² ≈ 2.5e-5``. Increase by 

734 a factor of 2-10 for additional smoothing. For noiseless synthetic 

735 data (e.g., roundtrip tests), the default 1e-10 preserves machine 

736 precision. 

737 spinup : {"constant"} | None, optional 

738 Spin-up policy applied when building the forward weight matrix 

739 used to set up the inverse problem. Same semantics as in 

740 :func:`infiltration_to_extraction`; default ``"constant"`` shifts 

741 ``tedges[0]`` backward by ``retardation_factor * 

742 max(aquifer_pore_volumes) / flow[0]`` so the inverse problem has 

743 no spin-up zero-rows for cout bins inside the original tedges 

744 range. The warm-start prefix is solved for internally but dropped 

745 before returning, so the output cin stays aligned with the 

746 user-provided ``tedges`` (length ``len(tedges) - 1``), not the 

747 padded grid. Passing ``None`` keeps the strict-validity behavior 

748 (zero-rows in W from incomplete breakthrough). 

749 

750 Returns 

751 ------- 

752 numpy.ndarray 

753 Concentration in the infiltrating water. Same units as cout. 

754 Length equals len(tedges) - 1 (unchanged whether or not 

755 ``spinup="constant"`` shifted ``tedges[0]``). NaN values indicate 

756 cin bins with no temporal overlap with the extraction data. The 

757 forward weight matrix used to set up the inverse problem treats 

758 spin-up and zero-flow cout bins as zero-rows according to the 

759 ``spinup`` policy. 

760 

761 Raises 

762 ------ 

763 ValueError 

764 If tedges length doesn't match flow plus one, if cout_tedges length 

765 doesn't match cout plus one, or if flow contains NaN. 

766 

767 See Also 

768 -------- 

769 gamma_extraction_to_infiltration : Deconvolution with gamma-distributed pore volumes 

770 infiltration_to_extraction : Forward operation (flow-weighted averaging) 

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

772 gwtransport.utils.solve_tikhonov : Solver used for inversion 

773 :ref:`concept-pore-volume-distribution` : Background on aquifer heterogeneity modeling 

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

775 

776 Notes 

777 ----- 

778 NaN values in ``cout`` mark measurement gaps (e.g. sparse lab samples). 

779 Their rows are excluded from the banded Tikhonov solve, matching 

780 :func:`gwtransport.deposition.extraction_to_deposition`; cin bins 

781 constrained only by gapped ``cout`` bins are returned as NaN. 

782 

783 Examples 

784 -------- 

785 Basic usage with pandas Series: 

786 

787 >>> import pandas as pd 

788 >>> import numpy as np 

789 >>> from gwtransport.utils import compute_time_edges 

790 >>> from gwtransport.advection import extraction_to_infiltration 

791 >>> 

792 >>> # Create cin/flow time edges 

793 >>> cin_dates = pd.date_range(start="2019-12-25", end="2020-01-15", freq="D") 

794 >>> tedges = compute_time_edges( 

795 ... tedges=None, tstart=None, tend=cin_dates, number_of_bins=len(cin_dates) 

796 ... ) 

797 >>> 

798 >>> # Create cout time edges 

799 >>> cout_dates = pd.date_range(start="2020-01-01", end="2020-01-20", freq="D") 

800 >>> cout_tedges = compute_time_edges( 

801 ... tedges=None, tstart=None, tend=cout_dates, number_of_bins=len(cout_dates) 

802 ... ) 

803 >>> 

804 >>> # Input concentration and flow 

805 >>> cout = np.ones(len(cout_dates)) 

806 >>> flow = np.ones(len(cin_dates)) * 100 # 100 m³/day 

807 >>> 

808 >>> # Define distribution of aquifer pore volumes 

809 >>> aquifer_pore_volumes = np.array([50, 100, 200]) # m³ 

810 >>> 

811 >>> # Run extraction_to_infiltration 

812 >>> cin = extraction_to_infiltration( 

813 ... cout=cout, 

814 ... flow=flow, 

815 ... tedges=tedges, 

816 ... cout_tedges=cout_tedges, 

817 ... aquifer_pore_volumes=aquifer_pore_volumes, 

818 ... ) 

819 >>> cin.shape 

820 (22,) 

821 

822 Round-trip reconstruction (symmetric with infiltration_to_extraction). 

823 The default ``spinup="constant"`` warm-starts the left edge; the cout 

824 window must therefore stay inside the cin window with margin matching 

825 the longest residence time on the right (forward NaN at the right 

826 edge would otherwise be rejected by ``extraction_to_infiltration``): 

827 

828 >>> from gwtransport.advection import infiltration_to_extraction 

829 >>> rt_cout_dates = pd.date_range(start="2020-01-01", end="2020-01-10", freq="D") 

830 >>> rt_cout_tedges = compute_time_edges( 

831 ... tedges=None, 

832 ... tstart=None, 

833 ... tend=rt_cout_dates, 

834 ... number_of_bins=len(rt_cout_dates), 

835 ... ) 

836 >>> cin_original = np.sin(np.linspace(0, 2 * np.pi, len(cin_dates))) + 2 

837 >>> cout_rt = infiltration_to_extraction( 

838 ... cin=cin_original, 

839 ... flow=flow, 

840 ... tedges=tedges, 

841 ... cout_tedges=rt_cout_tedges, 

842 ... aquifer_pore_volumes=aquifer_pore_volumes, 

843 ... ) 

844 >>> cin_recovered = extraction_to_infiltration( 

845 ... cout=cout_rt, 

846 ... flow=flow, 

847 ... tedges=tedges, 

848 ... cout_tedges=rt_cout_tedges, 

849 ... aquifer_pore_volumes=aquifer_pore_volumes, 

850 ... ) 

851 """ 

852 tedges = pd.DatetimeIndex(tedges) 

853 cout_tedges = pd.DatetimeIndex(cout_tedges) 

854 

855 # Convert to arrays for vectorized operations 

856 cout = np.asarray(cout) 

857 flow = np.asarray(flow) 

858 

859 _validate_advection_inputs( 

860 tedges=tedges, 

861 flow=flow, 

862 retardation_factor=retardation_factor, 

863 aquifer_pore_volumes=aquifer_pore_volumes, 

864 cout_values=cout, 

865 cout_tedges=cout_tedges, 

866 ) 

867 

868 aquifer_pore_volumes = np.asarray(aquifer_pore_volumes) 

869 

870 weight_tedges, weight_flow, _, _, n_pad = _resolve_spinup_inputs( 

871 spinup, 

872 tedges=tedges, 

873 flow=flow, 

874 aquifer_pore_volumes=aquifer_pore_volumes, 

875 retardation_factor=retardation_factor, 

876 ) 

877 n_cin_padded = len(weight_tedges) - 1 

878 

879 band_vals, col_start, contributing_bins, zero_flow_cout = _infiltration_to_extraction_weights( 

880 tedges=weight_tedges, 

881 cout_tedges=cout_tedges, 

882 aquifer_pore_volumes=aquifer_pore_volumes, 

883 flow=weight_flow, 

884 retardation_factor=retardation_factor, 

885 ) 

886 band_vals, _ = _resolve_spinup_mask( 

887 band_vals=band_vals, 

888 contributing_bins=contributing_bins, 

889 zero_flow_cout=zero_flow_cout, 

890 n_pv=len(aquifer_pore_volumes), 

891 ) 

892 

893 cin_padded = solve_inverse_transport_banded( 

894 band_vals=band_vals, 

895 col_start=col_start, 

896 observed=cout, 

897 n_output=n_cin_padded, 

898 regularization_strength=regularization_strength, 

899 ) 

900 # Drop warm-start prefix so the output aligns with the user-provided tedges. 

901 return cin_padded[n_pad:] 

902 

903 

904def _validate_front_tracking_inputs( 

905 *, 

906 cin: npt.ArrayLike, 

907 flow: npt.ArrayLike, 

908 tedges: pd.DatetimeIndex, 

909 cout_tedges: pd.DatetimeIndex, 

910 aquifer_pore_volumes: npt.ArrayLike, 

911 freundlich_k: float | None, 

912 freundlich_n: float | None, 

913 bulk_density: float | None, 

914 porosity: float | None, 

915 retardation_factor: float | None, 

916 langmuir_s_max: float | None, 

917 langmuir_k_l: float | None, 

918) -> tuple[ 

919 npt.NDArray[np.float64], 

920 npt.NDArray[np.float64], 

921 pd.DatetimeIndex, 

922 pd.DatetimeIndex, 

923 npt.NDArray[np.float64], 

924 SorptionModel, 

925 npt.NDArray[np.floating], 

926]: 

927 """Validate inputs and create sorption object for front tracking functions. 

928 

929 Returns 

930 ------- 

931 tuple 

932 Validated and converted inputs: (cin, flow, tedges, cout_tedges, 

933 aquifer_pore_volumes, sorption, cout_tedges_days). 

934 

935 Raises 

936 ------ 

937 ValueError 

938 If array lengths are inconsistent, values are non-physical (negative 

939 concentrations, negative flows, NaN values, non-positive pore 

940 volumes), retardation_factor < 1, Freundlich or Langmuir parameters 

941 are missing or non-positive, freundlich_n equals 1, or physical 

942 parameters are invalid. 

943 """ 

944 cin = np.asarray(cin, dtype=float) 

945 flow = np.asarray(flow, dtype=float) 

946 tedges = pd.DatetimeIndex(tedges) 

947 cout_tedges = pd.DatetimeIndex(cout_tedges) 

948 aquifer_pore_volumes = np.asarray(aquifer_pore_volumes, dtype=float) 

949 

950 _validate_tedges_parity(tedges, cin, tedges_name="tedges", values_name="cin") 

951 _validate_tedges_parity(tedges, flow, tedges_name="tedges", values_name="flow") 

952 _validate_no_nan(cin, name="cin") 

953 _validate_no_nan(flow, name="flow") 

954 _validate_non_negative_array(cin, name="cin") 

955 _validate_non_negative_array(flow, name="flow", message="flow must be non-negative (negative flow not supported)") 

956 if aquifer_pore_volumes.size == 0: 

957 msg = "aquifer_pore_volumes must not be empty" 

958 raise ValueError(msg) 

959 _validate_positive_array(aquifer_pore_volumes, name="aquifer_pore_volumes") 

960 

961 # Convert cout_tedges to days (relative to tedges[0]) for output computation 

962 cout_tedges_days = tedges_to_days(cout_tedges, ref=tedges[0]) 

963 

964 # Determine which sorption model is requested 

965 has_retardation = retardation_factor is not None 

966 has_freundlich = freundlich_k is not None or freundlich_n is not None 

967 has_langmuir = langmuir_s_max is not None or langmuir_k_l is not None 

968 n_models = has_retardation + has_freundlich + has_langmuir 

969 

970 if n_models == 0: 

971 msg = ( 

972 "Must provide one of: retardation_factor, Freundlich parameters " 

973 "(freundlich_k, freundlich_n, bulk_density, porosity), or Langmuir parameters " 

974 "(langmuir_s_max, langmuir_k_l, bulk_density, porosity)" 

975 ) 

976 raise ValueError(msg) 

977 if n_models > 1: 

978 msg = "Only one sorption model can be specified (retardation_factor, Freundlich, or Langmuir)" 

979 raise ValueError(msg) 

980 

981 # Create sorption object 

982 if retardation_factor is not None: 

983 _validate_retardation_factor(retardation_factor) 

984 sorption: SorptionModel = ConstantRetardation(retardation_factor=retardation_factor) 

985 elif has_freundlich: 

986 if freundlich_k is None or freundlich_n is None or bulk_density is None or porosity is None: 

987 msg = "All Freundlich parameters required (freundlich_k, freundlich_n, bulk_density, porosity)" 

988 raise ValueError(msg) 

989 if freundlich_k <= 0 or freundlich_n <= 0: 

990 msg = "Freundlich parameters must be positive" 

991 raise ValueError(msg) 

992 if abs(freundlich_n - 1.0) < EPSILON_FREUNDLICH_N: 

993 msg = "freundlich_n = 1 not supported (use retardation_factor for linear case)" 

994 raise ValueError(msg) 

995 if bulk_density <= 0 or not 0 < porosity < 1: 

996 msg = "Invalid physical parameters" 

997 raise ValueError(msg) 

998 

999 sorption = FreundlichSorption( 

1000 k_f=freundlich_k, 

1001 n=freundlich_n, 

1002 bulk_density=bulk_density, 

1003 porosity=porosity, 

1004 ) 

1005 else: 

1006 if langmuir_s_max is None or langmuir_k_l is None or bulk_density is None or porosity is None: 

1007 msg = "All Langmuir parameters required (langmuir_s_max, langmuir_k_l, bulk_density, porosity)" 

1008 raise ValueError(msg) 

1009 if langmuir_s_max <= 0 or langmuir_k_l <= 0: 

1010 msg = "Langmuir parameters must be positive" 

1011 raise ValueError(msg) 

1012 if bulk_density <= 0 or not 0 < porosity < 1: 

1013 msg = "Invalid physical parameters" 

1014 raise ValueError(msg) 

1015 

1016 sorption = LangmuirSorption( 

1017 s_max=langmuir_s_max, 

1018 k_l=langmuir_k_l, 

1019 bulk_density=bulk_density, 

1020 porosity=porosity, 

1021 ) 

1022 

1023 return cin, flow, tedges, cout_tedges, aquifer_pore_volumes, sorption, cout_tedges_days 

1024 

1025 

1026def _flow_weighted_front_tracking_output( 

1027 cout_tedges_days: npt.NDArray[np.floating], 

1028 flow_tedges_days: npt.NDArray[np.floating], 

1029 flow: npt.NDArray[np.floating], 

1030 v_outlet: float, 

1031 waves: list, 

1032 sorption: SorptionModel, 

1033 theta_edges: npt.NDArray[np.floating], 

1034 cin: npt.NDArray[np.floating], 

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

1036 """Compute flow-weighted bin-averaged concentration from front-tracking output. 

1037 

1038 Splits output bins at flow boundaries so that Q is constant within each 

1039 sub-bin, then combines sub-bins with flow-weighting: 

1040 ``c_avg = Σ(Q_k · c_k · dt_k) / Σ(Q_k · dt_k)``. 

1041 

1042 Internally translates the output ``t``-bin edges to θ via the same 

1043 ``(flow_tedges_days, theta_edges)`` map the tracker built, and calls 

1044 :func:`compute_bin_averaged_concentration_exact` in θ-coordinates. 

1045 

1046 Parameters 

1047 ---------- 

1048 cout_tedges_days : ndarray 

1049 Output time bin edges [days from reference]. 

1050 flow_tedges_days : ndarray 

1051 Flow time bin edges [days from reference] (length ``len(flow) + 1``). 

1052 flow : ndarray 

1053 Flow rate per flow bin [m³/day]. 

1054 v_outlet : float 

1055 Outlet volume position [m³]. 

1056 waves : list 

1057 Wave list from front tracking simulation. 

1058 sorption : object 

1059 Sorption model. 

1060 theta_edges : ndarray 

1061 Cumulative-flow edges at the flow-bin boundaries [m³] 

1062 (length ``len(flow) + 1``). 

1063 cin : ndarray 

1064 Infiltration concentration values, one per flow bin. Passed directly to 

1065 :func:`compute_bin_averaged_concentration_exact`. 

1066 

1067 Returns 

1068 ------- 

1069 ndarray 

1070 Flow-weighted bin-averaged concentrations. Length = len(cout_tedges_days) - 1. 

1071 

1072 Notes 

1073 ----- 

1074 Zero-flow sub-bins are dropped from the averaging only; a cin step injected 

1075 during a zero-flow bin still enters the tracker as a wave at a degenerate θ 

1076 and can corrupt adjacent output bins. 

1077 """ 

1078 inner_flow_edges = flow_tedges_days[ 

1079 (flow_tedges_days > cout_tedges_days[0]) & (flow_tedges_days < cout_tedges_days[-1]) 

1080 ] 

1081 fine_edges = np.unique(np.concatenate([cout_tedges_days, inner_flow_edges])) 

1082 

1083 # np.interp clips on both sides; extrapolate the θ map past either flow edge at 

1084 # the adjacent-bin flow (matches the FrontTrackerState.theta_at_t rule). Without 

1085 # this, out-of-window fine_edges collapse to a duplicate θ. A θ edge ≤ 0 

1086 # downstream reads back as m = 0 → 0.0 (the documented out-of-range contract). 

1087 fine_theta_edges = np.interp(fine_edges, flow_tedges_days, theta_edges) 

1088 underflow = fine_edges < flow_tedges_days[0] 

1089 if underflow.any(): 

1090 fine_theta_edges[underflow] = theta_edges[0] - (flow_tedges_days[0] - fine_edges[underflow]) * float(flow[0]) 

1091 overflow = fine_edges > flow_tedges_days[-1] 

1092 if overflow.any(): 

1093 fine_theta_edges[overflow] = theta_edges[-1] + (fine_edges[overflow] - flow_tedges_days[-1]) * float(flow[-1]) 

1094 

1095 # A zero-flow input span leaves θ stationary, so its sub-bins have zero width in 

1096 # θ and zero q·dt weight. Drop them before the exact averaging (which rejects 

1097 # non-positive-width bins); they read back as 0 and carry no weight in this 

1098 # averaging (see Notes for the degenerate-θ caveat). Consecutive kept bins stay 

1099 # contiguous because the dropped bins share their neighbours' θ value. 

1100 theta_lo, theta_hi = fine_theta_edges[:-1], fine_theta_edges[1:] 

1101 nondegenerate = theta_hi > theta_lo 

1102 c_fine = np.zeros(theta_lo.shape) 

1103 if nondegenerate.any(): 

1104 kept_edges = np.concatenate([theta_lo[nondegenerate], theta_hi[nondegenerate][-1:]]) 

1105 c_fine[nondegenerate] = compute_bin_averaged_concentration_exact( 

1106 theta_bin_edges=kept_edges, 

1107 v_outlet=v_outlet, 

1108 waves=waves, 

1109 sorption=sorption, 

1110 cin=cin, 

1111 theta_edges_inlet=theta_edges, 

1112 ) 

1113 

1114 # Map each fine sub-bin to its flow value. side="right" enforces the 

1115 # half-open [t_k, t_{k+1}) bin convention if a midpoint ever lands 

1116 # exactly on an inner flow edge (does not happen for np.unique-derived 

1117 # midpoints in practice, but is defensible against floating-point drift). 

1118 fine_mids = (fine_edges[:-1] + fine_edges[1:]) / 2 

1119 flow_idx = np.searchsorted(flow_tedges_days[1:], fine_mids, side="right") 

1120 flow_idx = np.clip(flow_idx, 0, len(flow) - 1) 

1121 q_fine = flow[flow_idx] 

1122 dt_fine = np.diff(fine_edges) 

1123 

1124 # Map each fine sub-bin to its original output bin. Same side="right" 

1125 # rationale as above. 

1126 cout_bin_idx = np.searchsorted(cout_tedges_days[1:], fine_mids, side="right") 

1127 cout_bin_idx = np.clip(cout_bin_idx, 0, len(cout_tedges_days) - 2) 

1128 

1129 # Vectorized per-bin flow-weighted average: 

1130 # c_out[k] = sum_i (Q_i * c_i * dt_i) / sum_i (Q_i * dt_i) for fine sub-bins i in bin k 

1131 n_cout = len(cout_tedges_days) - 1 

1132 qdt_product = q_fine * dt_fine 

1133 cqdt_product = c_fine * qdt_product 

1134 denominator = np.bincount(cout_bin_idx, weights=qdt_product, minlength=n_cout) 

1135 numerator = np.bincount(cout_bin_idx, weights=cqdt_product, minlength=n_cout) 

1136 # A zero-throughflow output bin (all overlapping input bins have zero flow) has 

1137 # an undefined flow-weighted average: emit NaN, matching the linear sibling. 

1138 # Pre-record bins keep positive throughflow, so their 0-mass windows read as 0.0. 

1139 c_out = np.full(n_cout, np.nan) 

1140 valid = denominator > 0 

1141 c_out[valid] = numerator[valid] / denominator[valid] 

1142 return c_out 

1143 

1144 

1145def infiltration_to_extraction_nonlinear_sorption( 

1146 *, 

1147 cin: npt.ArrayLike, 

1148 flow: npt.ArrayLike, 

1149 tedges: pd.DatetimeIndex, 

1150 cout_tedges: pd.DatetimeIndex, 

1151 aquifer_pore_volumes: npt.ArrayLike, 

1152 freundlich_k: float | None = None, 

1153 freundlich_n: float | None = None, 

1154 bulk_density: float | None = None, 

1155 porosity: float | None = None, 

1156 retardation_factor: float | None = None, 

1157 langmuir_s_max: float | None = None, 

1158 langmuir_k_l: float | None = None, 

1159 max_iterations: int = 10000, 

1160) -> tuple[npt.NDArray[np.floating], list[dict]]: 

1161 """ 

1162 Compute extracted concentration with complete diagnostic information. 

1163 

1164 Returns both bin-averaged concentrations and detailed simulation structure for each pore volume. 

1165 

1166 Exactly one sorption model must be specified: 

1167 

1168 - ``retardation_factor`` for constant (linear) retardation. 

1169 - ``freundlich_k`` + ``freundlich_n`` + ``bulk_density`` + ``porosity`` for 

1170 Freundlich isotherm. 

1171 - ``langmuir_s_max`` + ``langmuir_k_l`` + ``bulk_density`` + ``porosity`` for 

1172 Langmuir isotherm. 

1173 

1174 Parameters 

1175 ---------- 

1176 cin : array-like 

1177 Infiltration concentration [mg/L or any units]. 

1178 Length = len(tedges) - 1. The model assumes this value is constant over each 

1179 interval ``[tedges[i], tedges[i+1])``. 

1180 flow : array-like 

1181 Flow rate [m³/day]. Must be non-negative. 

1182 Length = len(tedges) - 1. The model assumes this value is constant over each 

1183 interval ``[tedges[i], tedges[i+1])``. 

1184 tedges : pandas.DatetimeIndex 

1185 Time bin edges. Length = len(cin) + 1. 

1186 cout_tedges : pandas.DatetimeIndex 

1187 Output time bin edges. Can be different from tedges. 

1188 Length = number of output bins + 1 (n+1 edges for n output values). 

1189 aquifer_pore_volumes : array-like 

1190 Array of aquifer pore volumes [m³] representing the distribution 

1191 of residence times in the aquifer system. Each pore volume must be positive. 

1192 freundlich_k : float, optional 

1193 Freundlich coefficient [(m³/kg)^(1/n)]. Must be positive. Same convention (isotherm 

1194 ``s = k_f * C^(1/n)``) as :func:`gwtransport.residence_time.freundlich_retardation`. 

1195 freundlich_n : float, optional 

1196 Freundlich exponent [-]. Must be positive and != 1. 

1197 bulk_density : float, optional 

1198 Bulk density [kg/m³]. Must be positive. 

1199 Shared by Freundlich and Langmuir models. 

1200 porosity : float, optional 

1201 Porosity [-]. Must be in (0, 1). 

1202 Shared by Freundlich and Langmuir models. 

1203 retardation_factor : float, optional 

1204 Constant retardation factor [-]. Must be >= 1.0. 

1205 langmuir_s_max : float, optional 

1206 Langmuir maximum sorption capacity [mg/kg]. Must be positive. 

1207 langmuir_k_l : float, optional 

1208 Langmuir half-saturation constant [mg/L]. Must be positive. 

1209 max_iterations : int, optional 

1210 Maximum number of events. Default 10000. 

1211 

1212 Returns 

1213 ------- 

1214 cout : numpy.ndarray 

1215 Flow-weighted concentrations averaged across all pore volumes. Output 

1216 bins whose source window leaves the inlet flow record (e.g. cout bins 

1217 before first breakthrough, or extending past the flow record) are 

1218 returned as ``0.0``, not NaN; the front-tracking solver clamps such 

1219 out-of-range windows to the last known state rather than masking them. 

1220 An output bin with zero throughflow (every overlapping input bin has 

1221 zero flow) has an undefined flow-weighted average and is returned as 

1222 NaN, matching :func:`infiltration_to_extraction`. 

1223 

1224 structures : list of dict 

1225 List of detailed simulation structures, one for each pore volume, with keys: 

1226 

1227 - 'waves': List[Wave] - All wave objects created during simulation 

1228 - 'events': List[dict] - All events; each record carries ``"theta"`` (m³) 

1229 and ``"type"``. Translate to user-facing time t via 

1230 ``tracker_state.t_at_theta(event["theta"])`` if needed. 

1231 - 'theta_first_arrival': float - Cumulative flow at first nonzero arrival [m³] 

1232 - 'n_events': int - Total number of events 

1233 - 'n_shocks': int - Number of shocks created 

1234 - 'n_rarefactions': int - Number of rarefactions created 

1235 - 'n_characteristics': int - Number of characteristics created 

1236 - 'theta_current': float - Final simulation cumulative flow [m³] 

1237 - 'sorption': SorptionModel - Sorption object 

1238 - 'tracker_state': FrontTrackerState - Complete simulation state 

1239 - 'aquifer_pore_volume': float - Pore volume for this simulation 

1240 

1241 Raises 

1242 ------ 

1243 RuntimeError 

1244 If the front-tracking solver leaves an unresolved wave interaction (the domain-mass 

1245 field over-counts stored mass). This is an internal-consistency tripwire, not an 

1246 input limitation: the solver composes every wave interaction, so a firing indicates a bug. 

1247 

1248 See Also 

1249 -------- 

1250 infiltration_to_extraction : Convolution-based approach for linear retardation 

1251 gamma_infiltration_to_extraction : For distributions of pore volumes 

1252 :ref:`concept-nonlinear-sorption` : Freundlich isotherm and front-tracking theory 

1253 :ref:`assumption-advection-dominated` : When diffusion/dispersion is negligible 

1254 

1255 Examples 

1256 -------- 

1257 .. disable_try_examples 

1258 

1259 :: 

1260 

1261 cout, structures = infiltration_to_extraction_nonlinear_sorption( 

1262 cin=cin, 

1263 flow=flow, 

1264 tedges=tedges, 

1265 cout_tedges=cout_tedges, 

1266 aquifer_pore_volumes=np.array([500.0]), 

1267 freundlich_k=0.01, 

1268 freundlich_n=2.0, 

1269 bulk_density=1500.0, 

1270 porosity=0.3, 

1271 ) 

1272 

1273 # Access spin-up period for first pore volume 

1274 theta_first = structures[0]["theta_first_arrival"] 

1275 t_first = structures[0]["tracker_state"].t_at_theta(theta_first) 

1276 print(f"First arrival: θ={theta_first:.2f} m³ (t={t_first:.2f} days)") 

1277 

1278 # Analyze events for first pore volume 

1279 for event in structures[0]["events"]: 

1280 print(f"θ={event['theta']:.2f}: {event['type']}") 

1281 """ 

1282 cin, flow, tedges, cout_tedges, aquifer_pore_volumes, sorption, cout_tedges_days = _validate_front_tracking_inputs( 

1283 cin=cin, 

1284 flow=flow, 

1285 tedges=tedges, 

1286 cout_tedges=cout_tedges, 

1287 aquifer_pore_volumes=aquifer_pore_volumes, 

1288 freundlich_k=freundlich_k, 

1289 freundlich_n=freundlich_n, 

1290 bulk_density=bulk_density, 

1291 porosity=porosity, 

1292 retardation_factor=retardation_factor, 

1293 langmuir_s_max=langmuir_s_max, 

1294 langmuir_k_l=langmuir_k_l, 

1295 ) 

1296 

1297 # Flow time edges in days (same reference as cout_tedges_days) 

1298 flow_tedges_days = tedges_to_days(tedges) 

1299 

1300 # Each pore-volume bin from the gamma distribution is an equal-mass streamtube, 

1301 # so all streamtubes carry equal flow at the outlet. The bundle outlet 

1302 # concentration is the simple arithmetic mean over streamtubes. Accumulate the 

1303 # per-streamtube output into a running sum so peak memory stays O(n_cout) 

1304 # rather than O(n_pv * n_cout). 

1305 cout_sum = np.zeros(len(cout_tedges) - 1) 

1306 structures = [] 

1307 

1308 # Resolve wave interactions out to the last requested output edge, so beyond-outlet merges 

1309 # that still feed an in-window outlet query are composed. The horizon follows the same 

1310 # piecewise-linear t → θ map the tracker uses, clamped at the first flow edge and 

1311 # extrapolating the final-bin flow when the output window extends past the flow record. 

1312 theta_flow_edges = np.concatenate(([0.0], np.cumsum(flow * np.diff(flow_tedges_days)))) 

1313 t_horizon = max(float(cout_tedges_days[-1]), float(flow_tedges_days[0])) 

1314 k = min(int(np.searchsorted(flow_tedges_days, t_horizon, side="right")) - 1, len(flow) - 1) 

1315 theta_horizon = float(theta_flow_edges[k] + (t_horizon - flow_tedges_days[k]) * flow[k]) 

1316 

1317 for aquifer_pore_volume in aquifer_pore_volumes: 

1318 tracker = FrontTracker( 

1319 cin=cin, 

1320 flow=flow, 

1321 tedges=tedges, 

1322 aquifer_pore_volume=aquifer_pore_volume, 

1323 sorption=sorption, 

1324 theta_horizon=theta_horizon, 

1325 ) 

1326 

1327 tracker.run(max_iterations=max_iterations) 

1328 

1329 # The solver resolves every wave interaction (shock↔shock, fan-entry, doubly-fed 

1330 # formation, same-apex annihilation and their compositions), so the wave list is 

1331 # interaction-consistent and the single-owner reader is exact. This detector is a 

1332 # tripwire: a firing means the solver left an unresolved interaction (an internal bug), 

1333 # not an unsupported input — fail loud rather than return a silently wrong cout. 

1334 interaction = find_unresolved_interaction(tracker.state) 

1335 if interaction is not None: 

1336 msg = ( 

1337 "infiltration_to_extraction_nonlinear_sorption: the front-tracking solver left an " 

1338 f"unresolved wave interaction ({interaction}). This is an internal inconsistency; " 

1339 "please report it with the cin/flow/pore-volume inputs at " 

1340 "https://github.com/gwtransport/gwtransport/issues." 

1341 ) 

1342 raise RuntimeError(msg) 

1343 

1344 cout_sum += _flow_weighted_front_tracking_output( 

1345 cout_tedges_days=cout_tedges_days, 

1346 flow_tedges_days=flow_tedges_days, 

1347 flow=flow, 

1348 v_outlet=aquifer_pore_volume, 

1349 waves=tracker.state.waves, 

1350 sorption=sorption, 

1351 theta_edges=tracker.state.theta_edges, 

1352 cin=cin, 

1353 ) 

1354 

1355 wave_counts = Counter(type(w) for w in tracker.state.waves) 

1356 structure = { 

1357 "waves": tracker.state.waves, 

1358 "events": tracker.state.events, 

1359 "theta_first_arrival": tracker.theta_first_arrival, 

1360 "n_events": len(tracker.state.events), 

1361 "n_shocks": wave_counts[ShockWave], 

1362 "n_rarefactions": wave_counts[RarefactionWave], 

1363 "n_characteristics": wave_counts[CharacteristicWave], 

1364 "theta_current": tracker.state.theta_current, 

1365 "sorption": sorption, 

1366 "tracker_state": tracker.state, 

1367 "aquifer_pore_volume": aquifer_pore_volume, 

1368 } 

1369 structures.append(structure) 

1370 

1371 return cout_sum / len(aquifer_pore_volumes), structures