Coverage for src/gwtransport/radial_asr.py: 88%

160 statements  

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

1r"""Exact radial advection-dispersion transport for a single well (push-pull / ASR). 

2 

3Water is injected in an infinite aquifer at a single fully-penetrating well and later recovered at 

4the same well under a signed flow schedule (push-pull / ASR). Transport is radial advection with 

5microdispersion, molecular diffusion, and linear sorption; the spread of velocities across the well 

6screen provides macrodispersion. Forward and backward modeling are supported. 

7 

8Computes the extracted flux concentration ``cout`` at a single fully-penetrating well driven by an 

9arbitrary signed flow schedule (positive = injection, negative = extraction, zero = rest) and an 

10arbitrary injected concentration ``cin``. The physics is the exact radial advection-dispersion of the 

11radial ASR knowledge base: volume coordinate ``V(r) = pi b n (r^2 - r_w^2)``, Scheidegger 

12velocity-dependent dispersion ``D = alpha_L |u| + D_m`` (microdispersion ``alpha_L |u|`` plus molecular diffusion ``D_m``), Kreft-Zuber flux boundary conditions, and 

13the exact per-phase kernels (Airy for ``D_m = 0``; the log-derivative Riccati ODE for ``D_m > 0``). 

14Nothing is reduced to a Gaussian; the exact 

15non-Gaussian breakthrough (with the correct skewness) is carried. 

16 

17The forward map is **grid-free** end to end -- no PDE is discretized, so none of the finite-volume 

18artefacts appear. A single inject-then-extract cycle with no intervening rest uses the closed-form echo operator 

19(``gwtransport._radial_asr_compose``, KB Sec. 10a) -- exact for arbitrary within-phase variable flow, 

20with the exact temporal moments. Any other signed-flow schedule (more reversals / multi-cycle ASR, or a 

21single cycle with a rest under nonzero ``D_m``) uses the reused-propagator-matrix engine 

22(``gwtransport._radial_asr_reuse``, KB addendum Sec. A1-A7), which composes the exact per-phase kernels 

23(Airy / Riccati / Bessel) through the interior two-point Green's functions. Each per-reversal field 

24hand-off ``f_out = P @ f`` is a bounded linear operator; its matrix ``P`` is built once per distinct 

25``(direction, phase volume)`` from a single batched de Hoog inversion and reused at every recurrence, so 

26the special-function + inversion cost is ``O(distinct phase volumes)`` rather than ``O(reversals)``. It is 

27bit-equivalent, to the de Hoog floor, to the per-reversal grid-free composition. Molecular diffusion during 

28pumping (the ``D_m > 0`` Whittaker kernel) is evaluated through the log-derivative Riccati ODE 

29(``gwtransport._radial_asr_kernels.resolvent_riccati``) -- exact to the de Hoog inversion floor at any 

30``A_0/D_m``, with no special-function precision cap, and reducing continuously to the Airy branch as 

31``D_m -> 0``. During a **rest** (``Q = 0``) advection and microdispersion vanish and molecular 

32diffusion acts alone on the wall-clock clock; it is carried exactly by the order-0 modified Bessel 

33pure-diffusion kernel, the dominant mixing for seasonal storage / ATES. The only 

34numerical steps are Gauss-Legendre quadrature and de Hoog Laplace inversion of exact special-function 

35kernels. An independent finite-volume solve of the same PDE (``tests/src/_radial_asr_fv_oracle.py``, 

36KB Sec. 9) is used only as a test oracle. The propagator matrices are assembled on the Bromwich 

37contour (``Re s > 0``), where the field hand-off is well-conditioned at any Peclet. The engine is chosen 

38automatically; cycles are expressed through the flow sign pattern, not 

39an argument. 

40 

41The reported ``cout`` is the flow-weighted average over each output bin -- defined on extraction bins 

42(``flow < 0``) and ``NaN`` on injection / rest bins (nothing is recovered there). 

43 

44Macrodispersion within the well screen 

45-------------------------------------- 

46The well screen has a **known** height; macrodispersion is the spread of arrival times caused by 

47*velocity heterogeneity across the screen*. It is modelled as parallel streamtubes (``pore_heights``): 

48each streamtube is an independent radial cell carrying the full flow, with an effective pore height 

49that sets its velocity, and the output is the weight-averaged breakthrough. A streamtube of effective 

50height ``b`` has velocity ``proportional to 1/b`` (its pore volume to radius ``r`` is 

51``pi b n (r^2 - r_w^2)``), so smaller ``b`` means faster breakthrough. 

52:func:`gamma_infiltration_to_extraction` builds this ensemble from a gamma distribution of the layer 

53**velocity** within the fixed screen height (see that function); the mean velocity is set by the screen 

54height and the spread by a velocity coefficient of variation. The spread is a within-screen velocity 

55distribution -- velocity heterogeneity across the well screen -- not an aquifer pore-volume distribution. 

56 

57Regional background flow (drift) 

58-------------------------------- 

59With a steady uniform regional Darcy flux ``regional_flux`` (``U``, drift seepage ``v_d = U/n``) the well 

60field is superimposed on a regional gradient, so the stored bubble drifts and recovery degrades. The 

61radial symmetry is broken and the transport is solved by an **azimuthal Fourier-mode** expansion 

62``c(r, theta) = sum_m c_m(r) e^{i m theta}`` (``m = 0`` is the radial engine; drift couples ``m`` to 

63``m +- 1``), composed through the same per-phase interior Green's functions 

64(``gwtransport._radial_asr_drift_kernels``). ``regional_flux = 0`` (default) dispatches to the radial path 

65bit-for-bit. The engine is for the **slow-drift** envelope -- the plume (including its rest-phase drift 

66displacement) must stay well inside the stagnation radius ``r_s = |A_0|/|v_d|`` (else a ``ValueError``). 

67Rest phases (``flow == 0``) are propagated by the exact free-space drift kernel (translate + anisotropic 

68spread). The drift-induced recovery loss is validated against an independent 2-D finite-volume oracle. 

69 

70Available functions: 

71 

72- :func:`infiltration_to_extraction` -- forward transport (cin -> cout). 

73- :func:`extraction_to_infiltration` -- inverse via Tikhonov regularization (cout -> cin). 

74- :func:`gamma_infiltration_to_extraction` -- gamma-distributed screen velocity (forward). 

75- :func:`gamma_extraction_to_infiltration` -- same, inverse. 

76 

77References 

78---------- 

79The references below give the published closed-form solutions for the **single-phase** radial *injection* 

80problem (steady divergent flow from one well) -- the per-phase forward kernel this module composes. The 

81convergent-extraction dual (KB Sec. 7) and the multi-cycle push-pull / ASR composition across flow 

82reversals are built on top of those kernels here and are not in the single-injection references. All 

83share the assumptions used here: a single fully-penetrating well in a homogeneous medium with steady 

84divergent flow ``v = Q / (2 pi b n r)``, plus retardation. 

85 

86The ``D_m = 0`` kernel (velocity-proportional microdispersion ``D = alpha_L |u|``, Airy functions) 

87is the classical radial-dispersion problem: Tang & Babu (1979) under a Dirichlet (resident-concentration) 

88well boundary, and Chen (1987) under the Cauchy / third-type (flux) boundary used here -- explicitly the 

89Kreft-Zuber flux concentration, with transfer function ``Ai(Y) / [Ai(Y0)/2 - p^(1/3) Ai'(Y0)]`` equal to 

90the flux operator this module evaluates. The ``D_m > 0`` kernel (``D = alpha_L |u| + D_m``, Kummer / 

91confluent-hypergeometric functions) under the same flux boundary, with retardation, is Aichi & Akitaya 

92(2018) -- whose well operator ``U(a,b) + 2a U(a+1,b+1)`` is this module's Whittaker flux boundary; they 

93record the ``D_m -> 0`` reduction to Chen (1987) as an open problem, which this module performs 

94continuously -- the log-derivative Riccati kernel reduces smoothly to the Airy branch as ``D_m -> 0``. 

95The ``alpha_L = 0`` limit (constant diffusion, drift-dominated radial transport, Whittaker equation) is 

96Akanji & Falade (2019). Each is an injection-only solution; none treats extraction or multi-cycle push-pull. 

97 

98Kreft, A., & Zuber, A. (1978). On the physical meaning of the dispersion equation and its solutions 

99for different initial and boundary conditions. Chemical Engineering Science, 33(11), 1471-1480. 

100 

101Tang, D. H., & Babu, D. K. (1979). Analytical solution of a velocity dependent dispersion problem. 

102Water Resources Research, 15(6), 1471-1478. 

103 

104Chen, C.-S. (1987). Analytical solutions for radial dispersion with Cauchy boundary at injection well. 

105Water Resources Research, 23(7), 1217-1224. 

106 

107Aichi, M., & Akitaya, K. (2018). Analytical solution for a radial advection-dispersion equation 

108including both mechanical dispersion and molecular diffusion for a steady-state flow field in a 

109horizontal aquifer caused by a constant rate injection from a well. Hydrological Research Letters, 

11012(3), 23-27. 

111 

112Akanji, L. T., & Falade, G. K. (2019). Closed-form solution of radial transport of tracers in porous 

113media influenced by linear drift. Energies, 12(1), 29. 

114 

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

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

117""" 

118 

119import numpy as np 

120import numpy.typing as npt 

121import pandas as pd 

122 

123from gwtransport import gamma 

124from gwtransport._radial_asr_compose import single_cycle_echo_matrix 

125from gwtransport._radial_asr_drift_kernels import _RS_FRAC, block_cout_deviation 

126from gwtransport._radial_asr_reuse import cout_deviation 

127from gwtransport._time import dt_to_days 

128from gwtransport._validation import _validate_retardation_factor 

129 

130 

131def _is_single_cycle(flow: npt.NDArray[np.floating]) -> bool: 

132 """Return True if the schedule is a single injection block followed by a single extraction block. 

133 

134 Such schedules (one flow reversal, injection first) use the exact closed-form echo operator; any 

135 other signed-flow pattern (more reversals, extraction first) uses the reused-propagator-matrix engine. 

136 

137 Returns 

138 ------- 

139 bool 

140 Whether ``flow`` is a single inject-then-extract cycle. 

141 """ 

142 signs = np.sign(flow[flow != 0.0]) 

143 n_changes = int(np.sum(np.diff(signs) != 0)) if signs.size else 0 

144 return n_changes <= 1 and (signs.size == 0 or signs[0] > 0) 

145 

146 

147def _validate( 

148 *, 

149 cin_or_cout: npt.NDArray[np.floating], 

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

151 tedges: pd.DatetimeIndex, 

152 cout_tedges: pd.DatetimeIndex, 

153 pore_heights: npt.NDArray[np.floating], 

154 porosity: float, 

155 well_radius: float, 

156 longitudinal_dispersivity: float, 

157 molecular_diffusivity: float, 

158 retardation_factor: float, 

159 weights: npt.NDArray[np.floating] | None, 

160 regional_flux: float, 

161 n_modes: int | None, 

162) -> None: 

163 """Validate inputs for the radial single-well transport functions (signed flow is allowed). 

164 

165 Raises 

166 ------ 

167 ValueError 

168 On inconsistent lengths, non-positive geometry, out-of-range porosity, negative dispersion, 

169 ``retardation_factor < 1``, mismatched ``weights``, NaN in ``flow``, a non-finite ``regional_flux``, 

170 an ``n_modes < 1``, or a ``cout_tedges`` that differs from ``tedges`` (a distinct output grid is not 

171 yet supported). 

172 """ 

173 if len(tedges) != len(flow) + 1: 

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

175 raise ValueError(msg) 

176 if len(cin_or_cout) != len(flow): 

177 msg = "cin/cout must have the same length as flow" 

178 raise ValueError(msg) 

179 if not tedges.equals(cout_tedges): 

180 msg = "cout_tedges must equal tedges (a distinct output grid is not yet supported)" 

181 raise ValueError(msg) 

182 if np.any(np.isnan(flow)): 

183 msg = "flow contains NaN values, which are not allowed" 

184 raise ValueError(msg) 

185 if np.any(pore_heights <= 0.0): 

186 msg = "pore_heights must be positive" 

187 raise ValueError(msg) 

188 if not 0.0 < porosity <= 1.0: 

189 msg = "porosity must be in (0, 1]" 

190 raise ValueError(msg) 

191 if well_radius <= 0.0: 

192 msg = "well_radius must be positive" 

193 raise ValueError(msg) 

194 if longitudinal_dispersivity <= 0.0: 

195 msg = "longitudinal_dispersivity must be positive (the dispersion kernel requires alpha_L > 0)" 

196 raise ValueError(msg) 

197 if molecular_diffusivity < 0.0: 

198 msg = "molecular_diffusivity must be non-negative" 

199 raise ValueError(msg) 

200 _validate_retardation_factor(retardation_factor) 

201 if weights is not None and len(weights) != len(pore_heights): 

202 msg = "weights must have the same length as pore_heights" 

203 raise ValueError(msg) 

204 if not np.isfinite(regional_flux): 

205 msg = "regional_flux must be finite" 

206 raise ValueError(msg) 

207 if n_modes is not None and n_modes < 1: 

208 msg = "n_modes must be >= 1" 

209 raise ValueError(msg) 

210 

211 

212def _echo_operator( 

213 *, 

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

215 tedges: pd.DatetimeIndex, 

216 c_geos: npt.NDArray[np.floating], 

217 well_radius: float, 

218 longitudinal_dispersivity: float, 

219 molecular_diffusivity: float, 

220 retardation_factor: float, 

221 weights: npt.NDArray[np.floating], 

222 n_quad: int, 

223) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.bool_], npt.NDArray[np.bool_]]: 

224 """Weight-averaged single-cycle echo operator ``W`` (``cout' = W @ cin'_inj``) over the streamtubes. 

225 

226 Builds the closed-form echo matrix per streamtube (geometry constant ``c_geo = pi b n``) and 

227 averages by ``weights``. Used by both the forward (``cout = W @ cin``) and the reverse (Tikhonov). 

228 

229 Returns 

230 ------- 

231 w_ens : ndarray, shape (n_ext, n_inj) 

232 Weight-averaged echo operator. 

233 inj_mask, ext_mask : ndarray of bool 

234 Injection (``flow > 0``) and extraction (``flow < 0``) bin masks. 

235 """ 

236 inj_mask, ext_mask = flow > 0.0, flow < 0.0 

237 dt = dt_to_days(tedges) 

238 inj_vol = np.concatenate(([0.0], np.cumsum((flow * dt)[inj_mask]))) # 0 .. S_inj 

239 ext_vol = np.concatenate(([0.0], np.cumsum((-flow * dt)[ext_mask]))) # 0 .. T_end 

240 inj_flow_scale = float(np.mean(flow[inj_mask])) if np.any(inj_mask) else 1.0 

241 ext_flow_scale = float(np.mean(-flow[ext_mask])) if np.any(ext_mask) else 1.0 

242 w_ens = np.zeros((int(np.sum(ext_mask)), int(np.sum(inj_mask)))) 

243 for c_geo, w_i in zip(c_geos, weights, strict=True): 

244 w_ens += w_i * single_cycle_echo_matrix( 

245 inj_volume_edges=inj_vol, 

246 ext_volume_edges=ext_vol, 

247 c_geo=c_geo, 

248 r_w=well_radius, 

249 alpha_l=longitudinal_dispersivity, 

250 inj_flow_scale=inj_flow_scale, 

251 ext_flow_scale=ext_flow_scale, 

252 retardation_factor=retardation_factor, 

253 molecular_diffusivity=molecular_diffusivity, 

254 n_quad=n_quad, 

255 ) 

256 return w_ens / np.sum(weights), inj_mask, ext_mask 

257 

258 

259def _reuse_ensemble( 

260 cin_deviation: npt.NDArray[np.floating], 

261 *, 

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

263 dt_days: npt.NDArray[np.floating], 

264 c_geos: npt.NDArray[np.floating], 

265 well_radius: float, 

266 longitudinal_dispersivity: float, 

267 molecular_diffusivity: float, 

268 retardation_factor: float, 

269 weights: npt.NDArray[np.floating], 

270 n_quad: int, 

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

272 """Weight-averaged multi-cycle extracted-flux deviation over the streamtubes. 

273 

274 Runs the reused-propagator-matrix multi-cycle engine once per streamtube (geometry constant 

275 ``c_geo = pi b n``) and averages by ``weights``. ``cin_deviation`` may be ``(n,)`` or ``(n, k)`` -- a 

276 column batch is transported through one engine pass per streamtube (the per-phase propagator / source / 

277 readout matrices are cin-independent, so they are built once and applied to every column). Used by the 

278 forward (with ``cin``) and the reverse (with the unit-pulse column batch). 

279 

280 Returns 

281 ------- 

282 ndarray, shape (n,) or (n, k) 

283 Weight-averaged extracted-flux deviation on extraction bins (matching ``cin_deviation``), ``0`` 

284 elsewhere. 

285 """ 

286 # cout_deviation returns NaN on injection / rest bins by contract (structural, expected). Only the 

287 # extraction bins are accumulated, so those structural NaNs are dropped without a blanket nan_to_num -- 

288 # a genuine NaN on an EXTRACTION bin (a real numerical failure) then propagates and surfaces instead of 

289 # silently reading as a physical zero. 

290 ext_mask = flow < 0.0 

291 acc = np.zeros(np.shape(cin_deviation)) 

292 for c_geo, w_i in zip(c_geos, weights, strict=True): 

293 dev = cout_deviation( 

294 cin_deviation=cin_deviation, 

295 flow=flow, 

296 dt_days=dt_days, 

297 c_geo=c_geo, 

298 r_w=well_radius, 

299 alpha_l=longitudinal_dispersivity, 

300 molecular_diffusivity=molecular_diffusivity, 

301 retardation_factor=retardation_factor, 

302 n_quad=n_quad, 

303 ) 

304 acc[ext_mask] += w_i * dev[ext_mask] 

305 return acc / np.sum(weights) 

306 

307 

308def _auto_n_modes( 

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

310 dt_days: npt.NDArray[np.floating], 

311 c_geo: float, 

312 well_radius: float, 

313 longitudinal_dispersivity: float, 

314 v_d: float, 

315) -> int: 

316 r"""Azimuthal truncation ``M`` sized from the drift ratio ``eps`` and the rest-phase displacement. 

317 

318 The pumping-phase mode amplitudes decay geometrically, ``|c_m| ~ eps^|m|`` with 

319 ``eps = v_d R_b / A_0``, so keeping modes ``-M .. M`` truncates the azimuthal field at 

320 ``O(eps^{M+1})``; ``M`` is chosen so that tail is below ``~5e-3``. An interior rest phase 

321 additionally translates the plume by ``delta = v_d t_rest`` (``R = 1``, conservative; idle bins 

322 before the first or after the last pumping do not move the field), populating harmonics up 

323 to ``~ delta / width`` (``width`` the radial breakthrough std) -- the second bound. The result is 

324 clamped to ``[2, 8]`` (the slow-drift envelope -- beyond ``eps ~ 0.6`` the far-field escape this 

325 engine does not model dominates anyway); the rest kernel's honest spectral-tail guard raises if a long 

326 rest still outruns the clamp (pass ``n_modes`` explicitly then). ``A_0`` uses the **smallest** pumping 

327 magnitude (the worst-case largest ``eps``, consistent with the stagnation-radius envelope guard), 

328 ``R_b`` the peak net injected radius. This function is only reached for nonzero drift. 

329 

330 Returns 

331 ------- 

332 int 

333 Azimuthal truncation ``M``. 

334 """ 

335 pumping = np.abs(flow[flow != 0.0]) 

336 if pumping.size == 0: # all-rest schedule: nothing pumps; the engine returns all-NaN downstream 

337 return 2 

338 a0 = float(np.min(pumping)) / (2.0 * c_geo) 

339 net_volume = np.concatenate(([0.0], np.cumsum(flow * dt_days))) 

340 peak_volume = max(float(net_volume.max()), 0.0) 

341 r_b = np.sqrt(well_radius**2 + peak_volume / c_geo) 

342 nz = np.flatnonzero(flow != 0.0) 

343 interior = slice(nz[0], nz[-1] + 1) # leading/trailing idle bins do not move the field 

344 delta = abs(v_d) * float(np.sum(dt_days[interior][flow[interior] == 0.0])) 

345 eps = min(abs(v_d) * (r_b + delta) / abs(a0), _RS_FRAC) 

346 m_eps = int(np.ceil(np.log(5e-3) / np.log(eps))) if eps > 0.0 else 2 

347 width = np.sqrt(longitudinal_dispersivity * r_b + longitudinal_dispersivity**2) 

348 m_shift = int(np.ceil(delta / width)) + 2 if delta > 0.0 else 2 

349 return int(np.clip(max(m_eps, m_shift), 2, 8)) 

350 

351 

352def _block_ensemble( 

353 cin_deviation: npt.NDArray[np.floating], 

354 *, 

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

356 dt_days: npt.NDArray[np.floating], 

357 c_geos: npt.NDArray[np.floating], 

358 porosity: float, 

359 well_radius: float, 

360 longitudinal_dispersivity: float, 

361 molecular_diffusivity: float, 

362 retardation_factor: float, 

363 regional_flux: float, 

364 n_modes: int | None, 

365 weights: npt.NDArray[np.floating], 

366 n_quad: int, 

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

368 """Weight-averaged multi-cycle extracted-flux deviation with regional drift over the streamtubes. 

369 

370 Runs the azimuthal-mode block engine (:func:`gwtransport._radial_asr_drift_kernels.block_cout_deviation`) 

371 once per streamtube (geometry constant ``c_geo = pi b n``) and averages by ``weights``. The drift 

372 seepage ``v_d = U / n`` is the same for every streamtube (a regional Darcy flux through the porosity); 

373 only the radial strength ``A_0 ~ 1/c_geo`` varies, so faster (thinner) streamtubes see a smaller drift 

374 ratio. ``n_modes`` is auto-sized per streamtube from its drift ratio when not given. ``cin_deviation`` 

375 may be ``(n,)`` or ``(n, k)`` -- a column batch is transported through one engine pass per streamtube 

376 (used by the reverse operator build). 

377 

378 Returns 

379 ------- 

380 ndarray, shape (n,) or (n, k) 

381 Weight-averaged extracted-flux deviation on extraction bins (matching ``cin_deviation``), ``0`` 

382 elsewhere. 

383 """ 

384 v_d = regional_flux / porosity 

385 acc = np.zeros(np.shape(cin_deviation)) 

386 for c_geo, w_i in zip(c_geos, weights, strict=True): 

387 m = ( 

388 n_modes 

389 if n_modes is not None 

390 else _auto_n_modes(flow, dt_days, c_geo, well_radius, longitudinal_dispersivity, v_d) 

391 ) 

392 acc += w_i * np.nan_to_num( 

393 block_cout_deviation( 

394 cin_deviation=cin_deviation, 

395 flow=flow, 

396 dt_days=dt_days, 

397 c_geo=c_geo, 

398 r_w=well_radius, 

399 alpha_l=longitudinal_dispersivity, 

400 v_d=v_d, 

401 molecular_diffusivity=molecular_diffusivity, 

402 retardation_factor=retardation_factor, 

403 n_modes=m, 

404 n_quad=n_quad, 

405 ) 

406 ) 

407 return acc / np.sum(weights) 

408 

409 

410def infiltration_to_extraction( 

411 *, 

412 cin: npt.ArrayLike, 

413 flow: npt.ArrayLike, 

414 tedges: pd.DatetimeIndex, 

415 cout_tedges: pd.DatetimeIndex, 

416 pore_heights: npt.ArrayLike, 

417 porosity: float, 

418 well_radius: float, 

419 longitudinal_dispersivity: float, 

420 molecular_diffusivity: float = 0.0, 

421 retardation_factor: float = 1.0, 

422 weights: npt.ArrayLike | None = None, 

423 background: float = 0.0, 

424 regional_flux: float = 0.0, 

425 n_modes: int | None = None, 

426 n_quad: int = 240, 

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

428 """Compute the extracted flux concentration at a radial well for a signed flow schedule. 

429 

430 Parameters 

431 ---------- 

432 cin : array-like, shape (n,) 

433 Injected concentration per time bin (used only on injection bins, ``flow > 0``). 

434 flow : array-like, shape (n,) 

435 Signed flow per time bin [m^3/day]: ``> 0`` injection, ``< 0`` extraction, ``0`` rest. 

436 tedges : DatetimeIndex 

437 Time bin edges (``n + 1`` for ``n`` bins). 

438 cout_tedges : DatetimeIndex 

439 Output time bin edges; must equal ``tedges``. Output is NaN on injection / rest bins. 

440 pore_heights : array-like 

441 Effective streamtube pore height(s) ``b`` [m] -- a scalar (one homogeneous screen) or an array 

442 of streamtube heights for the velocity-heterogeneity macrodispersion ensemble (each streamtube 

443 carries the full flow; smaller ``b`` = faster). See the module docstring and 

444 :func:`gamma_infiltration_to_extraction`. 

445 porosity : float 

446 Porosity ``n`` [-]. 

447 well_radius : float 

448 Well (screen) radius ``r_w`` [m]. 

449 longitudinal_dispersivity : float 

450 Longitudinal dispersivity ``alpha_L`` [m]. 

451 molecular_diffusivity : float, optional 

452 Molecular diffusivity ``D_m`` [m^2/day]. Default 0. ``D_m = 0`` uses the vectorized Airy branch; 

453 ``D_m > 0`` uses the log-derivative Riccati kernel -- exact to the de Hoog floor at any ``A_0/D_m`` 

454 with no precision cap, reducing continuously to the Airy branch as ``D_m -> 0``. 

455 retardation_factor : float, optional 

456 Linear retardation ``R >= 1``. Default 1. 

457 weights : array-like, optional 

458 Per-streamtube averaging weights (same length as ``pore_heights``). Default equal weights. 

459 background : float, optional 

460 Ambient aquifer concentration ``c_bg``. The deviation ``cin - c_bg`` is transported and 

461 ``c_bg`` is added back; constant ``cin = c_bg`` returns ``cout = c_bg``. Default 0. 

462 regional_flux : float, optional 

463 Steady uniform regional background Darcy flux ``U`` [m/day] in ``+x`` (drift seepage 

464 ``v_d = U / n``). ``0`` (default) reproduces the radial-symmetric engine bit-for-bit. A nonzero 

465 value engages the azimuthal-mode block engine, which captures the drift-induced recovery loss 

466 (the down-gradient plume is partly swept past the well). The slow-drift envelope requires the 

467 plume -- including its rest-phase drift displacement -- to stay well inside the stagnation radius 

468 ``r_s = |A_0| / |v_d|`` (a ``ValueError`` is raised otherwise). Rest phases (``flow == 0``) are 

469 propagated by the exact free-space drift kernel (translation ``v_d t / R`` plus anisotropic 

470 Gaussian spread, with a Neumann-image closure at the shut well face). See 

471 :ref:`concept-drift-envelope` for a worked multi-year feasibility table. 

472 n_modes : int, optional 

473 Azimuthal truncation ``M`` for the drift engine (keeps modes ``-M .. M``). Default ``None`` 

474 auto-sizes ``M`` from the plume-front drift ratio ``eps = v_d R_b / A_0`` and the rest-phase 

475 displacement (clamped to ``[2, 8]``). Ignored when ``regional_flux == 0``. 

476 n_quad : int, optional 

477 Gauss-Legendre node count for the resident-profile superposition. Default 240. 

478 

479 Returns 

480 ------- 

481 ndarray, shape (n,) 

482 Extracted flux concentration; NaN on injection and rest bins. 

483 """ 

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

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

486 pore_heights = np.atleast_1d(np.asarray(pore_heights, dtype=float)) 

487 weights_arr = np.ones(len(pore_heights)) if weights is None else np.atleast_1d(np.asarray(weights, dtype=float)) 

488 _validate( 

489 cin_or_cout=cin, 

490 flow=flow, 

491 tedges=tedges, 

492 cout_tedges=cout_tedges, 

493 pore_heights=pore_heights, 

494 porosity=porosity, 

495 well_radius=well_radius, 

496 longitudinal_dispersivity=longitudinal_dispersivity, 

497 molecular_diffusivity=molecular_diffusivity, 

498 retardation_factor=retardation_factor, 

499 weights=None if weights is None else weights_arr, 

500 regional_flux=regional_flux, 

501 n_modes=n_modes, 

502 ) 

503 c_geos = np.pi * pore_heights * porosity 

504 cout = np.full(len(flow), np.nan) 

505 if regional_flux != 0.0: 

506 # Steady regional drift breaks radial symmetry: the azimuthal-mode block engine carries it. 

507 ext_mask = flow < 0.0 

508 cout_dev = _block_ensemble( 

509 cin - background, 

510 flow=flow, 

511 dt_days=dt_to_days(tedges), 

512 c_geos=c_geos, 

513 porosity=porosity, 

514 well_radius=well_radius, 

515 longitudinal_dispersivity=longitudinal_dispersivity, 

516 molecular_diffusivity=molecular_diffusivity, 

517 retardation_factor=retardation_factor, 

518 regional_flux=regional_flux, 

519 n_modes=n_modes, 

520 weights=weights_arr, 

521 n_quad=n_quad, 

522 ) 

523 cout[ext_mask] = background + cout_dev[ext_mask] 

524 return cout 

525 # A rest phase combined with molecular diffusion (seasonal storage / ATES) cannot use the 

526 # flushed-volume echo operator, which is blind to a rest's wall-clock diffusion; route it to the 

527 # reuse engine (which propagates the rest with the Bessel pure-diffusion kernel). 

528 use_echo = _is_single_cycle(flow) and not (molecular_diffusivity > 0.0 and np.any(flow == 0.0)) 

529 if use_echo: 

530 w_ens, inj_mask, ext_mask = _echo_operator( 

531 flow=flow, 

532 tedges=tedges, 

533 c_geos=c_geos, 

534 well_radius=well_radius, 

535 longitudinal_dispersivity=longitudinal_dispersivity, 

536 molecular_diffusivity=molecular_diffusivity, 

537 retardation_factor=retardation_factor, 

538 weights=weights_arr, 

539 n_quad=n_quad, 

540 ) 

541 cout[ext_mask] = background + w_ens @ (cin[inj_mask] - background) 

542 return cout 

543 ext_mask = flow < 0.0 

544 cout_dev = _reuse_ensemble( 

545 cin - background, 

546 flow=flow, 

547 dt_days=dt_to_days(tedges), 

548 c_geos=c_geos, 

549 well_radius=well_radius, 

550 longitudinal_dispersivity=longitudinal_dispersivity, 

551 molecular_diffusivity=molecular_diffusivity, 

552 retardation_factor=retardation_factor, 

553 weights=weights_arr, 

554 n_quad=n_quad, 

555 ) 

556 cout[ext_mask] = background + cout_dev[ext_mask] 

557 return cout 

558 

559 

560def extraction_to_infiltration( 

561 *, 

562 cout: npt.ArrayLike, 

563 flow: npt.ArrayLike, 

564 tedges: pd.DatetimeIndex, 

565 cout_tedges: pd.DatetimeIndex, 

566 pore_heights: npt.ArrayLike, 

567 porosity: float, 

568 well_radius: float, 

569 longitudinal_dispersivity: float, 

570 molecular_diffusivity: float = 0.0, 

571 retardation_factor: float = 1.0, 

572 weights: npt.ArrayLike | None = None, 

573 background: float = 0.0, 

574 regional_flux: float = 0.0, 

575 n_modes: int | None = None, 

576 regularization_strength: float = 1e-10, 

577 n_quad: int = 240, 

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

579 """Recover the injected concentration from extracted-water measurements (Tikhonov inverse). 

580 

581 Inverts the forward operator built by :func:`infiltration_to_extraction`. Returns the injected 

582 concentration on injection bins (NaN on extraction / rest bins). 

583 

584 Parameters 

585 ---------- 

586 cout : array-like, shape (n,) 

587 Measured extracted concentration (used on extraction bins, ``flow < 0``). 

588 flow, tedges, cout_tedges, pore_heights, porosity, well_radius, longitudinal_dispersivity 

589 As in :func:`infiltration_to_extraction`. 

590 molecular_diffusivity, retardation_factor, weights, background, regional_flux, n_modes, n_quad 

591 As in :func:`infiltration_to_extraction`. 

592 regularization_strength : float, optional 

593 Tikhonov parameter. Default ``1e-10``. 

594 

595 Returns 

596 ------- 

597 ndarray, shape (n,) 

598 Recovered injected concentration; NaN on extraction / rest bins. 

599 

600 Raises 

601 ------ 

602 ValueError 

603 If ``cout`` contains NaN on any extraction bin (``flow < 0``), which would poison the 

604 least-squares solve. Structural NaN on injection / rest bins is allowed. 

605 """ 

606 cout = np.asarray(cout, dtype=float) 

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

608 pore_heights = np.atleast_1d(np.asarray(pore_heights, dtype=float)) 

609 weights_arr = np.ones(len(pore_heights)) if weights is None else np.atleast_1d(np.asarray(weights, dtype=float)) 

610 _validate( 

611 cin_or_cout=cout, 

612 flow=flow, 

613 tedges=tedges, 

614 cout_tedges=cout_tedges, 

615 pore_heights=pore_heights, 

616 porosity=porosity, 

617 well_radius=well_radius, 

618 longitudinal_dispersivity=longitudinal_dispersivity, 

619 molecular_diffusivity=molecular_diffusivity, 

620 retardation_factor=retardation_factor, 

621 weights=None if weights is None else weights_arr, 

622 regional_flux=regional_flux, 

623 n_modes=n_modes, 

624 ) 

625 # A NaN measurement on an extraction bin (the only bins the inverse reads) would poison the whole 

626 # least-squares solve into an all-NaN cin; raise instead, as the advection / diffusion inverses do. 

627 # Structural NaN on injection / rest bins is allowed (those bins are ignored by the inverse). 

628 if np.any(np.isnan(cout[flow < 0.0])): 

629 msg = "cout contains NaN values on extraction bins, which are not allowed" 

630 raise ValueError(msg) 

631 c_geos = np.pi * pore_heights * porosity 

632 # A rest phase with molecular diffusion routes to the reuse engine (see the forward function); regional 

633 # drift never uses the radial echo operator (it breaks the azimuthal symmetry the echo relies on). 

634 use_echo = ( 

635 regional_flux == 0.0 and _is_single_cycle(flow) and not (molecular_diffusivity > 0.0 and np.any(flow == 0.0)) 

636 ) 

637 if use_echo: 

638 w_ens, inj_mask, ext_mask = _echo_operator( 

639 flow=flow, 

640 tedges=tedges, 

641 c_geos=c_geos, 

642 well_radius=well_radius, 

643 longitudinal_dispersivity=longitudinal_dispersivity, 

644 molecular_diffusivity=molecular_diffusivity, 

645 retardation_factor=retardation_factor, 

646 weights=weights_arr, 

647 n_quad=n_quad, 

648 ) 

649 else: 

650 # Build the dense forward operator W whose columns are the unit-injection-pulse responses; the reverse 

651 # cannot reuse the cheap single-solve forward path. The per-phase propagator / source / readout matrices 

652 # are cin-independent (flow + geometry only), so the whole unit-pulse batch is transported in ONE engine 

653 # pass -- the matrices are built once and applied to every column. The block (drift) engine carries 

654 # the regional drift; the scalar reuse engine the drift-free case. 

655 inj_mask, ext_mask = flow > 0.0, flow < 0.0 

656 inj_idx = np.flatnonzero(inj_mask) 

657 dt_days = dt_to_days(tedges) 

658 pulses = np.zeros((len(flow), len(inj_idx))) 

659 pulses[inj_idx, np.arange(len(inj_idx))] = 1.0 

660 if regional_flux != 0.0: 

661 cols = _block_ensemble( 

662 pulses, 

663 flow=flow, 

664 dt_days=dt_days, 

665 c_geos=c_geos, 

666 porosity=porosity, 

667 well_radius=well_radius, 

668 longitudinal_dispersivity=longitudinal_dispersivity, 

669 molecular_diffusivity=molecular_diffusivity, 

670 retardation_factor=retardation_factor, 

671 regional_flux=regional_flux, 

672 n_modes=n_modes, 

673 weights=weights_arr, 

674 n_quad=n_quad, 

675 ) 

676 else: 

677 cols = _reuse_ensemble( 

678 pulses, 

679 flow=flow, 

680 dt_days=dt_days, 

681 c_geos=c_geos, 

682 well_radius=well_radius, 

683 longitudinal_dispersivity=longitudinal_dispersivity, 

684 molecular_diffusivity=molecular_diffusivity, 

685 retardation_factor=retardation_factor, 

686 weights=weights_arr, 

687 n_quad=n_quad, 

688 ) 

689 w_ens = cols[ext_mask, :] 

690 # Tikhonov least-squares min ||W x - (cout-bg)||^2 + lambda ||x||^2 via the stable augmented 

691 # system [W; sqrt(lambda) I] x = [cout-bg; 0]. The echo / reuse operator has column sums ~1 

692 # (mass conservation per injection bin) and overdetermined rows, so a direct Tikhonov fit is used. 

693 n_inj = w_ens.shape[1] 

694 augmented = np.vstack([w_ens, np.sqrt(regularization_strength) * np.eye(n_inj)]) 

695 rhs = np.concatenate([cout[ext_mask] - background, np.zeros(n_inj)]) 

696 cin_dev = np.linalg.lstsq(augmented, rhs, rcond=None)[0] 

697 cin = np.full(len(flow), np.nan) 

698 cin[inj_mask] = background + cin_dev 

699 return cin 

700 

701 

702def gamma_infiltration_to_extraction( 

703 *, 

704 cin: npt.ArrayLike, 

705 flow: npt.ArrayLike, 

706 tedges: pd.DatetimeIndex, 

707 cout_tedges: pd.DatetimeIndex, 

708 porosity: float, 

709 well_radius: float, 

710 longitudinal_dispersivity: float, 

711 screen_height: float, 

712 velocity_cv: float, 

713 n_bins: int = 100, 

714 molecular_diffusivity: float = 0.0, 

715 retardation_factor: float = 1.0, 

716 background: float = 0.0, 

717 regional_flux: float = 0.0, 

718 n_modes: int | None = None, 

719 n_quad: int = 240, 

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

721 """Radial transport with gamma-distributed screen velocity (within-screen macrodispersion). 

722 

723 The well screen has a **known** height ``screen_height``; macrodispersion is the spread of arrival 

724 times from velocity heterogeneity across that fixed height. The layer velocity is gamma-distributed 

725 with mean equal to the homogeneous value (a streamtube at the mean velocity has effective pore 

726 height ``screen_height``) and coefficient of variation ``velocity_cv``. A streamtube with velocity 

727 ratio ``rho`` (gamma, mean 1) has effective pore height ``screen_height / rho`` -- faster layers are 

728 thinner and break through sooner. The gamma is discretized into ``n_bins`` equal-probability bins 

729 (:func:`gwtransport.gamma.bins`) and averaged by probability mass via 

730 :func:`infiltration_to_extraction`. 

731 

732 Parameters 

733 ---------- 

734 screen_height : float 

735 Known well-screen height ``H`` [m] (the fixed total; the mean streamtube velocity is set by it). 

736 velocity_cv : float 

737 Coefficient of variation of the layer velocity (the macrodispersion strength). ``0`` is a 

738 homogeneous screen (a single streamtube, sharp breakthrough); typically ``< 1`` -- larger values 

739 give a heavy slow-velocity tail. 

740 n_bins : int, optional 

741 Number of equal-probability velocity bins. Default 100. 

742 cin, flow, tedges, cout_tedges, porosity, well_radius, longitudinal_dispersivity 

743 As in :func:`infiltration_to_extraction`. 

744 molecular_diffusivity, retardation_factor, background, regional_flux, n_modes, n_quad 

745 As in :func:`infiltration_to_extraction`. 

746 

747 Returns 

748 ------- 

749 ndarray, shape (n,) 

750 Extracted flux concentration; NaN on injection / rest bins. 

751 """ 

752 pore_heights, weights = _velocity_gamma_streamtubes(screen_height, velocity_cv, n_bins) 

753 return infiltration_to_extraction( 

754 cin=cin, 

755 flow=flow, 

756 tedges=tedges, 

757 cout_tedges=cout_tedges, 

758 pore_heights=pore_heights, 

759 porosity=porosity, 

760 well_radius=well_radius, 

761 longitudinal_dispersivity=longitudinal_dispersivity, 

762 molecular_diffusivity=molecular_diffusivity, 

763 retardation_factor=retardation_factor, 

764 weights=weights, 

765 background=background, 

766 regional_flux=regional_flux, 

767 n_modes=n_modes, 

768 n_quad=n_quad, 

769 ) 

770 

771 

772def gamma_extraction_to_infiltration( 

773 *, 

774 cout: npt.ArrayLike, 

775 flow: npt.ArrayLike, 

776 tedges: pd.DatetimeIndex, 

777 cout_tedges: pd.DatetimeIndex, 

778 porosity: float, 

779 well_radius: float, 

780 longitudinal_dispersivity: float, 

781 screen_height: float, 

782 velocity_cv: float, 

783 n_bins: int = 100, 

784 molecular_diffusivity: float = 0.0, 

785 retardation_factor: float = 1.0, 

786 background: float = 0.0, 

787 regional_flux: float = 0.0, 

788 n_modes: int | None = None, 

789 regularization_strength: float = 1e-10, 

790 n_quad: int = 240, 

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

792 """Inverse of :func:`gamma_infiltration_to_extraction` (gamma-distributed screen velocity). 

793 

794 Returns 

795 ------- 

796 ndarray, shape (n,) 

797 Recovered injected concentration; NaN on extraction / rest bins. 

798 """ 

799 pore_heights, weights = _velocity_gamma_streamtubes(screen_height, velocity_cv, n_bins) 

800 return extraction_to_infiltration( 

801 cout=cout, 

802 flow=flow, 

803 tedges=tedges, 

804 cout_tedges=cout_tedges, 

805 pore_heights=pore_heights, 

806 porosity=porosity, 

807 well_radius=well_radius, 

808 longitudinal_dispersivity=longitudinal_dispersivity, 

809 molecular_diffusivity=molecular_diffusivity, 

810 retardation_factor=retardation_factor, 

811 weights=weights, 

812 background=background, 

813 regional_flux=regional_flux, 

814 n_modes=n_modes, 

815 regularization_strength=regularization_strength, 

816 n_quad=n_quad, 

817 ) 

818 

819 

820def _velocity_gamma_streamtubes( 

821 screen_height: float, velocity_cv: float, n_bins: int 

822) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]: 

823 """Streamtube pore heights and weights for a gamma-distributed screen velocity (mean velocity <-> H). 

824 

825 The layer velocity ratio ``rho`` is gamma(mean 1, std ``velocity_cv``); the effective pore height is 

826 ``screen_height / rho`` (velocity ~ 1/height), so the mean velocity corresponds to height ``H``. 

827 

828 Returns 

829 ------- 

830 pore_heights : ndarray 

831 Effective streamtube pore heights ``screen_height / rho`` per velocity bin. 

832 weights : ndarray 

833 Probability mass per velocity bin. 

834 

835 Raises 

836 ------ 

837 ValueError 

838 If ``screen_height`` is not positive or ``velocity_cv`` is negative. 

839 """ 

840 if screen_height <= 0.0: 

841 msg = "screen_height must be positive" 

842 raise ValueError(msg) 

843 if velocity_cv < 0.0: 

844 msg = "velocity_cv must be non-negative" 

845 raise ValueError(msg) 

846 if velocity_cv == 0.0: 

847 # A degenerate gamma (std 0) is not a valid distribution; velocity_cv = 0 is the homogeneous 

848 # screen -- a single streamtube at the mean velocity (pore height H), matching the doc. 

849 return np.array([screen_height]), np.array([1.0]) 

850 bins = gamma.bins(mean=1.0, std=velocity_cv, n_bins=n_bins) 

851 return screen_height / bins["expected_values"], bins["probability_mass"]