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

157 statements  

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

1r"""Reused-propagator-matrix acceleration of the multi-cycle radial advection-dispersion engine. 

2 

3For a signed-flow schedule with ``K`` flow reversals, the per-reversal grid-free composition performs one 

4de Hoog inversion of the interior resolvent per intermediate reversal to hand the resident field across 

5the reversal -- the ``O(n_quad^2)`` per-reversal 

6cost. That hand-off is a *linear* operator on the resident field: the propagated field is 

7``f_out(r_i) = L^{-1}_s[ sum_j Ghat(r_i, r_j; s) w(r_j) f(r_j) ](tau)``, i.e. ``f_out = P @ f`` with the 

8propagator matrix 

9 

10``P_{dir,tau}[i, j] = L^{-1}_s[ Ghat(r_i, r_j; s) w_dir(r_j) ](tau)``. 

11 

12For a schedule with repeated phase volumes (periodic ASR / SWIW) the same ``(direction, tau)`` recurs every 

13cycle, so ``P`` is identical across cycles. This module builds each distinct ``P`` *once* (one batched de 

14Hoog inversion over all ``n_quad^2`` source/output entries, reusing the exact Airy interior Green's 

15function) and reuses it as a bounded matrix-multiply at every reversal -- the special-function + de Hoog 

16cost becomes ``O(number of distinct phase volumes)`` instead of ``O(K)``. 

17 

18The matrix is assembled on the Bromwich contour (``Re s > 0``), where the Airy exponent is growing-real 

19and tames the divergent injection gauge ``e^{+r/alpha_L}`` (carried in log space by 

20:func:`gwtransport._radial_asr_kernels.assemble_airy_resolvent`), so ``P`` is well-conditioned at any 

21Peclet (``|P| ~ O(1)``, the same bounded physical operator the per-reversal engine applies). ``P @ f`` 

22equals the per-reversal engine's ``_propagate(f)`` to the de Hoog floor; the only difference is the de Hoog 

23Pade acceleration's mild nonlinearity (``invert-then-sum`` vs ``sum-then-invert``), which vanishes as the 

24de Hoog ``n_terms``/``tol`` tighten. 

25 

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

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

28""" 

29 

30import numpy as np 

31import numpy.typing as npt 

32from scipy.special import ive, kve 

33 

34from gwtransport._radial_asr_compose import _DEHOOG_TERMS, _SCALE_MARGIN, _fr_step_response 

35from gwtransport._radial_asr_dehoog import dehoog_inverse 

36from gwtransport._radial_asr_kernels import ( 

37 _integrate_logderiv, 

38 _resolvent_airy_pieces, 

39 assemble_airy_resolvent, 

40) 

41 

42_INJECTION = "injection" 

43 

44 

45def _phase_slices(flow: npt.NDArray[np.floating]) -> list[tuple[int, slice]]: 

46 """Group the schedule into maximal one-signed phases. 

47 

48 Returns 

49 ------- 

50 list of (sign, slice) 

51 ``sign`` in ``{+1, -1, 0}`` (injection / extraction / rest) and the contiguous bin slice. 

52 """ 

53 signs = np.sign(flow).astype(int) 

54 edges = np.flatnonzero(np.diff(signs) != 0) + 1 

55 starts = np.concatenate(([0], edges)) 

56 stops = np.concatenate((edges, [len(flow)])) 

57 return [(int(signs[a]), slice(a, b)) for a, b in zip(starts, stops, strict=True)] 

58 

59 

60def _field_grid( 

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

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

63 c_geo: float, 

64 r_w: float, 

65 alpha_l: float, 

66 molecular_diffusivity: float, 

67 n_quad: int, 

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

69 """Radial Gauss-Legendre quadrature grid spanning the plume front plus its dispersive tail. 

70 

71 The grid is kept **tight**: it covers the advective front radius ``r_front`` (where the plume 

72 volume ``V(r) = peak net injected volume``) plus a margin of breakthrough widths (radial std 

73 ``~ sqrt(alpha_L r_front)``) and a molecular reach, and no further. An over-extended grid would 

74 push the Peclet so high that the divergent (injection) interior Green's function, which grows as 

75 ``e^{+r/alpha_L}`` before its ``e^{-r/alpha_L}`` Sturm-Liouville weight tames the product, overflows 

76 double precision; the resident profile is ``~0`` beyond the tail anyway (verified by mass 

77 conservation). Retardation rescales the clock, not the radius, so it does not enter ``r_max``. 

78 

79 Returns 

80 ------- 

81 r_nodes : ndarray 

82 Radial nodes (m), shape ``(n_quad,)``. 

83 v_nodes : ndarray 

84 Volume coordinate ``V(r) = c_geo (r^2 - r_w^2)`` at the nodes. 

85 dr_weights : ndarray 

86 Gauss-Legendre weights in ``r`` (so ``int g dr ~ sum g(r_k) dr_weights_k``). 

87 """ 

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

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

90 r_front = np.sqrt(r_w**2 + peak_volume / c_geo) # advective plume-front radius 

91 total_time = float(np.sum(dt_days)) 

92 reach = 12.0 * np.sqrt(alpha_l * r_front + alpha_l**2) + 6.0 * np.sqrt(molecular_diffusivity * total_time) 

93 r_max = r_front + reach + r_w 

94 nodes, weights = np.polynomial.legendre.leggauss(n_quad) 

95 r_nodes = 0.5 * (r_max - r_w) * (nodes + 1.0) + r_w 

96 dr_weights = 0.5 * (r_max - r_w) * weights 

97 v_nodes = c_geo * (r_nodes**2 - r_w**2) 

98 return r_nodes, v_nodes, dr_weights 

99 

100 

101def _airy_propagator_matrix( 

102 direction: str, 

103 tau: float, 

104 r_nodes: npt.NDArray[np.floating], 

105 dr_weights: npt.NDArray[np.floating], 

106 *, 

107 r_w: float, 

108 alpha_l: float, 

109 c_geo: float, 

110 flow_scale: float, 

111 n_terms: int, 

112 tol: float, 

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

114 r"""Field-propagator matrix ``P[i, j]`` for one constant-Q phase (``D_m = 0`` Airy branch). 

115 

116 ``f_out = P @ f`` with ``P[i, j] = L^{-1}_s[ Ghat(r_i, r_j; s) w(r_j) ](tau)`` -- the same Airy interior 

117 resolvent and flushed-volume clock as the per-reversal ``_propagate``, assembled for 

118 every output/source pair and inverted in a single batched de Hoog pass instead of contracting against a 

119 specific field. The Airy pieces are evaluated once on the grid per de Hoog node (as in ``_propagate``) 

120 and every output row is assembled by prefix selection (``O(n_quad)`` Airy evaluations). 

121 

122 The Sturm-Liouville source weight is ``w(r') = (2 c_geo r'/alpha_L) e^{-gauge_sign r'/alpha_L} dr'`` 

123 (``gauge_sign = +1`` injection / ``-1`` extraction); the flushed-volume clock is set by ``s = 

124 flow_scale * p`` with ``a0 = flow_scale/(2 c_geo)`` so ``beta = 2 c_geo p/alpha_L`` is flow-magnitude 

125 independent (the autonomy of the S-clock). 

126 

127 Returns 

128 ------- 

129 ndarray 

130 Propagator matrix ``P``, shape ``(n_quad, n_quad)``. 

131 """ 

132 a0 = flow_scale / (2.0 * c_geo) 

133 gauge_sign = 1.0 if direction == _INJECTION else -1.0 

134 n = r_nodes.size 

135 # Split the Sturm-Liouville source weight (2 c_geo r'/alpha_L) e^{-gauge_sign r'/alpha_L} dr' into its 

136 # non-exponential prefactor and its LOG exponent: the exponent is folded into assemble_airy_resolvent 

137 # so the divergent gauge cancels before np.exp (overflow-safe at any Peclet). Applying the exponential 

138 # weight afterwards would overflow -- the injection assemble exponent and the extraction weight each blow 

139 # up past r/alpha_L ~ 700 and meet as Inf * 0 = NaN. 

140 sl_prefactor = (2.0 * c_geo * r_nodes / alpha_l) * dr_weights 

141 sl_log = (-gauge_sign * r_nodes / alpha_l)[None, :] 

142 mu = c_geo * ((float(r_nodes.max()) + alpha_l) ** 2 + alpha_l**2 - r_w**2) 

143 scaling = _SCALE_MARGIN * max(mu, tau) 

144 below = r_nodes[None, :] < r_nodes[:, None] # below[i, j] = r_j < r_i 

145 

146 def f_hat(p: npt.NDArray[np.complexfloating]) -> npt.NDArray[np.complexfloating]: 

147 s = (flow_scale * p).reshape(-1, 1) 

148 grid_p = _resolvent_airy_pieces(s, r_nodes.reshape(1, -1), alpha_l, a0, gauge_sign) 

149 piece_w = _resolvent_airy_pieces(s, np.array([[r_w]]), alpha_l, a0, gauge_sign) 

150 ghat = np.empty((p.size, n, n), dtype=complex) 

151 for i in range(n): 

152 # r_< / r_> selection at output i (the SL kernel is symmetric; radii enter only via r_i + r_j). 

153 mask = below[i][None, :] 

154 piece_a = {f: np.where(mask, grid_p[f], grid_p[f][:, i : i + 1]) for f in grid_p} 

155 piece_b = {f: np.where(mask, grid_p[f][:, i : i + 1], grid_p[f]) for f in grid_p} 

156 r_sum = (r_nodes[i] + r_nodes)[None, :] 

157 ghat[:, i, :] = assemble_airy_resolvent( 

158 piece_a, piece_b, piece_w, r_sum, alpha_l, gauge_sign, source_log_weight=sl_log 

159 ) 

160 ghat *= sl_prefactor[None, None, :] # non-exponential SL prefactor (the gauge is already folded in) 

161 return ghat 

162 

163 return dehoog_inverse(f_hat=f_hat, t=tau, n_terms=n_terms, scaling=scaling, tol=tol) 

164 

165 

166def _riccati_propagator_matrix( 

167 direction: str, 

168 tau: float, 

169 r_nodes: npt.NDArray[np.floating], 

170 dr_weights: npt.NDArray[np.floating], 

171 *, 

172 r_w: float, 

173 alpha_l: float, 

174 c_geo: float, 

175 flow_scale: float, 

176 molecular_diffusivity: float, 

177 retardation_factor: float, 

178 n_terms: int, 

179 tol: float, 

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

181 r"""Field-propagator matrix ``P[i, j]`` for one constant-Q phase (``D_m > 0`` Riccati branch). 

182 

183 The matrix form of the per-reversal ``_propagate_diffusive``: instead of contracting 

184 the interior resolvent against a specific field by the prefix/suffix cumsums of 

185 :func:`gwtransport._radial_asr_kernels.resolvent_riccati`, the full Sturm-Liouville Green's function is 

186 assembled as the triangular outer product of the field-independent log-derivative integrals, 

187 

188 ``Ghat(r_i, r_j) w_j = -[exp(J_-(r_i) + J_+(r_j) + LG_j) if j <= i else exp(J_+(r_i) + J_-(r_j) + LG_j)] 

189 / (L_-(r_w) - L_+(r_w)) * r_j dr_j``, 

190 

191 with ``J_-`` / ``J_+`` the inward (decaying) and outward (well-regular) running integrals of the 

192 log-derivative (:func:`_integrate_logderiv`) and ``LG_j = b ln(G_j/G_w) - ln G_j`` the divergent gauge 

193 carried in log space (``b = 1 - sigma_A A_0/D_m``). Wall-clock time ``tau`` (the volume clock is not 

194 autonomous when ``D_m > 0``); retardation rescales ``A_0 -> A_0/R``, ``D_m -> D_m/R``. 

195 

196 Returns 

197 ------- 

198 ndarray 

199 Propagator matrix ``P``, shape ``(n_quad, n_quad)``. 

200 """ 

201 a0_eff = (flow_scale / (2.0 * c_geo)) / retardation_factor 

202 d_m_eff = molecular_diffusivity / retardation_factor 

203 sigma_a = 1 if direction == _INJECTION else -1 

204 n = r_nodes.size 

205 mu_t = c_geo * ((float(r_nodes.max()) + alpha_l) ** 2 + alpha_l**2 - r_w**2) / flow_scale 

206 scaling = _SCALE_MARGIN * max(mu_t, tau) 

207 b = 1.0 - sigma_a * a0_eff / d_m_eff 

208 rad = np.concatenate(([r_w], r_nodes)) 

209 g_nodes = alpha_l * a0_eff + d_m_eff * r_nodes 

210 g_w = alpha_l * a0_eff + d_m_eff * r_w 

211 lg = b * np.log(g_nodes / g_w) - np.log(g_nodes) # bounded divergent gauge in log space 

212 lp_w = a0_eff / (alpha_l * a0_eff + d_m_eff * r_w) if sigma_a > 0 else 0.0 

213 lower = np.tril(np.ones((n, n)))[None] # lower[i, j] = (j <= i) 

214 weight = (r_nodes * dr_weights)[None, None, :] 

215 

216 def f_hat(s: npt.NDArray[np.complexfloating]) -> npt.NDArray[np.complexfloating]: 

217 ld_m, jj_m = _integrate_logderiv(s, rad, r_w, alpha_l, a0_eff, d_m_eff, sigma_a, "decaying") 

218 lm_w = ld_m[:, 0] # L_-(r_w) 

219 jm = jj_m[:, 1:] # int_{r_w}^{r_i} L_- (n_s, n) 

220 _, jp = _integrate_logderiv(s, r_nodes, r_w, alpha_l, a0_eff, d_m_eff, sigma_a, "regular") # int L_+ 

221 denom = (lm_w - lp_w)[:, None, None] 

222 em_i = np.exp(jm)[:, :, None] 

223 ep_jl = np.exp(jp + lg[None, :])[:, None, :] 

224 ep_i = np.exp(jp)[:, :, None] 

225 em_jl = np.exp(jm + lg[None, :])[:, None, :] 

226 return -np.where(lower > 0, em_i * ep_jl, ep_i * em_jl) / denom * weight 

227 

228 return dehoog_inverse(f_hat=f_hat, t=tau, n_terms=n_terms, scaling=scaling, tol=tol) 

229 

230 

231def _rest_propagator_matrix( 

232 tau: float, 

233 r_nodes: npt.NDArray[np.floating], 

234 dr_weights: npt.NDArray[np.floating], 

235 *, 

236 r_w: float, 

237 d_m_eff: float, 

238 n_terms: int, 

239 tol: float, 

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

241 r"""Field-propagator matrix ``P[i, j]`` for a rest (``Q = 0``) phase -- pure molecular diffusion. 

242 

243 The matrix form of the per-reversal ``_propagate_rest``: the order-0 modified Bessel 

244 interior resolvent (:func:`gwtransport._radial_asr_kernels.rest_resolvent`) with the Sturm-Liouville rest 

245 measure ``w(r') dr' = (r'/D_m) dr'``, on the wall-clock clock. Retardation enters as ``d_m_eff = D_m/R``. 

246 

247 **Resolution limit.** Molecular diffusion over the rest spreads mass by the diffusion length 

248 ``sqrt(D_m tau)``. Once that length drops below half the coarsest node gap the Gauss-Legendre grid can no 

249 longer resolve the near-delta diffusive Green's function: the quadratured resolvent stops conserving mass 

250 (``dv^T P`` amplifies above ``1`` -- a maximum-principle violation, ``cout > cin``). In that under-resolved 

251 regime the physical propagator *is* the identity to grid accuracy (diffusion has not crossed a cell), so 

252 the identity is returned -- mass-exact and equal to the ``D_m = 0`` echo, the correct small-``D_m`` limit. 

253 

254 Returns 

255 ------- 

256 ndarray 

257 Propagator matrix ``P``, shape ``(n_quad, n_quad)``. 

258 """ 

259 if np.sqrt(d_m_eff * tau) < 0.5 * float(np.diff(r_nodes).max()): 

260 return np.eye(r_nodes.size) 

261 scaling = _SCALE_MARGIN * tau 

262 weight = (r_nodes / d_m_eff * dr_weights)[None, None, :] 

263 # r_nodes ascend, so r_< = r_nodes[min(i, j)], r_> = r_nodes[max(i, j)]: the order-0 scaled Bessels are 

264 # evaluated once on the grid per s-node and gathered by these index maps instead of O(n_quad) times. 

265 idx = np.arange(r_nodes.size) 

266 lt = np.minimum(idx[:, None], idx[None, :]) 

267 gt = np.maximum(idx[:, None], idx[None, :]) 

268 

269 def f_hat(s: npt.NDArray[np.complexfloating]) -> npt.NDArray[np.complexfloating]: 

270 kappa = np.sqrt(np.asarray(s, dtype=complex).reshape(-1, 1) / d_m_eff) # (n_s, 1), Re(kappa) > 0 

271 zr = kappa * r_nodes[None, :] # (n_s, n): kappa r' on the grid, Bessels evaluated once here 

272 iv0, kv0 = ive(0, zr), kve(0, zr) 

273 z_w = kappa * r_w 

274 ratio_w = ive(1, z_w) / kve(1, z_w) # (n_s, 1) 

275 z_lt, z_gt = zr[:, lt], zr[:, gt] # (n_s, n, n) 

276 # Ghat = [I_0(z_<) + (I_1(z_w)/K_1(z_w)) K_0(z_<)] K_0(z_>) with scaled Bessels (see rest_resolvent): 

277 # the scaling exponents difference to <= 0 so the growing I_0 never overflows at high kappa r. 

278 ghat = iv0[:, lt] * kv0[:, gt] * np.exp(np.abs(z_lt.real) - z_gt) 

279 outer = ratio_w[:, :, None] * kv0[:, lt] 

280 outer *= kv0[:, gt] 

281 outer *= np.exp((np.abs(z_w.real) + z_w)[:, :, None] - z_lt - z_gt) 

282 ghat += outer 

283 ghat *= weight 

284 return ghat 

285 

286 # The de Hoog underflow guard zeros far-field cells whose transform decayed to the double-precision floor 

287 # (physical ~0); a cell whose transform genuinely OVERFLOWED stays NaN so a real breakdown cannot read as 

288 # a physical zero. No extra masking here (that would zero overflowed cells too, reversing that contract). 

289 return dehoog_inverse(f_hat=f_hat, t=tau, n_terms=n_terms, scaling=scaling, tol=tol) 

290 

291 

292def _fr_source_matrix( 

293 v_nodes: npt.NDArray[np.floating], edges: npt.NDArray[np.floating], **readout: float 

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

295 r"""Injection-source operator ``M[k, j]`` such that ``field_k += sum_j M[k, j] cin_dev_j`` over one phase. 

296 

297 Matrix form of the per-reversal ``_fr_profile``: 

298 ``M[k, j] = G1_FR(S_inj - sigma_j; V'_k) - G1_FR(S_inj - sigma_{j+1}; V'_k)`` (the same FR step response, 

299 KB Sec. 10a), built once per distinct injection phase and reused across recurring cycles. 

300 

301 Returns 

302 ------- 

303 ndarray 

304 Source operator, shape ``(n_quad, n_inj_bins)``. 

305 """ 

306 # edges are phase-relative (edges[0] == 0 by caller construction); corners descend to 0 at the phase end 

307 inj_corners = edges[-1] - edges # descending within-phase cumulative volume corners, last is 0 

308 g = np.array([_fr_step_response(v, inj_corners, **readout) for v in v_nodes]) # (n_quad, n_bins + 1) 

309 return g[:, :-1] - g[:, 1:] 

310 

311 

312def _cout_readout_matrix( 

313 v_nodes: npt.NDArray[np.floating], 

314 dv_weights: npt.NDArray[np.floating], 

315 edges: npt.NDArray[np.floating], 

316 **readout: float, 

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

318 r"""Cout-readout operator ``M[i, k]`` such that ``cout_i = sum_k M[i, k] field_k`` over one phase. 

319 

320 Matrix form of the per-reversal ``_cout_phase``: 

321 ``M[i, k] = R dv_k [G1_FR(T_{i+1}; V'_k) - G1_FR(T_i; V'_k)] / dT_i`` (the KB Sec. 7 duality arrival 

322 kernel, same FR step response; the ``R`` amplitude mobilises the sorbed companion), built once per 

323 distinct extraction phase and reused. 

324 

325 Returns 

326 ------- 

327 ndarray 

328 Readout operator, shape ``(n_ext_bins, n_quad)``. 

329 """ 

330 # edges are phase-relative (edges[0] == 0 by caller construction) 

331 dt = np.diff(edges) 

332 g = np.array([_fr_step_response(v, edges, **readout) for v in v_nodes]) # (n_quad, n_bins + 1) 

333 diffs = (g[:, 1:] - g[:, :-1]) / dt[None, :] 

334 return (readout["retardation_factor"] * dv_weights[:, None] * diffs).T 

335 

336 

337def cout_deviation( 

338 *, 

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

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

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

342 c_geo: float, 

343 r_w: float, 

344 alpha_l: float, 

345 molecular_diffusivity: float = 0.0, 

346 retardation_factor: float = 1.0, 

347 n_quad: int = 240, 

348 n_terms: int = _DEHOOG_TERMS, 

349 tol: float = 1e-9, 

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

351 r"""Multi-cycle extracted-flux deviation via reused per-phase propagator matrices (any ``D_m``). 

352 

353 Drop-in for the per-reversal ``gridfree_cout_deviation`` reference: every per-phase linear 

354 operator -- the across-reversal field hand-off (``field = P @ field``), the injection source 

355 (:func:`_fr_source_matrix`, ``field += M_fr @ cin``), and the cout readout (:func:`_cout_readout_matrix`, 

356 ``cout = M_cout @ field``) -- is built once per distinct phase and reused as a matrix-multiply whenever 

357 that phase recurs, instead of recomputing the de Hoog inversion / FR step response each cycle. The 

358 propagator hand-off uses the Airy branch (:func:`_airy_propagator_matrix`, 

359 flushed-volume clock) for ``D_m = 0``, the Riccati branch (:func:`_riccati_propagator_matrix`, 

360 wall-clock) for ``D_m > 0``, and the Bessel rest branch (:func:`_rest_propagator_matrix`) for ``Q = 0`` 

361 phases when ``D_m > 0`` -- so the special-function + de Hoog cost is ``O(number of distinct phases)`` 

362 rather than ``O(K)`` reversals. Equal to the per-reversal grid-free engine to the de Hoog floor at 

363 matched ``n_terms``/``tol``; tighten both for machine precision (the de Hoog Pade nonlinearity, dominant 

364 at low Peclet / high retardation). 

365 

366 Parameters 

367 ---------- 

368 cin_deviation : ndarray, shape (n,) or (n, k) 

369 Injected concentration deviation per bin (used on injection bins, ``flow > 0``). A trailing column 

370 axis is transported through one engine pass (the per-phase matrices are cin-independent), used by 

371 the reverse operator build to apply all unit-pulse columns at once. 

372 flow : ndarray, shape (n,) 

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

374 dt_days : ndarray, shape (n,) 

375 Bin widths [day]. 

376 c_geo : float 

377 Geometry constant ``pi b n`` (``V = c_geo (r^2 - r_w^2)``). 

378 r_w : float 

379 Well radius [m]. 

380 alpha_l : float 

381 Longitudinal dispersivity [m]. 

382 molecular_diffusivity : float, optional 

383 Molecular diffusivity [m^2/day]. ``0`` selects the Airy branch (flushed-volume clock); ``> 0`` the 

384 Riccati log-derivative pumping branch and the order-0 Bessel rest branch (both wall-clock). Default 0. 

385 retardation_factor : float, optional 

386 Linear retardation ``R >= 1`` (rescales the flushed-volume clock). Default 1. 

387 n_quad : int, optional 

388 Number of radial Gauss-Legendre nodes. Default 240. 

389 n_terms : int, optional 

390 de Hoog series length for the matrix build. Default ``_DEHOOG_TERMS``. 

391 tol : float, optional 

392 de Hoog target accuracy for the matrix build. Default ``1e-9``. Tighten (e.g. 

393 ``tol=1e-13, n_terms=64``) for machine precision at low Peclet / high retardation. 

394 

395 Returns 

396 ------- 

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

398 Extracted-flux deviation per bin (matching ``cin_deviation``); ``NaN`` on injection / rest bins. 

399 """ 

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

401 cin_deviation = np.asarray(cin_deviation, dtype=float) 

402 batch = cin_deviation.shape[1:] # () for a single series, (k,) for a column batch (reverse operator build) 

403 flushed = np.abs(flow) * dt_days 

404 # D_m = 0 keeps the advective grid; D_m > 0 widens it to the molecular reach (the diffusive pumping and 

405 # rest kernels both spread beyond the advective front), matching gridfree_cout_deviation. 

406 r_nodes, v_nodes, dr_weights = _field_grid(flow, dt_days, c_geo, r_w, alpha_l, molecular_diffusivity, n_quad) 

407 dv_weights = 2.0 * c_geo * r_nodes * dr_weights 

408 

409 phases = _phase_slices(flow) 

410 matrices: dict[tuple, npt.NDArray[np.floating]] = {} 

411 

412 def propagate(field, direction, flow_scale, phase_volume, phase_time): 

413 if molecular_diffusivity == 0.0: # Airy: flushed-volume clock, retardation rescales the clock 

414 tau = phase_volume / retardation_factor 

415 key = ("airy", direction, round(tau, 9), round(flow_scale, 9)) 

416 if key not in matrices: 

417 matrices[key] = _airy_propagator_matrix( 

418 direction, 

419 tau, 

420 r_nodes, 

421 dr_weights, 

422 r_w=r_w, 

423 alpha_l=alpha_l, 

424 c_geo=c_geo, 

425 flow_scale=flow_scale, 

426 n_terms=n_terms, 

427 tol=tol, 

428 ) 

429 return matrices[key] @ field 

430 key = ("riccati", direction, round(phase_time, 9), round(flow_scale, 9)) # D_m > 0: wall-clock time 

431 if key not in matrices: 

432 matrices[key] = _riccati_propagator_matrix( 

433 direction, 

434 phase_time, 

435 r_nodes, 

436 dr_weights, 

437 r_w=r_w, 

438 alpha_l=alpha_l, 

439 c_geo=c_geo, 

440 flow_scale=flow_scale, 

441 molecular_diffusivity=molecular_diffusivity, 

442 retardation_factor=retardation_factor, 

443 n_terms=n_terms, 

444 tol=tol, 

445 ) 

446 return matrices[key] @ field 

447 

448 field = np.zeros((n_quad, *batch)) 

449 cout = np.full((len(flow), *batch), np.nan) 

450 for idx, (sign, sl) in enumerate(phases): 

451 phase_volume = float(flushed[sl].sum()) 

452 if phase_volume == 0.0: # rest: pure molecular diffusion on the wall-clock clock (D_m = 0 -> identity) 

453 if molecular_diffusivity > 0.0: 

454 tau = float(np.sum(dt_days[sl])) 

455 key = ("rest", round(tau, 9)) 

456 if key not in matrices: 

457 matrices[key] = _rest_propagator_matrix( 

458 tau, 

459 r_nodes, 

460 dr_weights, 

461 r_w=r_w, 

462 d_m_eff=molecular_diffusivity / retardation_factor, 

463 n_terms=n_terms, 

464 tol=tol, 

465 ) 

466 field = matrices[key] @ field 

467 continue 

468 flow_scale = float(np.mean(np.abs(flow[sl]))) 

469 phase_time = float(np.sum(dt_days[sl])) 

470 edges = np.concatenate(([0.0], np.cumsum(flushed[sl]))) 

471 readout = { 

472 "c_geo": c_geo, 

473 "r_w": r_w, 

474 "alpha_l": alpha_l, 

475 "retardation_factor": retardation_factor, 

476 "flow_scale": flow_scale, 

477 "molecular_diffusivity": molecular_diffusivity, 

478 } 

479 # The injection-source and cout-readout operators are also linear and phase-structure-determined, so 

480 # they are matrix-cached and reused across recurring cycles too (retardation R and D_m are fixed for 

481 # the whole call, so the cache key is the within-phase volume edges + the flow scale). 

482 ekey = (tuple(np.round(edges, 9)), round(flow_scale, 9)) 

483 if sign > 0: # injection: propagate the buffer, then add the new injected resident profile 

484 field = propagate(field, _INJECTION, flow_scale, phase_volume, phase_time) 

485 mkey = ("fr", *ekey) 

486 if mkey not in matrices: 

487 matrices[mkey] = _fr_source_matrix(v_nodes, edges, **readout) 

488 field += matrices[mkey] @ cin_deviation[sl] 

489 else: # extraction: read cout, then propagate the residual if more pumping follows 

490 mkey = ("cout", *ekey) 

491 if mkey not in matrices: 

492 matrices[mkey] = _cout_readout_matrix(v_nodes, dv_weights, edges, **readout) 

493 cout[sl] = matrices[mkey] @ field 

494 if idx != len(phases) - 1: 

495 field = propagate(field, "extraction", flow_scale, phase_volume, phase_time) 

496 return cout