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

39 statements  

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

1r"""Composition of constant-Q radial phases into the well observable (single cycle). 

2 

3For a single inject-then-extract cycle at one well (no intermediate flow reversal) the extracted 

4flux concentration is built grid-free from the per-phase kernels of :mod:`gwtransport._radial_asr_kernels` 

5(the KB Sec. 10a pipeline), with everything carried in the flushed-volume clock so that arbitrary 

6within-phase variable flow is exact for ``D_m = 0`` (the S-clock convolution theorem): 

7 

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 

11 

12 ``f(V') = sum_j cin'_j [G1(S_inj - sigma_j; V') - G1(S_inj - sigma_{j+1}; V')]``, 

13 

14 where ``G1(S; V') = L^{-1}[ghat_FR(p; V')/p](S)`` is the flux-resident step response in flushed 

15 volume (FR mode: flux injection at the well, resident detection at volume ``V'``). 

16 

172. **Extraction -> echo.** Each resident parcel at ``V'`` returns to the well with the duality arrival 

18 kernel whose flushed-extraction-volume Laplace transform is the same ``ghat_FR(p; V')`` (KB Sec. 7, 

19 ``|Q| h_bar = ghat_FR``). The flow-weighted average over an output (extraction) volume bin 

20 ``[T_i, T_{i+1}]`` is therefore ``[G1(T_{i+1}; V') - G1(T_i; V')]/(T_{i+1}-T_i)``, and superposing 

21 over the resident profile gives the bin-averaged echo. The whole map is the weight matrix 

22 

23 ``W_{ij} = int_0^inf [G1(S_inj-sigma_j;V') - G1(S_inj-sigma_{j+1};V')] 

24 * [G1(T_{i+1};V') - G1(T_i;V')]/(T_{i+1}-T_i) dV'``, ``cout'_i = sum_j W_{ij} cin'_j``. 

25 

26The flushed-volume FR transfer function is the autonomous form: ``ghat`` depends on the Laplace 

27variable only through ``beta = s/(alpha_L A_0)``, and the S-clock substitution makes 

28``beta = 2 c_geo R p / alpha_L`` (``c_geo = pi b n``, ``p`` conjugate to flushed volume), independent 

29of the flow magnitude. So the kernel is evaluated as ``transfer_function(s = 2 c_geo p, a0 = 1, R)``. 

30 

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

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

33""" 

34 

35import numpy as np 

36import numpy.typing as npt 

37 

38from gwtransport._radial_asr_dehoog import dehoog_inverse 

39from gwtransport._radial_asr_kernels import transfer_function 

40 

41# Default de Hoog series length and front-anchored scaling margin for the radial-ASR inversions: the FR 

42# step response (here) and the field propagators (_radial_asr_reuse) both import these so the two never 

43# silently desync on de Hoog resolution. 

44_DEHOOG_TERMS = 44 

45_SCALE_MARGIN = 1.3 

46 

47 

48def _fr_step_response( 

49 v_prime: float, 

50 corner_volumes: npt.NDArray[np.floating], 

51 *, 

52 c_geo: float, 

53 r_w: float, 

54 alpha_l: float, 

55 retardation_factor: float, 

56 flow_scale: float, 

57 molecular_diffusivity: float, 

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

59 r"""Flux-resident step response ``G1(S; V') = L^{-1}[ghat_FR(p; V')/p](S)`` at one ``V'``. 

60 

61 The flushed-volume Laplace variable ``p`` enters the transfer function as ``s = flow_scale * p`` 

62 with ``A_0 = flow_scale / (2 c_geo)``. For ``D_m = 0`` the flow magnitude cancels (``ghat`` 

63 depends only on ``beta = 2 c_geo p / alpha_L``), so the response is the flow-independent S-clock 

64 kernel -- exact for arbitrary within-phase variable flow. For ``D_m > 0`` the kernel depends on 

65 ``A_0`` separately, so ``flow_scale`` must be the (constant) phase flow magnitude. 

66 

67 The de Hoog half-period is anchored to the FR arrival-volume mean at ``V'`` 

68 (``mu = R c_geo[(r'+alpha_L)^2 + alpha_L^2 - r_w^2]``, KB Sec. 7 -- the breakthrough front), 

69 bounded below by the requested corner volumes, so the front is resolved even when the output 

70 extends far past it. Corners ``<= 0`` map to ``0`` (no breakthrough yet). 

71 

72 Returns 

73 ------- 

74 ndarray 

75 ``G1(S; V')`` for each ``S`` in ``corner_volumes`` (same shape). 

76 """ 

77 r_p = np.sqrt(r_w**2 + v_prime / c_geo) 

78 mu = retardation_factor * c_geo * ((r_p + alpha_l) ** 2 + alpha_l**2 - r_w**2) 

79 

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

81 return ( 

82 transfer_function( 

83 s=flow_scale * p, 

84 r=r_p, 

85 r_w=r_w, 

86 alpha_l=alpha_l, 

87 a0=flow_scale / (2.0 * c_geo), 

88 d_m=molecular_diffusivity, 

89 retardation_factor=retardation_factor, 

90 inject="flux", 

91 detect="resident", 

92 ) 

93 / p 

94 ) 

95 

96 cv = np.asarray(corner_volumes, dtype=float) 

97 out = np.zeros_like(cv) 

98 positive = cv > 0.0 

99 if np.any(positive): 

100 scaling = _SCALE_MARGIN * max(mu, float(cv[positive].max())) 

101 out[positive] = dehoog_inverse(f_hat=f_hat, t=cv[positive], n_terms=_DEHOOG_TERMS, scaling=scaling) 

102 return out 

103 

104 

105def single_cycle_echo_matrix( 

106 *, 

107 inj_volume_edges: npt.NDArray[np.floating], 

108 ext_volume_edges: npt.NDArray[np.floating], 

109 c_geo: float, 

110 r_w: float, 

111 alpha_l: float, 

112 inj_flow_scale: float, 

113 ext_flow_scale: float, 

114 retardation_factor: float = 1.0, 

115 molecular_diffusivity: float = 0.0, 

116 n_quad: int = 240, 

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

118 r"""Echo weight matrix ``W`` (``cout' = W @ cin'``) for one inject-then-extract cycle, one disk. 

119 

120 The injection builds the resident profile with the FR kernel at the injection flow magnitude and 

121 the extraction reads it out at the extraction flow magnitude. For ``D_m = 0`` the flow magnitudes 

122 cancel (the S-clock kernel is flow-independent), so arbitrary within-phase variable flow is exact; 

123 for ``D_m > 0`` the flow scales must be the (constant) per-phase magnitudes. 

124 

125 Parameters 

126 ---------- 

127 inj_volume_edges : ndarray 

128 Cumulative flushed-volume edges of the injection bins (length ``n_inj + 1``), increasing. 

129 ext_volume_edges : ndarray 

130 Cumulative flushed-volume edges of the extraction (output) bins (length ``n_ext + 1``), 

131 increasing, measured from the start of extraction. 

132 c_geo : float 

133 Geometry constant ``pi b n`` so that ``V(r) = c_geo (r^2 - r_w^2)`` (m^3 per m^2). 

134 r_w : float 

135 Well radius (m). 

136 alpha_l : float 

137 Longitudinal dispersivity (m). 

138 inj_flow_scale, ext_flow_scale : float 

139 Flow magnitudes ``|Q|`` (m^3/day) of the injection and extraction phases (only relevant when 

140 ``molecular_diffusivity > 0``; ignored to within rounding for ``D_m = 0``). 

141 retardation_factor : float, optional 

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

143 molecular_diffusivity : float, optional 

144 Molecular diffusivity (m^2/day). Default 0 (Airy S-clock; exact for variable flow). 

145 n_quad : int, optional 

146 Number of Gauss-Legendre nodes for the ``V'`` superposition integral. Default 240. 

147 

148 Returns 

149 ------- 

150 ndarray, shape (n_ext, n_inj) 

151 ``W`` with ``cout'_i = sum_j W_{ij} cin'_j`` (deviation from background). 

152 """ 

153 sigma = np.asarray(inj_volume_edges, dtype=float) - inj_volume_edges[0] # 0 .. S_inj 

154 s_inj = sigma[-1] 

155 t_edges = np.asarray(ext_volume_edges, dtype=float) - ext_volume_edges[0] # 0 .. T_end 

156 dt = np.diff(t_edges) 

157 

158 # V' quadrature window: the resident profile f(V') = G1(S_inj; V') has its front at the retarded solute 

159 # radius r_front (where the FR arrival mean equals S_inj) and a dispersive tail of breakthrough width 

160 # ~sqrt(alpha_L r_front). A flat 4 S_inj/R margin is Peclet-blind and truncates that tail at low Peclet 

161 # (r_front/alpha_L << 1), losing mass (int f dV' < S_inj/R). Mirror the reuse engine's 12-sigma advective 

162 # reach plus a 6-sigma molecular reach over the cycle duration -- the same grid that conserves the mass 

163 # to ~1e-5. D_m dispatch is inside transfer_function (Airy for D_m = 0, Riccati log-derivative for D_m > 0). 

164 r_front = np.sqrt(r_w**2 + s_inj / (retardation_factor * c_geo)) 

165 total_time = s_inj / inj_flow_scale + t_edges[-1] / ext_flow_scale 

166 r_max = r_front + 12.0 * np.sqrt(alpha_l * r_front + alpha_l**2) + 6.0 * np.sqrt(molecular_diffusivity * total_time) 

167 v_max = c_geo * (r_max**2 - r_w**2) 

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

169 v_nodes = 0.5 * v_max * (nodes + 1.0) 

170 v_weights = 0.5 * v_max * weights 

171 

172 inj_corners = s_inj - sigma # G1 evaluated at S_inj - sigma_j (descending, last is 0) 

173 w = np.zeros((len(dt), len(sigma) - 1)) 

174 for v, vw in zip(v_nodes, v_weights, strict=True): 

175 g_inj = _fr_step_response( 

176 v, 

177 inj_corners, 

178 c_geo=c_geo, 

179 r_w=r_w, 

180 alpha_l=alpha_l, 

181 retardation_factor=retardation_factor, 

182 flow_scale=inj_flow_scale, 

183 molecular_diffusivity=molecular_diffusivity, 

184 ) 

185 g_ext = _fr_step_response( 

186 v, 

187 t_edges, 

188 c_geo=c_geo, 

189 r_w=r_w, 

190 alpha_l=alpha_l, 

191 retardation_factor=retardation_factor, 

192 flow_scale=ext_flow_scale, 

193 molecular_diffusivity=molecular_diffusivity, 

194 ) 

195 f_contrib = g_inj[:-1] - g_inj[1:] # length n_inj: resident-profile contribution of each cin bin 

196 ext_avg = (g_ext[1:] - g_ext[:-1]) / dt # length n_ext: bin-averaged arrival per output bin 

197 w += vw * np.outer(ext_avg, f_contrib) 

198 # Retardation amplitude: the mobile profile integrates to S_inj/R (sorbed mass is immobile) and the 

199 # arrival kernel's CDF plateaus at 1, so the bare readout under-recovers by 1/R. Each extracted 

200 # mobile parcel mobilizes its sorbed companion (total solute = R x mobile), so scale the readout by R. 

201 return retardation_factor * w