Coverage for src/gwtransport/_radial_asr_compose.py: 100%
20 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 21:13 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 21:13 +0000
1r"""Flux-resident step response of a constant-Q radial phase in the flushed-volume clock.
3The single injection/readout primitive the composition engines are built from
4(:mod:`gwtransport._radial_asr_reuse`): ``G1(S; V') = L^{-1}[ghat_FR(p; V')/p](S)``, the flux-resident
5step response in flushed volume (FR mode: flux injection at the well, resident detection at volume
6``V'``). It carries both directions of the transport:
81. **Injection -> resident profile.** A piecewise-constant injected deviation ``cin'`` (concentration
9 minus background) over injection volume bins ``[sigma_j, sigma_{j+1}]`` leaves, after flushing the
10 total injected volume ``S_inj``, the resident profile
12 ``f(V') = sum_j cin'_j [G1(S_inj - sigma_j; V') - G1(S_inj - sigma_{j+1}; V')]``.
142. **Extraction -> arrival.** Each resident parcel at ``V'`` returns to the well with the duality
15 arrival kernel whose flushed-extraction-volume Laplace transform is the same ``ghat_FR(p; V')``
16 (``|Q| h_bar = ghat_FR``). The flow-weighted average over an output (extraction) volume bin
17 ``[T_i, T_{i+1}]`` is therefore ``[G1(T_{i+1}; V') - G1(T_i; V')]/(T_{i+1}-T_i)``.
19The flushed-volume FR transfer function is the autonomous form: ``ghat`` depends on the Laplace
20variable only through ``beta = s/(alpha_L A_0)``, and the S-clock substitution makes
21``beta = 2 c_geo R p / alpha_L`` (``c_geo = pi b n``, ``p`` conjugate to flushed volume), independent
22of the flow magnitude. So the kernel is evaluated as ``transfer_function(s = 2 c_geo p, a0 = 1, R)``.
24This file is part of gwtransport which is released under AGPL-3.0 license.
25See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details.
26"""
28import numpy as np
29import numpy.typing as npt
31from gwtransport._radial_asr_dehoog import dehoog_inverse
32from gwtransport._radial_asr_kernels import transfer_function
34# Default de Hoog series length and front-anchored scaling margin for the radial-ASR inversions: the FR
35# step response (here) and the field propagators (_radial_asr_reuse) both import these so the two never
36# silently desync on de Hoog resolution.
37_DEHOOG_TERMS = 44
38_SCALE_MARGIN = 1.3
41def _fr_step_response(
42 v_prime: float,
43 corner_volumes: npt.NDArray[np.floating],
44 *,
45 c_geo: float,
46 r_w: float,
47 alpha_l: float,
48 retardation_factor: float,
49 flow_scale: float,
50 molecular_diffusivity: float,
51) -> npt.NDArray[np.floating]:
52 r"""Flux-resident step response ``G1(S; V') = L^{-1}[ghat_FR(p; V')/p](S)`` at one ``V'``.
54 For ``D_m = 0`` the flushed-volume Airy kernel depends on the Laplace variable ``p`` only through
55 ``beta = 2 c_geo R p / alpha_L``, so it is evaluated directly in the flow-free canonical form
56 (``s = 2 c_geo p``, ``A_0 = 1``) -- exact for arbitrary within-phase variable flow and bit-independent of
57 ``flow_scale``. For ``D_m > 0`` the kernel depends on ``A_0 = flow_scale / (2 c_geo)`` separately, so the
58 Laplace variable enters as ``s = flow_scale * p`` and ``flow_scale`` must be the (constant) phase flow
59 magnitude.
61 The de Hoog half-period is anchored to the FR arrival-volume mean at ``V'``
62 (``mu = R c_geo[(r'+alpha_L)^2 + alpha_L^2 - r_w^2]`` -- the breakthrough front), bounded below by
63 the requested corner volumes, so the front is resolved even when the output extends far past it.
64 Corners ``<= 0`` map to ``0`` (no breakthrough yet).
66 Returns
67 -------
68 ndarray
69 ``G1(S; V')`` for each ``S`` in ``corner_volumes`` (same shape).
70 """
71 r_p = np.sqrt(r_w**2 + v_prime / c_geo)
72 mu = retardation_factor * c_geo * ((r_p + alpha_l) ** 2 + alpha_l**2 - r_w**2)
73 # D_m = 0: the Airy S-clock kernel depends on p only through beta = 2 c_geo R p / alpha_L, so evaluate it
74 # in the flow-free canonical form (a0 = 1, s = 2 c_geo p) -- routing flow_scale through s and a0 would
75 # round-trip it and leave ~1-ulp bit-noise the de Hoog QD stage amplifies. D_m > 0: the kernel depends on
76 # A_0 = flow_scale/(2 c_geo) separately, so use the (constant) phase flow magnitude.
77 s_mult = 2.0 * c_geo if molecular_diffusivity == 0.0 else flow_scale
78 a0 = s_mult / (2.0 * c_geo) # 1.0 exactly for D_m = 0 (flow-free), flow_scale/(2 c_geo) for D_m > 0
80 def f_hat(p: npt.NDArray[np.complexfloating]) -> npt.NDArray[np.complexfloating]:
81 return (
82 transfer_function(
83 s=s_mult * p,
84 r=r_p,
85 r_w=r_w,
86 alpha_l=alpha_l,
87 a0=a0,
88 d_m=molecular_diffusivity,
89 retardation_factor=retardation_factor,
90 detect="resident",
91 )
92 / p
93 )
95 cv = np.asarray(corner_volumes, dtype=float)
96 out = np.zeros_like(cv)
97 positive = cv > 0.0
98 if np.any(positive):
99 scaling = _SCALE_MARGIN * max(mu, float(cv[positive].max()))
100 out[positive] = dehoog_inverse(f_hat=f_hat, t=cv[positive], n_terms=_DEHOOG_TERMS, scaling=scaling)
101 return out