Coverage for src/gwtransport/_radial_asr_drift_kernels.py: 97%
291 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 21:17 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 21:17 +0000
1r"""Block (azimuthal-mode) Laplace kernels for single-well ASR with steady regional drift.
3Generalizes the radial per-phase kernels (:mod:`gwtransport._radial_asr_kernels`) to a steady uniform
4regional drift ``v_d = U/n`` superimposed on the radial well field ``A_0/r``. The 2-D
5advection-dispersion generator ``O[c] = v.grad c - div(D grad c)`` (rank-1 Scheidegger tensor
6``D = alpha_L (v outer v)/|v| + D_m I``, transverse mechanical dispersion neglected) is expanded in
7azimuthal Fourier modes ``c(r, theta) = sum_m c_m(r) e^{i m theta}``. The radial well flow acts within
8each mode (``m = 0`` is the radial engine); the drift couples mode ``m`` to its neighbours, giving a
9coupled second-order block ODE
11 A(r) c'' + B(r) c' + (S0(r) + R s I) c = 0, c = (c_{-M}, ..., c_M)^T,
13with the ``s``-independent coefficient matrices (``M[f]`` the azimuthal Toeplitz coupling matrix whose
14``(m, m')`` entry is the ``(m - m')``-th Fourier coefficient of ``f(r, .)``; ``Dm = diag(modes)``)
16 A = -M[D_rr]
17 B = M[v_r - D_rr/r - d_r D_rr - (1/r) d_theta D_rtheta] + M[-2 D_rtheta/r] (i Dm)
18 S0 = M[v_theta/r - (d_r D_rtheta)/r - (1/r^2) d_theta D_thth] (i Dm) + M[-D_thth/r^2] (-Dm^2),
20derived by collecting ``c''``, ``c'`` and ``c`` (the ``d_r d_theta c`` cross term folds into ``B`` via
21``d_theta -> i m``; the ``d_theta^2`` term into ``S0`` via ``-m^2``). At ``v_d = 0`` every ``M[.]`` is
22diagonal and the system decouples into the radial per-mode operator, so the ``m = 0`` block is exactly
23the radial engine's ODE ``G c'' + (D_m - A_0) c' - R s r c = 0`` (``G = alpha_L A_0 + D_m r``).
25This file is part of gwtransport which is released under AGPL-3.0 license.
26See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details.
27"""
29from collections.abc import Callable
31import numpy as np
32import numpy.typing as npt
33from scipy.integrate import solve_ivp
34from scipy.interpolate import BarycentricInterpolator
35from scipy.special import airye
37from gwtransport._radial_asr_dehoog import dehoog_inverse
38from gwtransport._radial_asr_reuse import _phase_slices # generic signed-schedule grouping (shared)
40# Per-phase orientation. Injection (Q > 0) is the divergent operator (Robin/flux well BC); extraction
41# (Q < 0) the convergent operator (Danckwerts/Neumann well BC). The signed well strength A_0 carries the
42# sign, so the azimuthal drift coupling uses the signed eps = v_d r / A_0 per phase automatically.
43_INJECTION = "injection"
45# Riccati integration tolerances (matched to the scalar log-derivative kernel) and the drift-specific
46# outer-boundary policy: the recessive initial condition is set at r_far and washed inward; r_far is
47# floored well past the field but hard-capped below the stagnation radius r_s = |A_0|/v_d, where the
48# coordinate finite-escape of the matrix Riccati would otherwise blow up (the plan's r_far <= 0.6 r_s).
49# The slow-drift envelope is honest: the SIGNIFICANT plume (front + _PLUME_WIDTHS breakthrough widths,
50# where the resident field has decayed to ~1% of peak) must fit below that cap, else the engine raises
51# rather than silently truncate it. Only the negligible far tail beyond r_far is dropped (the recessive
52# IC washes it out anyway). At v_d = 0 (r_s = inf) the cap is inactive and this is the scalar grid.
53_RICCATI_RTOL = 1e-9
54_RICCATI_ATOL = 1e-10
55_RFAR_FIELD_MULT = 8.0
56_RFAR_DECAY = 44.0
57_RS_FRAC = 0.6
58_GRID_WIDTHS = 12.0
59_PLUME_WIDTHS = 3.0
60# Rest-with-drift kernel: Gauss-Hermite quadrature size for the Gaussian spread, and the honest azimuthal
61# truncation guard -- the relative spectral tail (harmonics M < |m| <= 2M after reprojection) above which
62# the translated plume is declared too eccentric for the kept modes (raise, never silently fold the tail).
63_REST_HERMITE = 20
64_REST_TAIL_MAX = 1e-2
65# Per-call cap on cached per-phase kernel solutions (each entry is O(n_quad n_s (2M+1)^2) complex, ~70 MB
66# at the defaults and ~6x that at the auto-sizing ceiling n_modes=8 -- up to ~2.4 GB at the cap; a periodic
67# schedule needs one entry per pumping direction, so the cap only sheds entries on long aperiodic
68# schedules, where nothing recurs anyway).
69_SOLUTIONS_CACHE_MAX = 8
72def _toeplitz_from_theta(f_theta: npt.NDArray[np.floating], n_modes: int) -> npt.NDArray[np.complexfloating]:
73 r"""Azimuthal coupling matrix ``M[f]`` from samples of ``f(theta)`` on a uniform grid.
75 ``M[f]_{a,b} = c_{m_a - m_b}`` where ``c_k`` is the ``k``-th Fourier coefficient of ``f`` (the
76 multiplication-by-``f`` operator in the ``e^{i m theta}`` basis), ``m`` running ``-n_modes .. n_modes``.
77 The grid must satisfy ``len(f_theta) >= 4 n_modes + 1`` so every needed coefficient ``|k| <= 2 n_modes``
78 is unaliased.
80 Returns
81 -------
82 ndarray of complex, shape (2 n_modes + 1, 2 n_modes + 1)
83 The Toeplitz coupling matrix.
84 """
85 n = f_theta.shape[-1]
86 coeffs = np.fft.fft(f_theta, axis=-1) / n # coeffs[k] = c_k, with c_{-k} at index n-k
87 modes = np.arange(-n_modes, n_modes + 1)
88 diff = modes[:, None] - modes[None, :] # m_a - m_b in [-2M, 2M]
89 return coeffs[..., diff % n]
92def _theta_grid(n_modes: int) -> npt.NDArray[np.floating]:
93 r"""Uniform azimuthal FFT grid sized for the eps-banded harmonics.
95 The needed Fourier coefficients ``|k| <= 2 n_modes`` of the tensor components decay like
96 ``eps^|k|`` and coefficient ``k`` aliases the ``k +- n_theta`` harmonic; ``8 n_modes + 48`` keeps
97 that alias below ~1e-12 across the slow-drift envelope (``eps <= 0.6`` at the grid edge).
99 Returns
100 -------
101 ndarray
102 Grid angles, shape ``(8 n_modes + 48,)``.
103 """
104 nth = 8 * n_modes + 48
105 return np.arange(nth) * (2.0 * np.pi / nth)
108def _tensor_components(
109 rr: npt.NDArray[np.floating], theta: npt.NDArray[np.floating], *, alpha_l: float, a0: float, v_d: float, d_m: float
110) -> tuple[npt.NDArray[np.floating], ...]:
111 r"""Velocity and rank-1 Scheidegger tensor components on an ``(r, theta)`` product grid.
113 The single source of the drift field's tensor sampling, shared by the interior coupling matrices
114 and the well-face operators so the formulas cannot drift apart. ``rr`` broadcasts as ``(n_r, 1)``.
116 Returns
117 -------
118 v_r, v_th, speed, d_rr, d_rt, d_tt : ndarray
119 Components on the product grid, each shape ``(n_r, n_theta)`` (``v_th`` is ``(1, n_theta)``).
120 """
121 cos_t, sin_t = np.cos(theta), np.sin(theta)
122 v_r = a0 / rr + v_d * cos_t[None, :]
123 v_th = -v_d * sin_t[None, :] # theta-only (r-independent); broadcasts against the (n_r, .) factors
124 speed = np.sqrt(v_r**2 + v_th**2)
125 speed = np.where(speed == 0.0, 1.0, speed) # a0=v_d=0 never reached here (alpha_L>0 guard upstream)
126 d_rr = alpha_l * v_r**2 / speed + d_m
127 d_rt = alpha_l * v_r * v_th / speed
128 d_tt = alpha_l * v_th**2 / speed + d_m
129 return v_r, v_th, speed, d_rr, d_rt, d_tt
132def block_coupling_matrices(
133 r: npt.NDArray[np.floating],
134 *,
135 alpha_l: float,
136 a0: float,
137 v_d: float,
138 d_m: float,
139 n_modes: int,
140) -> tuple[npt.NDArray[np.complexfloating], npt.NDArray[np.complexfloating], npt.NDArray[np.complexfloating]]:
141 r"""Coefficient matrices ``A(r), B(r), S0(r)`` of the coupled-mode block ODE.
143 The block ODE is ``A c'' + B c' + (S0 + R s I) c = 0``; ``A, B, S0`` are ``s``-independent (the Laplace
144 node enters only through ``R s I``). ``a0`` is the signed well strength ``A_0 = Q/(2 pi b n)`` (``> 0``
145 injection, ``< 0`` extraction); ``v_d = U/n`` the regional drift. Built by FFT-in-theta of the exact
146 Scheidegger tensor components and their analytic radial derivatives.
148 Parameters
149 ----------
150 r : ndarray
151 Radii (m), shape ``(n_r,)``.
152 alpha_l : float
153 Longitudinal dispersivity (m).
154 a0 : float
155 Signed well strength ``A_0 = Q/(2 pi b n)`` (m^2/day).
156 v_d : float
157 Regional drift seepage velocity ``U/n`` (m/day).
158 d_m : float
159 Molecular diffusivity (m^2/day).
160 n_modes : int
161 Azimuthal truncation ``M`` (keeps modes ``-M .. M``).
163 Returns
164 -------
165 a_mat, b_mat, s0_mat : ndarray of complex, each shape (n_r, 2 n_modes + 1, 2 n_modes + 1)
166 The coefficient matrices at each radius.
167 """
168 r = np.atleast_1d(np.asarray(r, dtype=float))
169 theta = _theta_grid(n_modes)
170 nth = theta.size
171 rr = r[:, None] # (n_r, 1) broadcast against theta
172 v_r, v_th, speed, d_rr, d_rt, d_tt = _tensor_components(rr, theta, alpha_l=alpha_l, a0=a0, v_d=v_d, d_m=d_m)
174 # analytic radial derivatives (v_th is r-independent; d_r v_r = -a0/r^2)
175 dv_r = -a0 / rr**2
176 dspeed = v_r * dv_r / speed
177 d_drr = alpha_l * (2.0 * v_r * dv_r / speed - v_r**2 * dspeed / speed**2)
178 d_drt = alpha_l * (dv_r * v_th / speed - v_r * v_th * dspeed / speed**2)
180 # angular derivatives via spectral differentiation (exact on the grid)
181 kth = np.fft.fftfreq(nth, d=1.0 / nth) # integer wavenumbers
182 dth_drt = np.real(np.fft.ifft(1j * kth * np.fft.fft(d_rt, axis=-1), axis=-1))
183 dth_dtt = np.real(np.fft.ifft(1j * kth * np.fft.fft(d_tt, axis=-1), axis=-1))
185 modes = np.arange(-n_modes, n_modes + 1)
186 i_dm = 1j * modes # i * diag(m) as a row to scale columns
187 m2 = modes**2
189 a_mat = -_toeplitz_from_theta(d_rr, n_modes)
190 b_coeff = v_r - d_rr / rr - d_drr - dth_drt / rr
191 b_mat = (
192 _toeplitz_from_theta(b_coeff, n_modes) + _toeplitz_from_theta(-2.0 * d_rt / rr, n_modes) * i_dm[None, None, :]
193 )
194 s_coeff = v_th / rr - d_drt / rr - dth_dtt / rr**2
195 s0_mat = (
196 _toeplitz_from_theta(s_coeff, n_modes) * i_dm[None, None, :]
197 + _toeplitz_from_theta(-d_tt / rr**2, n_modes) * (-m2)[None, None, :]
198 )
200 # _toeplitz_from_theta always returns (n_r, nm, nm) and r is atleast_1d, so the shape is uniform.
201 return a_mat, b_mat, s0_mat
204def _face_matrices(
205 r_w: float, *, alpha_l: float, a0_signed: float, v_d: float, d_m: float, n_modes: int
206) -> tuple[npt.NDArray[np.complexfloating], npt.NDArray[np.complexfloating], npt.NDArray[np.complexfloating]]:
207 r"""Well-face Toeplitz matrices ``M[v_r]``, ``M[D_rr]``, ``M[D_rtheta]`` at ``r = r_w``.
209 These carry the exact ``theta``-modulated well-face physics: the drift adds ``v_d cos(theta)`` to the
210 face velocity and an ``O(eps_w)`` cross-dispersion ``D_rtheta``, which couple neighbouring modes in
211 the face boundary conditions (Robin flux inlet / Danckwerts) and in the injected-flux source. Though
212 individually ``O(eps_w)``, these couplings enter the drift recovery loss at ``O(eps^2)`` -- the order
213 of the loss itself -- so the face conditions are built exactly.
215 Returns
216 -------
217 m_vr, m_drr, m_drt : ndarray of complex, each shape (2 n_modes + 1, 2 n_modes + 1)
218 Azimuthal coupling matrices of the face velocity and dispersion-tensor components.
219 """
220 v_r, _, _, d_rr, d_rt, _ = _tensor_components(
221 np.array([[r_w]]), _theta_grid(n_modes), alpha_l=alpha_l, a0=a0_signed, v_d=v_d, d_m=d_m
222 )
223 return (
224 _toeplitz_from_theta(v_r, n_modes)[0],
225 _toeplitz_from_theta(d_rr, n_modes)[0],
226 _toeplitz_from_theta(d_rt, n_modes)[0],
227 )
230def field_grid(
231 flow: npt.NDArray[np.floating],
232 dt_days: npt.NDArray[np.floating],
233 c_geo: float,
234 r_w: float,
235 alpha_l: float,
236 v_d: float,
237 a0: float,
238 n_quad: int,
239 d_m: float = 0.0,
240 drift_shift: float = 0.0,
241) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating], float]:
242 r"""Radial Gauss-Legendre grid and recessive-IC radius ``r_far`` for the drift block engine.
244 The grid spans the advective plume front ``r_front = sqrt(r_w^2 + V_peak/c_geo)`` plus a margin of
245 breakthrough widths (radial std ``~ sqrt(alpha_L r_front)``), the molecular reach
246 ``~ sqrt(D_m * total_time)`` (as the scalar engine's grid), and the total rest-phase drift
247 displacement ``drift_shift`` (the rest kernel translates the plume down-gradient; the grid must
248 contain it). For drift, the recessive initial condition at ``r_far`` must stay inside the stagnation
249 radius ``r_s = |A_0|/v_d`` (the matrix Riccati has a coordinate finite-escape as
250 ``eps(r) = v_d r/A_0 -> 1``), so ``r_far`` is hard-capped at ``_RS_FRAC * r_s``. The envelope is
251 enforced **honestly**: the *significant* plume (front + ``_PLUME_WIDTHS`` breakthrough widths + the
252 rest displacement, where the resident field has fallen to ~1% of peak) must fit below that cap --
253 otherwise a ``ValueError`` is raised rather than the plume being silently truncated. The quadrature
254 spans the *significant* plume up to ``r_far`` (a generous ``_GRID_WIDTHS``-width field grid clipped at
255 ``r_far``); only the negligible tail beyond ``r_far`` is dropped, which the recessive IC washes out
256 anyway. At ``v_d = 0`` (``r_s = inf``) the cap is inactive and the grid is the scalar grid. ``a0``
257 should be the **smallest** per-phase ``|A_0|`` (the most restrictive ``r_s``).
259 Returns
260 -------
261 r_nodes : ndarray
262 Radial nodes (m), shape ``(n_quad,)``.
263 dr_weights : ndarray
264 Gauss-Legendre weights in ``r``.
265 r_far : float
266 Outer radius (m) where the recessive (decaying) block initial condition is imposed.
268 Raises
269 ------
270 ValueError
271 If the significant plume reaches the stagnation radius (``r_front + _PLUME_WIDTHS * (width +
272 reach_dm) + drift_shift + r_w > _RS_FRAC * r_s``, with ``width`` carrying the rest kernel's
273 mechanical spread in quadrature and ``reach_dm = sqrt(D_m * total_time)``) -- the drift is too
274 strong for the radial engine's contained-plume assumption.
275 """
276 net_volume = np.concatenate(([0.0], np.cumsum(flow * dt_days)))
277 peak_volume = max(float(net_volume.max()), 0.0)
278 total_time = float(np.sum(dt_days))
279 r_front = np.sqrt(r_w**2 + peak_volume / c_geo)
280 # radial breakthrough variance ~ alpha_L r_front (+ alpha_L^2), plus -- in quadrature -- the rest
281 # kernel's own mechanical spread sigma_x^2 = 2 alpha_L v_d t_rest / R = 2 alpha_L drift_shift
282 width = np.sqrt(alpha_l * r_front + alpha_l**2 + 2.0 * alpha_l * drift_shift)
283 reach_dm = np.sqrt(d_m * total_time)
284 r_significant = r_front + _PLUME_WIDTHS * (width + reach_dm) + drift_shift + r_w
285 r_grid = r_front + _GRID_WIDTHS * width + 2.0 * _PLUME_WIDTHS * reach_dm + drift_shift + r_w
286 r_s = abs(a0) / abs(v_d) if v_d != 0.0 else np.inf
287 r_far_cap = _RS_FRAC * r_s
288 if r_significant > r_far_cap:
289 eps_front = abs(v_d) * r_front / abs(a0)
290 msg = (
291 f"drift too strong for the radial engine: the plume (front r_front={r_front:.2f} m, "
292 f"eps_front={eps_front:.2f}) reaches the stagnation radius r_s={r_s:.2f} m; the slow-drift "
293 "envelope requires the plume to stay well inside r_s = |A_0|/|v_d|"
294 )
295 raise ValueError(msg)
296 # r_far >= r_significant (the cap was not exceeded and the washout floor is >= r_grid >= r_significant),
297 # so clipping the generous grid at r_far drops only the negligible far tail, not the significant plume.
298 r_far = min(max(_RFAR_FIELD_MULT * r_grid, r_grid + _RFAR_DECAY * alpha_l), r_far_cap)
299 r_max = min(r_grid, r_far)
300 nodes, weights = np.polynomial.legendre.leggauss(n_quad)
301 r_nodes = 0.5 * (r_max - r_w) * (nodes + 1.0) + r_w
302 dr_weights = 0.5 * (r_max - r_w) * weights
303 return r_nodes, dr_weights, r_far
306def _interval_transitions(
307 l_dense: Callable[[npt.NDArray[np.floating]], npt.NDArray[np.complexfloating]],
308 r_from: npt.NDArray[np.floating],
309 r_to: npt.NDArray[np.floating],
310 n_s: int,
311 nm: int,
312) -> npt.NDArray[np.complexfloating]:
313 r"""Fundamental transitions ``Psi(r_to[i], r_from[i])`` of ``Y' = L(r) Y`` for a batch of intervals.
315 All intervals are integrated in lockstep on a common ``tau in [0, 1]`` clock (``r = r_from +
316 tau (r_to - r_from)``, signed widths), with the log-derivative field ``L`` evaluated through its dense
317 ODE interpolant, chunked to bound memory. Every interval spans adjacent quadrature radii, so each
318 transition stays within a few e-foldings of unity -- which is what keeps the prefix/suffix
319 Green's-function recursions built from them unconditionally well-conditioned, where a globally
320 pivoted fundamental matrix accumulates the full mode-split Sturm-Liouville exponent across the grid
321 and overflows / colinearizes.
323 Returns
324 -------
325 ndarray of complex, shape (n_intervals, n_s, nm, nm)
326 The per-interval transition matrices.
328 Raises
329 ------
330 RuntimeError
331 If an interval-transition integration fails.
332 """
333 n_int = r_from.size
334 eye = np.eye(nm, dtype=complex)
335 out = np.empty((n_int, n_s, nm, nm), dtype=complex)
336 chunk = 64 # bounds the ODE state (and DOP853's stage copies) to ~chunk * n_s * nm^2 complex
337 for lo in range(0, n_int, chunk):
338 hi = min(lo + chunk, n_int)
339 start, width = r_from[lo:hi], r_to[lo:hi] - r_from[lo:hi]
340 nc = hi - lo
342 def rhs(tau: float, y: npt.NDArray[np.floating], start=start, width=width, nc=nc) -> npt.NDArray[np.floating]:
343 psi = y.view(complex).reshape(nc, n_s, nm, nm)
344 ld = l_dense(start + tau * width) # (nc, n_s, nm, nm)
345 return (width[:, None, None, None] * (ld @ psi)).reshape(-1).view(float)
347 sol = solve_ivp(
348 rhs,
349 [0.0, 1.0],
350 np.tile(eye.reshape(-1), nc * n_s).view(float),
351 rtol=_RICCATI_RTOL,
352 atol=_RICCATI_ATOL,
353 method="DOP853",
354 )
355 if not sol.success:
356 msg = "block interval-transition integration failed"
357 raise RuntimeError(msg)
358 out[lo:hi] = np.ascontiguousarray(sol.y[:, -1]).view(complex).reshape(nc, n_s, nm, nm)
359 return out
362def _block_solutions(
363 s: npt.NDArray[np.complexfloating],
364 r_nodes: npt.NDArray[np.floating],
365 r_w: float,
366 *,
367 alpha_l: float,
368 a0: float,
369 v_d: float,
370 d_m: float,
371 retardation_factor: float,
372 n_modes: int,
373 direction: str,
374 r_far: float,
375) -> dict[str, npt.NDArray[np.complexfloating]]:
376 r"""Batched block log-derivative + per-interval transition solutions of one constant-Q phase.
378 Integrates, for every Laplace node ``s`` at once (the coefficient matrices ``A, B, S0`` are
379 ``s``-independent -- only the ``R s A^{-1}`` term carries ``s`` -- so one vectorized ODE pass covers
380 all nodes), the matrix Riccati ``L' = -L^2 - A^{-1} B L - A^{-1}(S0 + R s I)`` (``L = c' c^{-1}``) on
381 two branches:
383 * **decaying** (recessive as ``r -> inf``): inward from ``r_far`` with a block-diagonal recessive IC
384 (the scalar per-mode decaying log-derivative on every diagonal -- exact at ``v_d = 0`` and washed in
385 by the inward attractor otherwise). Gives ``L_-`` at ``r_nodes`` and ``r_w``.
386 * **regular** (well boundary): outward from ``r_w`` with the **exact block well IC** built from the
387 face Toeplitz matrices (:func:`_face_matrices`): injection Robin
388 ``L_+ = M[D_rr]^{-1}(M[v_r] - M[D_rt](i m)/r_w)``, extraction Danckwerts
389 ``L_+ = -M[D_rr]^{-1} M[D_rt](i m)/r_w``. The ``O(eps_w)`` face couplings these carry look small
390 but enter the drift loss at ``O(eps^2)``, the order of the loss itself. Gives ``L_+``.
392 The recessive / regular fundamental solutions enter the interior resolvent only through
393 **per-interval transitions** ``Psi_-(r_i, r_{i-1})`` (outward hops) and ``Psi_+(r_{i-1}, r_i)``
394 (inward hops), with ``r_{-1} = r_w``, integrated through the dense ``L`` interpolants
395 (:func:`_interval_transitions`). Each hop spans a few e-foldings at most, so the prefix/suffix
396 resolvent recursions (:func:`_resolvent_field_laplace`, :func:`_readout_laplace`,
397 :func:`_resident_laplace`) stay bounded and well-conditioned at any Peclet, Laplace frequency, and
398 azimuthal truncation. A single fundamental matrix pivoted at ``r_w`` -- even de-scaled by a scalar
399 log-amplitude -- accumulates the full mode-split Sturm-Liouville exponent across the grid and
400 overflows / colinearizes for phases with small ``A_0`` or short durations, which is why the hops
401 are the stored representation.
403 ``a0`` is the unsigned flow scale ``|A_0|``; the phase ``direction`` sets the operator orientation.
404 Retardation enters as the explicit ``R`` in the ``R s`` term (the ODE keeps the physical ``A_0``/``D_m``).
406 Returns
407 -------
408 dict of ndarray
409 ``Lm_w`` (``L_-`` at ``r_w``, shape ``(n_s, nm, nm)``), ``H`` (the Wronskian blocks
410 ``[A (L_- - L_+)]^{-1}`` at ``r_nodes``, ``(n_quad, n_s, nm, nm)``) and ``Tm``/``Tp`` (recessive
411 outward / regular inward per-interval transitions, ``(n_quad, n_s, nm, nm)``).
413 Raises
414 ------
415 RuntimeError
416 If a matrix Riccati or interval-transition integration does not succeed (the log-derivative hit
417 a pole, typically the coordinate finite-escape near the stagnation radius).
418 """
419 s = np.asarray(s, dtype=complex).reshape(-1)
420 n_s = s.size
421 nm = 2 * n_modes + 1
422 eye = np.eye(nm)
423 sigma_a = 1.0 if direction == _INJECTION else -1.0
424 a0_signed = sigma_a * abs(a0)
425 r_max = float(np.max(r_nodes))
427 def riccati_rhs(r: float, y: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
428 ld = y.view(complex).reshape(n_s, nm, nm)
429 a_m, b_m, s0_m = block_coupling_matrices(
430 np.array([r]), alpha_l=alpha_l, a0=a0_signed, v_d=v_d, d_m=d_m, n_modes=n_modes
431 )
432 a_inv = np.linalg.inv(a_m[0])
433 d_ld = (
434 -(ld @ ld)
435 - ((a_inv @ b_m[0])[None] @ ld)
436 - (a_inv @ s0_m[0])[None]
437 - (retardation_factor * s)[:, None, None] * a_inv[None]
438 )
439 return d_ld.reshape(-1).view(float)
441 # recessive IC at r_far (block-diagonal; scalar per-mode decaying log-derivative)
442 if d_m == 0.0:
443 beta = retardation_factor * s / (alpha_l * abs(a0))
444 b13 = beta ** (1.0 / 3.0)
445 zeta = b13 * r_far + beta ** (-2.0 / 3.0) / (4.0 * alpha_l * alpha_l)
446 aie, aipe, _, _ = airye(zeta)
447 l0 = 1.0 / (2.0 * alpha_l) + b13 * (aipe / aie)
448 else:
449 astar = alpha_l * abs(a0) / d_m
450 kappa = np.sqrt(retardation_factor * s / d_m)
451 a_coef = (1.0 - sigma_a * abs(a0) / d_m) / 2.0 - kappa * astar / 2.0
452 l0 = -kappa - a_coef / (r_far + astar)
453 lm0 = (l0[:, None, None] * eye[None]).astype(complex)
454 sol_m = solve_ivp(
455 riccati_rhs,
456 [r_far, r_w],
457 lm0.reshape(-1).view(float),
458 rtol=_RICCATI_RTOL,
459 atol=_RICCATI_ATOL,
460 dense_output=True,
461 method="DOP853",
462 )
463 # Exact block well-face IC for the regular branch: the theta-modulated face velocity and the
464 # D_rtheta cross-dispersion couple the modes in the face condition (a mode-decoupled face IC would
465 # mis-state the drift loss at O(eps^2), the order of the loss itself):
466 # injection (Robin flux inlet, homogeneous part): M[v_r] c - M[D_rr] c' - M[D_rt] (i m / r_w) c = 0
467 # extraction (Danckwerts, zero dispersive flux): M[D_rr] c' + M[D_rt] (i m / r_w) c = 0
468 m_vr, m_drr, m_drt = _face_matrices(r_w, alpha_l=alpha_l, a0_signed=a0_signed, v_d=v_d, d_m=d_m, n_modes=n_modes)
469 i_dm_face = 1j * np.arange(-n_modes, n_modes + 1)
470 cross = (m_drt * i_dm_face[None, :]) / r_w
471 lp_w = np.linalg.solve(m_drr, (m_vr - cross) if sigma_a > 0 else -cross)
472 lp0 = np.broadcast_to(lp_w, (n_s, nm, nm)).astype(complex)
473 sol_p = solve_ivp(
474 riccati_rhs,
475 [r_w, r_max],
476 lp0.reshape(-1).view(float),
477 rtol=_RICCATI_RTOL,
478 atol=_RICCATI_ATOL,
479 dense_output=True,
480 method="DOP853",
481 )
482 if not (sol_m.success and sol_p.success):
483 msg = "block Riccati integration failed (the matrix log-derivative likely hit a pole near stagnation)"
484 raise RuntimeError(msg)
486 def l_minus_at(r: npt.NDArray[np.floating]) -> npt.NDArray[np.complexfloating]:
487 return np.ascontiguousarray(sol_m.sol(r).T).view(complex).reshape(np.size(r), n_s, nm, nm)
489 def l_plus_at(r: npt.NDArray[np.floating]) -> npt.NDArray[np.complexfloating]:
490 return np.ascontiguousarray(sol_p.sol(r).T).view(complex).reshape(np.size(r), n_s, nm, nm)
492 prev = np.concatenate(([r_w], r_nodes[:-1]))
493 a_n = block_coupling_matrices(r_nodes, alpha_l=alpha_l, a0=a0_signed, v_d=v_d, d_m=d_m, n_modes=n_modes)[0]
494 # The log-derivative branches enter the resolvent only through the Wronskian block
495 # H(r') = [A(r')(L_-(r') - L_+(r'))]^{-1} -- bounded wherever the recessive and regular subspaces are
496 # transverse -- so H is materialized once here (and cached with the phase) instead of its factors.
497 h = np.linalg.inv(a_n[:, None] @ (l_minus_at(r_nodes) - l_plus_at(r_nodes)))
498 return {
499 "Lm_w": l_minus_at(np.array([r_w]))[0],
500 "H": h,
501 "Tm": _interval_transitions(l_minus_at, prev, r_nodes, n_s, nm), # Psi_-(r_i, r_{i-1}), outward hops
502 "Tp": _interval_transitions(l_plus_at, r_nodes, prev, n_s, nm), # Psi_+(r_{i-1}, r_i), inward hops
503 }
506def _resident_laplace(
507 d: dict[str, npt.NDArray[np.complexfloating]],
508 *,
509 alpha_l: float,
510 d_m: float,
511 r_w: float,
512 a0: float,
513 v_d: float,
514 n_modes: int,
515) -> npt.NDArray[np.complexfloating]:
516 r"""Laplace-domain resident mode-field per unit uniform-``cin`` flux injection at the well.
518 The injected water enters through the exact Kreft-Zuber flux boundary
519 ``v_r c - D_rr d_r c - D_rt (1/r_w) d_th c = v_r cin``: the ``theta``-modulated face velocity gives
520 the injected flux an ``O(eps_w)`` ``m = +-1`` modulation and the ``D_rtheta`` cross term couples the
521 modes in the face operator, so with the recessive branch (``c' = L_-(r_w) c``) the face field is
522 ``c(r_w) = F_w^{-1} M[v_r] e_0`` with ``F_w = M[v_r] - M[D_rr] L_-(r_w) - M[D_rt] (i m)/r_w``, carried
523 outward by the stable hops ``c(r_i) = Psi_-(r_i, r_{i-1}) c(r_{i-1})``. Reduces to the scalar FR
524 resident transfer ``E / f_w`` at ``v_d = 0``.
526 Returns
527 -------
528 ndarray
529 ``c(r_nodes; s)``, shape ``(n_s, n_quad, nm)``.
530 """
531 nm = 2 * n_modes + 1
532 n_s = d["Lm_w"].shape[0]
533 e0 = np.zeros(nm, dtype=complex)
534 e0[n_modes] = 1.0
535 m_vr, m_drr, m_drt = _face_matrices(r_w, alpha_l=alpha_l, a0_signed=abs(a0), v_d=v_d, d_m=d_m, n_modes=n_modes)
536 cross = (m_drt * (1j * np.arange(-n_modes, n_modes + 1))[None, :]) / r_w
537 fw = (m_vr - cross)[None] - m_drr[None] @ d["Lm_w"] # (n_s, nm, nm)
538 y = np.linalg.solve(fw, np.broadcast_to(m_vr @ e0, (n_s, nm))[..., None]) # (n_s, nm, 1)
539 tm = d["Tm"]
540 c = np.empty((tm.shape[0], n_s, nm), dtype=complex)
541 for i in range(tm.shape[0]):
542 y = tm[i] @ y
543 c[i] = y[..., 0]
544 return np.transpose(c, (1, 0, 2))
547def _source_blocks(
548 d: dict[str, npt.NDArray[np.complexfloating]],
549 field: npt.NDArray[np.floating],
550 dr_weights: npt.NDArray[np.floating],
551 retardation_factor: float,
552) -> npt.NDArray[np.complexfloating]:
553 r"""Wronskian-weighted source contributions ``H_j (R dr_j) field_j`` of the interior resolvent.
555 ``H(r') = [A(r')(L_-(r') - L_+(r'))]^{-1}`` is the matrix Wronskian block of the interior Green's
556 function, materialized with the per-phase solutions (:func:`_block_solutions`).
558 Returns
559 -------
560 ndarray
561 ``H_j source_j``, shape ``(n_quad, n_s, nm, k)`` for a ``(n_quad, nm, k)`` mode-field batch.
562 """
563 src = ((retardation_factor * dr_weights)[:, None, None] * field).astype(complex) # (n_quad, nm, k)
564 return d["H"] @ src[:, None] # (n_quad, n_s, nm, k)
567def _resolvent_field_laplace(
568 d: dict[str, npt.NDArray[np.complexfloating]],
569 field: npt.NDArray[np.floating],
570 dr_weights: npt.NDArray[np.floating],
571 retardation_factor: float,
572) -> npt.NDArray[np.complexfloating]:
573 r"""Laplace-domain resident field after one constant-Q phase propagates ``field`` (all nodes).
575 The interior Green's function of the constant-Q block operator, with recessive ``Y_-`` and well-regular
576 ``Y_+`` and the matrix Wronskian ``H(r') = [A(r')(L_-(r') - L_+(r'))]^{-1}``, is
577 ``Ghat(r, r') = Y_-(r) Y_-(r')^{-1} H(r')`` for ``r >= r'`` and ``Y_+(r) Y_+(r')^{-1} H(r')`` for
578 ``r <= r'``. Applied to the source measure ``(R dr_j) field_j`` it separates into a recessive prefix
579 (``j <= i``) and a regular suffix (``j > i``), both evaluated as first-order recursions over the
580 per-interval transitions (``hs_j = H_j source_j``):
582 P_0 = hs_0, P_i = Psi_-(r_i, r_{i-1}) P_{i-1} + hs_i,
583 S_{n-1} = 0, S_{i-1} = Psi_+(r_{i-1}, r_i) (S_i + hs_i),
584 F_i = P_i + S_i.
586 Every factor is a short-interval transition (a few e-foldings) or a bounded Wronskian block, so the
587 recursion is well-conditioned at any Peclet and Laplace frequency: distant contributions fade by
588 repeated bounded multiplication exactly as the physical Green's function does, with no globally
589 accumulated exponent to overflow or colinearize.
591 Returns
592 -------
593 ndarray
594 Propagated mode-field batch, shape ``(n_s, n_quad, nm, k)`` for a ``(n_quad, nm, k)`` ``field``.
595 """
596 hs = _source_blocks(d, field, dr_weights, retardation_factor) # (n_quad, n_s, nm, k)
597 tm, tp = d["Tm"], d["Tp"]
598 n_quad = hs.shape[0]
599 f = np.empty_like(hs)
600 p = hs[0]
601 f[0] = p
602 for i in range(1, n_quad):
603 p = tm[i] @ p + hs[i]
604 f[i] = p
605 s_acc = np.zeros_like(p)
606 for i in range(n_quad - 1, 0, -1):
607 s_acc = tp[i] @ (s_acc + hs[i])
608 f[i - 1] += s_acc
609 return np.transpose(f, (1, 0, 2, 3)) # (n_s, n_quad, nm, k)
612def _readout_laplace(
613 d: dict[str, npt.NDArray[np.complexfloating]],
614 field: npt.NDArray[np.floating],
615 dr_weights: npt.NDArray[np.floating],
616 retardation_factor: float,
617 n_modes: int,
618 eps_w: float,
619) -> npt.NDArray[np.complexfloating]:
620 r"""Laplace-domain extracted flux concentration from a resident ``field`` under extraction.
622 Under the exact Danckwerts boundary (zero dispersive flux across the face, cross term included in the
623 regular-branch IC) the extracted flux concentration equals the resident concentration at the face
624 pointwise in ``theta``, i.e. the extraction interior resolvent evaluated at ``r_w``. Since ``r_w``
625 lies below every node, only the regular (suffix) branch contributes:
626 ``c(r_w) = sum_j Y_+(r_w) Y_+(r_j)^{-1} H_j (R dr_j) field_j``, evaluated by running the suffix
627 recursion of :func:`_resolvent_field_laplace` one extra inward hop to ``r_w``. The extracted mixture
628 is the **inflow-flux-weighted** azimuthal average -- ``|v_r(r_w, theta)| = (|A_0|/r_w)(1 - eps_w
629 cos(theta))`` with ``eps_w = v_d r_w / |A_0|`` -- applied explicitly on the face modes:
631 cout_hat = c_0(r_w) - (eps_w / 2) (c_{+1}(r_w) + c_{-1}(r_w)).
633 (The face flux weighting and the ``D_rtheta`` face coupling are ``O(eps_w)`` individually but enter
634 the drift loss at ``O(eps^2)``, the order of the loss itself; the engine-vs-FV loss tests pin them.)
636 Returns
637 -------
638 ndarray
639 ``cout_hat(s)``, shape ``(n_s, k)`` for a ``(n_quad, nm, k)`` ``field``.
640 """
641 hs = _source_blocks(d, field, dr_weights, retardation_factor) # (n_quad, n_s, nm, k)
642 tp = d["Tp"]
643 s_acc = np.zeros_like(hs[0])
644 for i in range(hs.shape[0] - 1, 0, -1):
645 s_acc = tp[i] @ (s_acc + hs[i])
646 face = tp[0] @ (s_acc + hs[0]) # (n_s, nm, k): the resident face modes
647 return face[:, n_modes, :] - 0.5 * eps_w * (face[:, n_modes + 1, :] + face[:, n_modes - 1, :])
650def _rest_drift_field(
651 field: npt.NDArray[np.floating],
652 r_nodes: npt.NDArray[np.floating],
653 dr_weights: npt.NDArray[np.floating],
654 r_w: float,
655 *,
656 alpha_l: float,
657 v_d: float,
658 d_m: float,
659 retardation_factor: float,
660 t_rest: float,
661 n_modes: int,
662) -> npt.NDArray[np.floating]:
663 r"""Advance the resident mode-field through a rest phase (``Q = 0``) under drift.
665 With the well shut the velocity field is the bare uniform drift ``v = v_d x_hat``, so the
666 advection-dispersion kernel is exact in free space and constant-coefficient: the field translates
667 down-gradient by ``delta = v_d t / R`` and spreads by the anisotropic Gaussian with
668 ``sigma_x^2 = 2 (alpha_L |v_d| + D_m) t / R`` along the drift and ``sigma_y^2 = 2 D_m t / R`` across
669 it (rank-1 Scheidegger tensor, ``alpha_T = 0``). The kernel is applied in real space:
671 1. the mode-field is evaluated at the Gauss-Hermite-shifted source points of every polar target node
672 (barycentric interpolation on the radial Legendre nodes -- spectrally accurate for the smooth
673 resident field -- times the azimuthal phase sum);
674 2. Gauss-Hermite quadrature (``_REST_HERMITE`` nodes per Gaussian axis) averages the spread;
675 3. the updated real-space samples are reprojected onto the modes by FFT over a uniform theta grid.
677 The shut well is closed by a **radial Neumann image**: source points falling inside the well disk are
678 folded back across the face (``r' -> 2 r_w - r'``), the leading-order zero-dispersive-flux closure,
679 which conserves the near-well mass (a zeroed disk would swallow ``O(sigma r_w / R_b^2)`` of the plume).
680 The residual is the circle-vs-line curvature of the image and the neglected ``O(r_w^2/r^2)`` dipole
681 distortion of the drift around the cylinder -- at ``v_d = 0``, ``D_m > 0`` the kernel differs from
682 the scalar engine's exact well-respecting Bessel rest kernel only by this ``r_w``-scale closure
683 residual for a stored plume (the public API dispatches ``v_d = 0`` to the scalar path anyway).
684 Source points outside the radial grid carry no mass by the grid's containment guarantee
685 (:func:`field_grid` provisions for the rest displacement).
687 An **honest truncation guard** protects the azimuthal representation: after reprojection, the energy
688 in the harmonics just above the kept band (``M < |m| <= 2M``, available from the FFT grid) measures
689 the translated field's spectral tail; if it exceeds ``_REST_TAIL_MAX`` of the field, the translated
690 plume is too eccentric for the kept modes and a ``ValueError`` asks for a larger ``n_modes`` rather
691 than silently folding the tail.
693 Returns
694 -------
695 ndarray
696 The advanced mode-field, shape ``(n_quad, nm, k)`` (matching ``field``).
698 Raises
699 ------
700 ValueError
701 If the translated field's azimuthal spectral tail exceeds the truncation guard (increase
702 ``n_modes``).
703 """
704 delta = v_d * t_rest / retardation_factor
705 sig_x = np.sqrt(2.0 * (alpha_l * abs(v_d) + d_m) * t_rest / retardation_factor)
706 sig_y = np.sqrt(2.0 * d_m * t_rest / retardation_factor)
707 if delta == 0.0 and sig_x == 0.0:
708 return field # v_d = 0 and D_m = 0: a rest phase is the identity
709 n_quad, nm, k = field.shape
710 modes = np.arange(-n_modes, n_modes + 1)
711 theta = _theta_grid(n_modes)
712 nth = theta.size
713 x = r_nodes[:, None] * np.cos(theta)[None, :] # (n_quad, nth)
714 y = r_nodes[:, None] * np.sin(theta)[None, :]
715 interp = BarycentricInterpolator(r_nodes, field.reshape(n_quad, nm * k))
716 r_hi = float(r_nodes[-1])
718 def field_at(xp: npt.NDArray[np.floating], yp: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
719 rp = np.hypot(xp, yp).ravel()
720 thp = np.arctan2(yp, xp).ravel()
721 # Source points inside the shut well are folded back across the face (radial Neumann image,
722 # r' -> 2 r_w - r'): the leading-order zero-dispersive-flux closure at the well, which conserves
723 # the near-well mass that a zeroed disk would silently swallow. Folded points land in
724 # [r_w, 2 r_w], always inside the grid (field_grid's margins are many multiples of r_w).
725 rp = np.where(rp < r_w, 2.0 * r_w - rp, rp)
726 inside = rp <= r_hi # beyond the grid: no mass (containment guaranteed by field_grid)
727 vals = np.zeros((rp.size, nm * k))
728 vals[inside] = interp(rp[inside])
729 phase = np.exp(1j * thp[:, None] * modes[None, :]) # (n_pts, nm)
730 return np.einsum("pmk,pm->pk", vals.reshape(-1, nm, k), phase).real.reshape(*xp.shape, k)
732 zh, wh = np.polynomial.hermite.hermgauss(_REST_HERMITE)
733 f_new = np.zeros((n_quad, nth, k))
734 if sig_y == 0.0: # D_m = 0: the Gaussian spread is 1-D along the drift
735 for za, wa in zip(zh, wh, strict=True):
736 f_new += wa * field_at(x - delta - np.sqrt(2.0) * sig_x * za, y)
737 f_new /= np.sqrt(np.pi)
738 else:
739 for za, wa in zip(zh, wh, strict=True):
740 x_shift = x - delta - np.sqrt(2.0) * sig_x * za
741 for zb, wb in zip(zh, wh, strict=True):
742 f_new += (wa * wb) * field_at(x_shift, y - np.sqrt(2.0) * sig_y * zb)
743 f_new /= np.pi
744 coeffs = np.fft.fft(f_new, axis=1) / nth # (n_quad, nth, k); c_m at index m mod nth
745 measure = (r_nodes * dr_weights)[:, None, None] # radial area measure for the spectral-tail energy
746 tail_idx = np.concatenate([np.arange(n_modes + 1, 2 * n_modes + 1), -np.arange(n_modes + 1, 2 * n_modes + 1)])
747 band_idx = np.concatenate([tail_idx, modes])
748 # Per COLUMN, not aggregated: a batched build (the reverse operator's unit pulses) must not let one
749 # column's excess tail hide in the energy of the others.
750 e_tail = np.sum(measure * np.abs(coeffs[:, tail_idx % nth]) ** 2, axis=(0, 1)) # (k,)
751 e_band = np.sum(measure * np.abs(coeffs[:, band_idx % nth]) ** 2, axis=(0, 1))
752 ratios = np.sqrt(np.divide(e_tail, e_band, out=np.zeros_like(e_tail), where=e_band > 0.0))
753 if np.any(ratios > _REST_TAIL_MAX):
754 msg = (
755 f"rest drift displacement (delta = {delta:.2f} m over {t_rest:.1f} d) makes the plume too "
756 f"eccentric for the kept azimuthal modes (spectral tail {float(ratios.max()):.2%} > "
757 f"{_REST_TAIL_MAX:.0%}); increase n_modes"
758 )
759 raise ValueError(msg)
760 return coeffs[:, modes % nth].real
763def block_cout_deviation(
764 *,
765 cin_deviation: npt.NDArray[np.floating],
766 flow: npt.NDArray[np.floating],
767 dt_days: npt.NDArray[np.floating],
768 c_geo: float,
769 r_w: float,
770 alpha_l: float,
771 v_d: float,
772 molecular_diffusivity: float = 0.0,
773 retardation_factor: float = 1.0,
774 n_modes: int = 3,
775 n_quad: int = 240,
776 n_terms: int = 44,
777 tol: float = 1e-9,
778) -> npt.NDArray[np.floating]:
779 r"""Multi-cycle extracted-flux deviation with steady regional drift, via the azimuthal-mode block engine.
781 Generalizes the scalar reused-propagator engine (:func:`gwtransport._radial_asr_reuse.cout_deviation`)
782 to the coupled azimuthal modes. The resident state is a mode-field batch ``field[r_node, mode, column]``
783 (one column per independent ``cin_deviation`` column); each constant-Q phase advances it with the exact
784 per-phase block kernels (:func:`_block_solutions`):
786 * **injection** -- the existing field is propagated by the injection-direction interior resolvent
787 (:func:`_resolvent_field_laplace`), then the freshly injected resident profile is added via the
788 ``m = 0`` Kreft-Zuber flux transfer (:func:`_resident_laplace`) superposed over the injection bins;
789 * **extraction** -- the ``m = 0`` well-face flux concentration is read out (:func:`_readout_laplace`)
790 and bin-averaged into ``cout``; the residual field is then propagated for any following phase;
791 * **rest** (``flow == 0``) -- the field is advanced by the exact free-space drift kernel
792 (:func:`_rest_drift_field`): translation by ``v_d t/R`` plus the anisotropic Gaussian spread, with
793 a Neumann-image closure at the shut well face and an honest guard on the azimuthal truncation of
794 the translated plume.
796 Every per-phase operator is grid-free in ``(r, theta)`` (no PDE mesh): the only numerics are the radial
797 matrix Riccati ODEs, Gauss-Legendre quadrature and de Hoog Laplace inversion. The interior resolvent is
798 applied per reversal (prefix/suffix recursions over the per-interval transitions) rather than as a
799 materialized propagator, so memory stays ``O((2M+1)^2 n_quad)`` per phase kernel, and the per-phase
800 kernel solutions are cached across recurring phases -- the block analogue of the scalar engine's reused
801 propagator matrices, making the ODE cost ``O(distinct phases)`` instead of ``O(reversals)``. At
802 ``v_d = 0`` the modes decouple and the ``m = 0`` block reproduces the scalar engine to the de Hoog
803 floor *for constant-per-phase flow* (the public API dispatches ``v_d = 0`` to the scalar path for the
804 bit-for-bit guarantee).
806 Within-phase variable flow is **approximate**: each phase is clocked in wall-clock time at its mean
807 magnitude ``a0 = mean(|flow[phase]|)`` (the drift breaks the flushed-volume-clock autonomy the scalar
808 ``D_m = 0`` path exploits for exact variable flow, and matches the scalar ``D_m > 0`` path's same mean-
809 flow approximation). It is exact for piecewise-constant flow. At ``v_d = 0`` it is additionally exact
810 for constant ``cin`` over a phase at any flow profile (the resident profile then depends only on the
811 total injected volume), leaving only the *variable cin AND variable flow* cin-placement error (bins at
812 time corners rather than exact volume edges). Under drift no such exactness survives for any
813 within-phase flow variation: the mode coupling integrates ``eps(r(t))`` on the wall clock, so two
814 flow profiles with equal volume and duration end in different fields while the mean-flow engine
815 returns identical results for both. The error grows with the within-phase variation; use finer
816 phases if needed.
818 Parameters
819 ----------
820 cin_deviation : ndarray, shape (n,) or (n, k)
821 Injected concentration deviation per bin (used on injection bins, ``flow > 0``). A 2-D input
822 transports ``k`` independent deviation columns through one engine pass sharing the per-phase
823 kernels (used to build the reverse operator's column block in a single run).
824 flow : ndarray, shape (n,)
825 Signed flow per bin [m^3/day]: ``> 0`` injection, ``< 0`` extraction, ``0`` rest.
826 dt_days : ndarray, shape (n,)
827 Bin widths [day].
828 c_geo : float
829 Geometry constant ``pi b n`` (``V = c_geo (r^2 - r_w^2)``).
830 r_w : float
831 Well radius [m].
832 alpha_l : float
833 Longitudinal dispersivity [m].
834 v_d : float
835 Regional drift seepage velocity ``U / n`` [m/day]. ``0`` is the radial-symmetric limit (the modes
836 decouple); the public API dispatches that to the scalar engine, but this function handles it too.
837 molecular_diffusivity : float, optional
838 Molecular diffusivity [m^2/day]. Default 0.
839 retardation_factor : float, optional
840 Linear retardation ``R >= 1``. Default 1.
841 n_modes : int, optional
842 Azimuthal truncation ``M`` (keeps modes ``-M .. M``). Default 3.
843 n_quad : int, optional
844 Number of radial Gauss-Legendre nodes. Default 240.
845 n_terms : int, optional
846 de Hoog series length. Default 44.
847 tol : float, optional
848 de Hoog target accuracy. Default ``1e-9``.
850 Returns
851 -------
852 ndarray, shape (n,) or (n, k)
853 Extracted-flux deviation per bin (matching ``cin_deviation``); ``NaN`` on injection / rest bins.
855 Notes
856 -----
857 Propagates a ``ValueError`` from :func:`field_grid` when the significant plume (including the
858 rest-phase drift displacement) reaches the stagnation radius (drift too strong for the radial
859 engine), or from :func:`_rest_drift_field` when a rest translation makes the plume too eccentric for
860 the kept azimuthal modes (increase ``n_modes``); and a ``RuntimeError`` from :func:`_block_solutions`
861 if a per-phase matrix Riccati or interval-transition integration fails.
862 """
863 flow = np.asarray(flow, dtype=float)
864 dt_days = np.asarray(dt_days, dtype=float)
865 cin_deviation = np.asarray(cin_deviation, dtype=float)
866 vector_input = cin_deviation.ndim == 1
867 cin_cols = cin_deviation[:, None] if vector_input else cin_deviation # (n, k)
868 n_rhs = cin_cols.shape[1]
869 phases = _phase_slices(flow)
870 pumping = [(sign, sl) for sign, sl in phases if sign != 0]
871 if not pumping: # all-rest schedule: nothing is injected or extracted
872 cout_empty = np.full((len(flow), n_rhs), np.nan)
873 return cout_empty[:, 0] if vector_input else cout_empty
874 while phases[-1][0] == 0: # trailing rest phases cannot affect any output; don't propagate or guard them
875 phases.pop()
876 # Each pumping phase is clocked at its mean magnitude, so its A_0 = mean(|flow[phase]|)/(2 c_geo); the
877 # stagnation radius r_s = |A_0|/|v_d| is smallest for the weakest phase, so size the grid cap on that
878 # (worst-case). Interior rest phases translate the plume by v_d t/R; the grid provisions for their
879 # total shift. Leading rests act on an empty field and trailing rests are dropped above, so neither
880 # counts -- idle padding must not inflate the envelope guard or dilute the radial resolution.
881 a0_min = min(float(np.mean(np.abs(flow[sl]))) for _, sl in pumping) / (2.0 * c_geo)
882 nz = np.flatnonzero(flow != 0.0)
883 interior = slice(nz[0], nz[-1] + 1)
884 rest_time = float(np.sum(dt_days[interior][flow[interior] == 0.0]))
885 drift_shift = abs(v_d) * rest_time / retardation_factor
886 r_nodes, dr_weights, r_far = field_grid(
887 flow, dt_days, c_geo, r_w, alpha_l, v_d, a0_min, n_quad, d_m=molecular_diffusivity, drift_shift=drift_shift
888 )
889 nm = 2 * n_modes + 1
891 # The per-phase block solutions are a pure function of (direction, |A_0|, s) -- every other input is
892 # fixed for the call -- and the de Hoog nodes depend only on max(t), so the 2-3 inversions within one
893 # phase (propagate / resident / readout all span the same phase duration) and every recurrence of the
894 # phase across a periodic schedule share one Riccati + transition solve. Caching them is the block
895 # analogue of the scalar engine's reused propagator matrices: the ODE cost becomes O(distinct phases)
896 # instead of O(reversals). FIFO-capped: an entry holds O(n_quad n_s (2M+1)^2) complex (~70 MB at the
897 # defaults), and a periodic schedule needs only one entry per pumping direction.
898 solutions_cache: dict[tuple[str, float, bytes], dict] = {}
900 def solutions(s: npt.NDArray[np.complexfloating], a0: float, direction: str) -> dict:
901 key = (direction, a0, s.tobytes())
902 if key not in solutions_cache:
903 if len(solutions_cache) >= _SOLUTIONS_CACHE_MAX:
904 solutions_cache.pop(next(iter(solutions_cache)))
905 solutions_cache[key] = _block_solutions(
906 s,
907 r_nodes,
908 r_w,
909 alpha_l=alpha_l,
910 a0=a0,
911 v_d=v_d,
912 d_m=molecular_diffusivity,
913 retardation_factor=retardation_factor,
914 n_modes=n_modes,
915 direction=direction,
916 r_far=r_far,
917 )
918 return solutions_cache[key]
920 def propagate(field: npt.NDArray[np.floating], a0: float, direction: str, t_phase: float) -> npt.NDArray:
921 def f_hat(s):
922 return _resolvent_field_laplace(solutions(s, a0, direction), field, dr_weights, retardation_factor)
924 return dehoog_inverse(f_hat=f_hat, t=t_phase, n_terms=n_terms, tol=tol)
926 field = np.zeros((n_quad, nm, n_rhs))
927 cout = np.full((len(flow), n_rhs), np.nan)
928 for idx, (sign, sl) in enumerate(phases):
929 # One cumulative-time base per phase: the propagate / resident / readout inversions then share
930 # bitwise-identical de Hoog nodes (max(t) equal), so the solutions cache hits within the phase.
931 csum = np.cumsum(dt_days[sl])
932 t_phase = float(csum[-1])
933 if sign == 0: # rest: free-space translate + anisotropic-spread kernel (Neumann image at the well)
934 if np.any(field):
935 field = _rest_drift_field(
936 field,
937 r_nodes,
938 dr_weights,
939 r_w,
940 alpha_l=alpha_l,
941 v_d=v_d,
942 d_m=molecular_diffusivity,
943 retardation_factor=retardation_factor,
944 t_rest=t_phase,
945 n_modes=n_modes,
946 )
947 continue
948 a0 = float(np.mean(np.abs(flow[sl]))) / (2.0 * c_geo)
949 if sign > 0: # injection: propagate the buffer, then add the freshly injected resident profile
950 if np.any(field):
951 field = propagate(field, a0, _INJECTION, t_phase)
952 corners = t_phase - np.concatenate(([0.0], csum)) # descending, last is 0
954 def f_hat_resident(s, a0=a0):
955 return (
956 _resident_laplace(
957 solutions(s, a0, _INJECTION),
958 alpha_l=alpha_l,
959 d_m=molecular_diffusivity,
960 r_w=r_w,
961 a0=a0,
962 v_d=v_d,
963 n_modes=n_modes,
964 )
965 / s[:, None, None]
966 )
968 g1 = dehoog_inverse(f_hat=f_hat_resident, t=corners, n_terms=n_terms, tol=tol) # (n_corner, n_quad, nm)
969 field += np.einsum("bk,bqm->qmk", cin_cols[sl], g1[:-1] - g1[1:])
970 else: # extraction: read the m=0 well-face flux, then propagate the residual if more phases follow
971 ext_corners = np.concatenate(([0.0], csum))
973 def f_hat_readout(s, a0=a0, field=field):
974 return (
975 _readout_laplace(
976 solutions(s, a0, "extraction"),
977 field,
978 dr_weights,
979 retardation_factor,
980 n_modes,
981 eps_w=v_d * r_w / a0,
982 )
983 / s[:, None]
984 )
986 cdf = dehoog_inverse(f_hat=f_hat_readout, t=ext_corners, n_terms=n_terms, tol=tol) # (n_corner, k)
987 cout[sl] = (cdf[1:] - cdf[:-1]) / np.diff(ext_corners)[:, None]
988 if idx != len(phases) - 1:
989 field = propagate(field, a0, "extraction", t_phase)
990 return cout[:, 0] if vector_input else cout