Coverage for src/gwtransport/utils.py: 90%

436 statements  

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

1""" 

2General Utilities for 1D Groundwater Transport Modeling. 

3 

4This module provides general-purpose utility functions for time series manipulation, 

5interpolation, numerical operations, and data processing used throughout the gwtransport 

6package. Functions include linear interpolation/averaging, bin overlap calculations, 

7underdetermined system solvers, and external data retrieval. 

8 

9Available functions: 

10 

11- :func:`step_plot_coords` - Compute step-plot coordinates from bin edges and 

12 bin-averaged values. Returns paired x/y arrays for plotting piecewise-constant 

13 functions with ``ax.plot(x, y)``. 

14 

15- ``_make_strictly_monotone`` (private) - Bump consecutive duplicates in a non-decreasing 

16 array by ``k * ulp(max)`` so it becomes strictly monotone. Used before V → t inversions to 

17 prevent ``np.interp`` from silently picking one limit at plateau levels. 

18 

19- :func:`cumulative_flow_volume` - Cumulative infiltrated/extracted volume from per-bin flow 

20 rates and bin widths, prepended with a leading zero. Optionally bumped to strict 

21 monotonicity for V → t inversions. 

22 

23- :func:`linear_interpolate` - Linear interpolation using numpy's optimized interp function. 

24 Automatically handles unsorted data with configurable extrapolation (None for clamping, 

25 float for constant values). Handles multi-dimensional query arrays. 

26 

27- :func:`linear_average` - Compute average values of piecewise linear time series between 

28 specified x-edges. Supports 1D or 2D edge arrays for batch processing. Handles NaN values 

29 and offers multiple extrapolation methods ('nan', 'outer', 'raise'). 

30 

31- :func:`time_bin_overlap` - Calculate fraction of time bins overlapping with specified time 

32 ranges. Similar to partial_isin but for time-based bin overlaps with list of (start, end) 

33 tuples. 

34 

35- :func:`simplify_bins` - Simplify a piecewise-constant time series by merging adjacent bins 

36 whose values are within a tolerance. Uses volume-weighted (flow x width) averaging when 

37 flow is provided, otherwise width-weighted. Direction-independent via largest-jump splitting. 

38 

39- :func:`compute_time_edges` - Compute DatetimeIndex of time bin edges from explicit edges, 

40 start times, or end times. Validates consistency with expected number of bins and handles 

41 uniform spacing extrapolation. 

42 

43The inverse solvers below are two intentionally coexisting families: a Tikhonov family (the dense 

44:func:`solve_inverse_transport` and its banded equivalent :func:`solve_inverse_transport_banded`, 

45both fed by :func:`compute_reverse_target` and built on :func:`solve_tikhonov`) for the 

46overdetermined deconvolution in advection/diffusion, and a separate nullspace solver 

47(:func:`solve_underdetermined_system`) for the underdetermined deposition inverse. 

48 

49- :func:`solve_tikhonov` - Solve linear system with Tikhonov regularization toward a target. 

50 Well-determined modes follow the data; poorly-determined modes are pulled toward the target. 

51 

52- :func:`compute_reverse_target` - Build the regularization target for the inverse problem by 

53 transposing and row-normalizing the forward coefficient matrix. Consumed by 

54 :func:`solve_tikhonov` and :func:`solve_inverse_transport`. 

55 

56- :func:`solve_inverse_transport` - Solve the inverse transport problem (deconvolution) via 

57 Tikhonov regularization. Shared by advection, diffusion, and diffusion_fast 

58 ``extraction_to_infiltration`` functions. 

59 

60- :func:`solve_inverse_transport_banded` - Memory-light banded equivalent of 

61 :func:`solve_inverse_transport` for a forward operator stored in banded layout. Assembles the 

62 Tikhonov normal equations directly in banded form and solves them via banded Cholesky. 

63 

64- :func:`solve_underdetermined_system` - Solve underdetermined linear system (Ax = b, m < n) 

65 with nullspace regularization. Handles NaN values by row exclusion. Supports built-in 

66 objectives ('squared_differences', 'summed_differences') or custom callable objectives. 

67 Used by :mod:`gwtransport.deposition`. 

68 

69- :func:`get_soil_temperature` - Download soil temperature data from KNMI weather stations with 

70 automatic caching. Supports stations 260 (De Bilt), 273 (Marknesse), 286 (Nieuw Beerta), 

71 323 (Wilhelminadorp). Returns DataFrame with columns TB1-TB5, TNB1-TNB2, TXB1-TXB2 at various 

72 depths. Daily cache prevents redundant downloads. 

73 

74- ``_generate_failed_coverage_badge`` (private) - Generate SVG badge indicating failed coverage 

75 using genbadge library. Used in CI/CD workflows. 

76 

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

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

79""" 

80 

81from __future__ import annotations 

82 

83import io 

84import warnings 

85from collections.abc import Callable 

86from datetime import date 

87from pathlib import Path 

88 

89import numpy as np 

90import numpy.typing as npt 

91import pandas as pd 

92from scipy.linalg import cho_solve_banded, cholesky_banded, null_space 

93from scipy.optimize import minimize 

94 

95cache_dir = Path(__file__).parent.parent.parent / "cache" 

96 

97 

98def step_plot_coords(edges: npt.ArrayLike, values: npt.ArrayLike) -> tuple[npt.NDArray, npt.NDArray]: 

99 """Compute step-plot coordinates from bin edges and bin-averaged values. 

100 

101 Converts bin edges (n+1) and bin values (n) into paired x/y arrays 

102 suitable for plotting piecewise-constant (step) functions with 

103 ``ax.plot(x, y)``. 

104 

105 Parameters 

106 ---------- 

107 edges : array-like 

108 Bin edges (n+1 elements for n bins). Can be numeric, datetime, or 

109 any type accepted by :func:`numpy.repeat`. 

110 values : array-like 

111 Bin-averaged values (n elements), one per bin. 

112 

113 Returns 

114 ------- 

115 x : ndarray 

116 Step x-coordinates (2n elements). Same dtype as *edges*. 

117 y : ndarray 

118 Step y-coordinates (2n elements). Same dtype as *values*. 

119 

120 Examples 

121 -------- 

122 >>> import numpy as np 

123 >>> edges = np.array([0.0, 1.0, 3.0, 6.0]) 

124 >>> values = np.array([2.0, 5.0, 1.0]) 

125 >>> x, y = step_plot_coords(edges, values) 

126 >>> x 

127 array([0., 1., 1., 3., 3., 6.]) 

128 >>> y 

129 array([2., 2., 5., 5., 1., 1.]) 

130 """ 

131 x = np.repeat(edges, 2)[1:-1] 

132 y = np.repeat(values, 2) 

133 return x, y 

134 

135 

136_DUP_BUMP_ULPS = 16 # safety factor in ulps; see _make_strictly_monotone docstring 

137 

138 

139def _make_strictly_monotone(arr: npt.ArrayLike) -> npt.NDArray[np.floating]: 

140 """Bump consecutive duplicates so a non-decreasing array becomes strictly monotone. 

141 

142 Returns the input unchanged if no consecutive duplicates are present. Otherwise returns a 

143 new array with each duplicate bumped up by ``k * step``, where ``k`` is its 1-based 

144 position within the consecutive duplicate run and ``step`` is ``16 * ulp(max(arr))`` 

145 capped per run so the largest bump stays strictly below the next genuine value above the 

146 plateau (``step = min(16 * ulp(max(arr)), gap / (run_len + 1))``). The cap prevents a long 

147 run from overshooting a closely-spaced successor; a gap narrower than the run length in 

148 ulps is unrepresentable and cannot be separated. 

149 

150 The factor of 16 is a safety margin against IEEE 754 rounding noise in ``np.interp``'s 

151 linear-interpolation arithmetic, which differs subtly between Linux x86_64 (with FMA) 

152 and ARM macOS. A 1-ulp gap, while strictly monotone, can place a downstream query value 

153 on the wrong side of a bracket boundary if the intermediate arithmetic rounds 1 ulp away 

154 from the exact value. 16 ulps ensures the bracket selection is unambiguous on every 

155 platform we support. The perturbation is relative to the array scale: 

156 ``bump ≈ 16 * ulp(max(arr)) ≈ 3.5e-15 * max(arr)``, i.e. about 15 significant digits 

157 below the data scale and well below physical relevance. The absolute size therefore grows 

158 with the cumulative-volume magnitude (e.g. ``~1e-13`` only for ``max(arr) ~ 30``). 

159 

160 Parameters 

161 ---------- 

162 arr : array-like 

163 1D non-decreasing array (e.g., a cumulative volume sequence ``flow_cum`` that contains 

164 plateaus from ``Q = 0`` bins). 

165 

166 Returns 

167 ------- 

168 ndarray 

169 Strictly monotone array of the same length. 

170 

171 Notes 

172 ----- 

173 Use this before passing ``arr`` as ``x_ref`` to a ``V → t`` inversion via 

174 :func:`linear_interpolate` or :func:`numpy.interp`. Plateaus in ``arr`` make ``arr⁻¹`` 

175 multi-valued, and ``np.interp`` would silently pick one of the two limits, biasing 

176 integrals over output bins that span the kink. 

177 """ 

178 arr = np.asarray(arr, dtype=float) 

179 diffs = np.diff(arr) 

180 if not np.any(diffs == 0): 

181 return arr 

182 ulp_max = np.nextafter(arr.max(), np.inf) - arr.max() 

183 n = len(arr) 

184 idx = np.arange(n) 

185 is_dup = np.concatenate(([False], diffs == 0)) 

186 # 1-based position of each duplicate within its consecutive run. 

187 last_nondup = np.maximum.accumulate(np.where(is_dup, -1, idx)) 

188 cumcount = np.where(is_dup, idx - last_nondup, 0) 

189 

190 # Per-run headroom: each bumped value must stay strictly below the next genuine 

191 # (non-duplicate) value above the plateau, otherwise a long run can overshoot a 

192 # closely-spaced next value and break monotonicity. ``next_nondup_idx`` is the first 

193 # non-duplicate index after each position (``n`` when the run reaches the array end, where 

194 # there is no successor and hence no overshoot risk). The gap to that successor caps the 

195 # bump step so the last (largest) bump in a run of length L is at most ``L/(L+1)`` of the 

196 # gap. A gap narrower than the run length in ulps is unrepresentable and cannot be split. 

197 next_nondup_idx = np.minimum.accumulate(np.where(is_dup, n, idx)[::-1])[::-1] 

198 has_successor = next_nondup_idx < n 

199 gap_to_next = arr[np.clip(next_nondup_idx, 0, n - 1)] - arr[idx] 

200 run_len = next_nondup_idx - last_nondup - 1 

201 full_step = _DUP_BUMP_ULPS * ulp_max 

202 with np.errstate(invalid="ignore", divide="ignore"): 

203 capped_step = np.where(has_successor, np.minimum(full_step, gap_to_next / (run_len + 1.0)), full_step) 

204 bump = np.where(is_dup, cumcount * capped_step, 0.0) 

205 return arr + bump 

206 

207 

208def cumulative_flow_volume( 

209 flow: npt.ArrayLike, dt_days: npt.ArrayLike, *, strictly_monotone: bool = False 

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

211 """Cumulative infiltrated/extracted volume from per-bin flow rates. 

212 

213 Multiplies each per-bin flow rate by its bin width and accumulates, with a 

214 leading zero prepended so the result has one entry per bin edge (n+1 values 

215 for n bins). The result is the cumulative volume ``V`` at each time edge. 

216 

217 Parameters 

218 ---------- 

219 flow : array-like 

220 Flow rate per bin (m³/day), length n. 

221 dt_days : array-like 

222 Bin widths in days, length n (e.g. ``numpy.diff`` of edge days). 

223 strictly_monotone : bool, optional 

224 When ``True``, bump consecutive duplicates (plateaus from ``Q = 0`` 

225 bins) via ``_make_strictly_monotone`` so the cumulative volume is 

226 strictly increasing. Required before a V → t inversion; leave ``False`` 

227 when the plateaus must be preserved. Default is ``False``. 

228 

229 Returns 

230 ------- 

231 ndarray 

232 Cumulative volume at each edge (length ``len(flow) + 1``), starting at 

233 zero. 

234 

235 See Also 

236 -------- 

237 ``_make_strictly_monotone`` : Bump duplicates before V → t inversion. 

238 """ 

239 flow_cum = np.concatenate(([0.0], np.cumsum(np.asarray(flow) * np.asarray(dt_days)))) 

240 return _make_strictly_monotone(flow_cum) if strictly_monotone else flow_cum 

241 

242 

243def linear_interpolate( 

244 *, 

245 x_ref: npt.ArrayLike, 

246 y_ref: npt.ArrayLike, 

247 x_query: npt.ArrayLike, 

248 left: float | None = None, 

249 right: float | None = None, 

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

251 """ 

252 Linear interpolation using numpy's optimized interp function. 

253 

254 Automatically handles unsorted reference data by sorting it first. 

255 

256 Parameters 

257 ---------- 

258 x_ref : array-like 

259 Reference x-values. If unsorted, will be automatically sorted. 

260 y_ref : array-like 

261 Reference y-values corresponding to x_ref. 

262 x_query : array-like 

263 Query x-values where interpolation is needed. Array may have any shape. 

264 left : float, optional 

265 Value to return for x_query < x_ref[0]. 

266 

267 - If ``left=None``: clamp to y_ref[0] (default) 

268 - If ``left=float``: use specified value (e.g., ``np.nan``) 

269 

270 right : float, optional 

271 Value to return for x_query > x_ref[-1]. 

272 

273 - If ``right=None``: clamp to y_ref[-1] (default) 

274 - If ``right=float``: use specified value (e.g., ``np.nan``) 

275 

276 Returns 

277 ------- 

278 ndarray 

279 Interpolated y-values with the same shape as x_query. 

280 

281 Examples 

282 -------- 

283 Basic interpolation with clamping (default): 

284 

285 >>> import numpy as np 

286 >>> from gwtransport.utils import linear_interpolate 

287 >>> x_ref = np.array([1.0, 2.0, 3.0, 4.0]) 

288 >>> y_ref = np.array([10.0, 20.0, 30.0, 40.0]) 

289 >>> x_query = np.array([0.5, 1.5, 2.5, 3.5, 4.5]) 

290 >>> linear_interpolate(x_ref=x_ref, y_ref=y_ref, x_query=x_query) 

291 array([10., 15., 25., 35., 40.]) 

292 

293 Using NaN for extrapolation: 

294 

295 >>> linear_interpolate( 

296 ... x_ref=x_ref, y_ref=y_ref, x_query=x_query, left=np.nan, right=np.nan 

297 ... ) 

298 array([nan, 15., 25., 35., nan]) 

299 

300 Handles unsorted reference data automatically: 

301 

302 >>> x_unsorted = np.array([3.0, 1.0, 4.0, 2.0]) 

303 >>> y_unsorted = np.array([30.0, 10.0, 40.0, 20.0]) 

304 >>> linear_interpolate(x_ref=x_unsorted, y_ref=y_unsorted, x_query=x_query) 

305 array([10., 15., 25., 35., 40.]) 

306 """ 

307 x_ref = np.asarray(x_ref) 

308 y_ref = np.asarray(y_ref) 

309 x_query = np.asarray(x_query) 

310 

311 sort_idx = np.argsort(x_ref) 

312 x_ref_sorted = x_ref[sort_idx] 

313 y_ref_sorted = y_ref[sort_idx] 

314 

315 return np.interp(x_query, x_ref_sorted, y_ref_sorted, left=left, right=right) 

316 

317 

318def linear_average( 

319 *, 

320 x_data: npt.ArrayLike, 

321 y_data: npt.ArrayLike, 

322 x_edges: npt.ArrayLike, 

323 extrapolate_method: str = "nan", 

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

325 """ 

326 Compute the average value of a piecewise linear time series between specified x-edges. 

327 

328 Parameters 

329 ---------- 

330 x_data : array-like 

331 x-coordinates of the time series data points, must be in ascending order. 

332 y_data : array-like 

333 y-coordinates of the time series data points. Can be 1D or 2D. 

334 

335 - If 1D: shape ``(n_data,)`` -- a single series. 

336 - If 2D: shape ``(n_series_y, n_data)`` -- multiple series sharing the same 

337 ``x_data``. The leading axis is averaged independently per row. Cannot be 

338 combined with 2D ``x_edges`` (each row of ``x_edges`` and each row of 

339 ``y_data`` would otherwise have to broadcast against each other, which is 

340 not supported). 

341 x_edges : array-like 

342 x-coordinates of the integration edges. 

343 

344 - If 1D: shape ``(n_edges,)``, must be in ascending order. 

345 - If 2D: shape ``(n_series_x, n_edges)``, each row must be in ascending order. 

346 extrapolate_method : str, optional 

347 Method for handling bin edges that fall outside ``x_data``. Default 

348 is ``'nan'``. 

349 

350 - ``'outer'``: average over the **in-range** portion of each bin 

351 (clip-then-average). The bin width used for normalisation is the 

352 clipped width, not the original width. For example, 

353 ``x_data = y_data = [1, 2, 3]`` and ``x_edges = [0, 5]`` returns 

354 ``2.0`` (integral over ``[1, 3]`` divided by clipped width 2), 

355 **not** ``2.2`` (which a constant-extension scheme would give). 

356 - ``'nan'``: bins that extend outside ``x_data`` are returned as ``nan``. 

357 - ``'raise'``: raise an error if any bin edge falls outside ``x_data``. 

358 

359 Returns 

360 ------- 

361 ndarray 

362 2D array of average values between consecutive pairs of x_edges. 

363 Shape is ``(n_series, n_bins)`` where ``n_bins = n_edges - 1`` and 

364 ``n_series = max(n_series_x, n_series_y)``. Both ``x_edges`` and ``y_data`` 

365 being 1D yields ``n_series = 1``. 

366 

367 Raises 

368 ------ 

369 ValueError 

370 If ``x_edges`` is not 1D or 2D. If ``y_data`` is not 1D or 2D. If both 

371 ``x_edges`` and ``y_data`` are 2D. If ``x_data`` and ``y_data`` have 

372 incompatible shapes or are empty. If ``x_edges`` has fewer than 2 values per 

373 row. If ``x_data`` is not in ascending order. If ``x_edges`` rows are not in 

374 ascending order. If ``extrapolate_method`` is ``'raise'`` and any edge falls 

375 outside the data range. 

376 

377 Notes 

378 ----- 

379 **NaN handling is asymmetric between 1D and 2D ``y_data``.** 

380 

381 - 1D ``y_data`` is treated as a single series; internal NaN gaps are 

382 silently bridged by linear interpolation across the gap (via 

383 ``np.interp`` with ``left=nan, right=nan``). 

384 - 2D ``y_data`` is treated row-wise; any output bin whose 

385 ``[edge_left, edge_right]`` touches a NaN segment **in that row** is 

386 set to NaN, while other rows are unaffected. 

387 

388 Callers that need NaN-bridging behaviour across multiple series must 

389 pre-fill (e.g., ``pd.DataFrame.interpolate``) before calling. 

390 

391 Examples 

392 -------- 

393 >>> import numpy as np 

394 >>> from gwtransport.utils import linear_average 

395 >>> x_data = [0, 1, 2, 3] 

396 >>> y_data = [0, 1, 1, 0] 

397 >>> x_edges = [0, 1.5, 3] 

398 >>> linear_average( 

399 ... x_data=x_data, y_data=y_data, x_edges=x_edges 

400 ... ) # doctest: +ELLIPSIS 

401 array([[0.666..., 0.666...]]) 

402 

403 >>> x_edges_2d = [[0, 1.5, 3], [0.5, 2, 3]] 

404 >>> linear_average(x_data=x_data, y_data=y_data, x_edges=x_edges_2d) 

405 array([[0.66666667, 0.66666667], 

406 [0.91666667, 0.5 ]]) 

407 

408 Multiple y-series with shared x_data and x_edges: 

409 

410 >>> y_data_2d = [[0, 1, 1, 0], [0, 2, 2, 0]] 

411 >>> linear_average(x_data=x_data, y_data=y_data_2d, x_edges=x_edges) 

412 array([[0.66666667, 0.66666667], 

413 [1.33333333, 1.33333333]]) 

414 """ 

415 # Convert inputs to numpy arrays 

416 x_data = np.asarray(x_data, dtype=float) 

417 y_data = np.asarray(y_data, dtype=float) 

418 x_edges = np.asarray(x_edges, dtype=float) 

419 

420 # Ensure x_edges is always 2D 

421 if x_edges.ndim == 1: 

422 x_edges = x_edges[np.newaxis, :] 

423 elif x_edges.ndim != 2: # noqa: PLR2004 

424 msg = "x_edges must be 1D or 2D array" 

425 raise ValueError(msg) 

426 

427 # Ensure y_data is always 2D internally with shape (n_series_y, n_data) 

428 if y_data.ndim == 1: 

429 y_data = y_data[np.newaxis, :] 

430 elif y_data.ndim != 2: # noqa: PLR2004 

431 msg = "y_data must be 1D or 2D array" 

432 raise ValueError(msg) 

433 

434 # 2D y_data requires 1D x_edges (no per-row x_edges allowed). The combination would 

435 # require an outer product over (n_series_x, n_series_y), which is intentionally 

436 # not supported -- callers can loop or stack instead. 

437 n_series_x = x_edges.shape[0] 

438 n_series_y = y_data.shape[0] 

439 if n_series_x > 1 and n_series_y > 1: 

440 msg = "Cannot combine 2D x_edges with 2D y_data" 

441 raise ValueError(msg) 

442 n_series = max(n_series_x, n_series_y) 

443 

444 # Input validation 

445 if y_data.shape[1] != x_data.shape[0] or x_data.shape[0] == 0: 

446 msg = "x_data and y_data must have the same length and be non-empty" 

447 raise ValueError(msg) 

448 if x_edges.shape[1] < 2: # noqa: PLR2004 

449 msg = "x_edges must contain at least 2 values in each row" 

450 raise ValueError(msg) 

451 if not np.all(np.diff(x_data) >= 0): 

452 msg = "x_data must be in ascending order" 

453 raise ValueError(msg) 

454 if not np.all(np.diff(x_edges, axis=1) >= 0): 

455 msg = "x_edges must be in ascending order along each row" 

456 raise ValueError(msg) 

457 

458 # Filter out NaN values. With 2D y_data, a column is dropped only when all rows 

459 # have NaN there; per-row NaNs are handled via segment masking below so that one 

460 # series' NaNs do not contaminate the others. 

461 x_nan = np.isnan(x_data) 

462 y_any_finite = np.any(~np.isnan(y_data), axis=0) 

463 show = ~x_nan & y_any_finite 

464 if show.sum() < 2: # noqa: PLR2004 

465 if show.sum() == 1 and extrapolate_method == "outer": 

466 # For a single retained data point with outer extrapolation, use the 

467 # row-wise value broadcast across all output bins. 

468 constant_value = y_data[:, show][:, 0] # shape (n_series_y,) 

469 return np.broadcast_to(constant_value[:, None], (n_series, x_edges.shape[1] - 1)).astype( 

470 np.float64, copy=True 

471 ) 

472 return np.full(shape=(n_series, x_edges.shape[1] - 1), fill_value=np.nan) 

473 

474 x_data_clean = x_data[show] 

475 y_data_clean = y_data[:, show] # shape (n_series_y, n_clean) 

476 

477 # Handle extrapolation for all series at once (vectorized). The 'raise' and 'nan' 

478 # branches never mutate edges_processed, so they alias x_edges directly; 'outer' 

479 # produces a fresh clipped array. 

480 if extrapolate_method == "outer": 

481 edges_processed = np.clip(x_edges, x_data_clean[0], x_data_clean[-1]) 

482 elif extrapolate_method == "raise": 

483 if np.any(x_edges < x_data_clean[0]) or np.any(x_edges > x_data_clean[-1]): 

484 msg = "x_edges must be within the range of x_data" 

485 raise ValueError(msg) 

486 edges_processed = x_edges 

487 else: # nan method 

488 edges_processed = x_edges 

489 

490 # Create a combined grid of all unique x points (data + all edges) 

491 all_unique_x = np.unique(np.concatenate([x_data_clean, edges_processed.ravel()])) 

492 

493 # Interpolate y values at all unique x points once. For 2D y_data we vectorize 

494 # the linear interpolation manually since np.interp does not accept 2D y. 

495 if n_series_y == 1: 

496 all_unique_y_result = np.interp(all_unique_x, x_data_clean, y_data_clean[0], left=np.nan, right=np.nan) 

497 all_unique_y: npt.NDArray[np.floating] = np.asarray(all_unique_y_result, dtype=np.float64)[np.newaxis, :] 

498 else: 

499 # Locate each query x in x_data_clean. For x within the data range, idx is in 

500 # [1, len(x_data_clean) - 1] so left_idx = idx - 1 is the bracketing left index. 

501 idx = np.searchsorted(x_data_clean, all_unique_x).clip(1, len(x_data_clean) - 1) 

502 left_idx = idx - 1 

503 right_idx = idx 

504 x_left = x_data_clean[left_idx] 

505 x_right = x_data_clean[right_idx] 

506 denom = x_right - x_left 

507 # Detect query points coincident with an x_data point. Handling them via a 

508 # direct lookup avoids the IEEE 754 trap where NaN * 0 = NaN, which would 

509 # otherwise contaminate exact-endpoint queries adjacent to a NaN sample. 

510 on_left_node = denom == 0 # only happens if x_left == x_right (duplicate) 

511 weights = np.where(on_left_node, 0.0, (all_unique_x - x_left) / np.where(on_left_node, 1.0, denom)) 

512 all_unique_y = y_data_clean[:, left_idx] * (1.0 - weights) + y_data_clean[:, right_idx] * weights 

513 # Override at exact x_data positions to avoid NaN * 0 contamination. 

514 is_left_match = all_unique_x == x_left 

515 is_right_match = all_unique_x == x_right 

516 all_unique_y[:, is_left_match] = y_data_clean[:, left_idx[is_left_match]] 

517 all_unique_y[:, is_right_match] = y_data_clean[:, right_idx[is_right_match]] 

518 # Mark out-of-range query points as NaN (matches np.interp(left=nan, right=nan)). 

519 out_of_range = (all_unique_x < x_data_clean[0]) | (all_unique_x > x_data_clean[-1]) 

520 all_unique_y[:, out_of_range] = np.nan 

521 

522 # Compute cumulative integrals once using trapezoidal rule. 

523 # Segments outside the data range carry NaN (from the interp step with left/right=NaN); 

524 # those NaNs will be masked out later via the bin-range check, so we suppress 

525 # them here only to keep the cumulative sum finite for in-range bins. 

526 dx = np.diff(all_unique_x) 

527 y_avg = (all_unique_y[:, :-1] + all_unique_y[:, 1:]) / 2 

528 segment_integrals = np.where(np.isnan(y_avg), 0.0, dx[np.newaxis, :] * y_avg) 

529 # Cumulative integral with leading 0 along the x axis. 

530 cumulative_integral = np.concatenate([np.zeros((y_avg.shape[0], 1)), np.cumsum(segment_integrals, axis=1)], axis=1) 

531 

532 # Vectorized computation for all series 

533 # Find indices of all edges in the combined grid 

534 edge_indices_result = np.searchsorted(all_unique_x, edges_processed) 

535 # Ensure it's a 2D array for type checker 

536 edge_indices: npt.NDArray[np.intp] = np.asarray(edge_indices_result, dtype=np.intp).reshape(edges_processed.shape) 

537 

538 # Compute integral between consecutive edges. Broadcast over n_series via the leading axis 

539 # of cumulative_integral. edge_indices is (n_series_x, n_bins+1); cumulative_integral is 

540 # (n_series_y, n_unique_x). We rely on n_series_x == 1 or n_series_y == 1 (enforced above). 

541 integral_values = cumulative_integral[:, edge_indices[:, 1:]] - cumulative_integral[:, edge_indices[:, :-1]] 

542 # integral_values has shape (n_series_y, n_series_x, n_bins). Squeeze the singleton. 

543 integral_values_2d = integral_values[0] if n_series_y == 1 else integral_values[:, 0, :] 

544 

545 # Compute widths between consecutive edges for all series (vectorized) 

546 edge_widths = np.diff(edges_processed, axis=1) # shape (n_series_x, n_bins) 

547 # Broadcast widths to match (n_series, n_bins) 

548 edge_widths_b = np.broadcast_to(edge_widths, (n_series, edge_widths.shape[1])) if n_series_y > 1 else edge_widths 

549 

550 # Handle zero-width intervals (vectorized) 

551 zero_width_mask = edge_widths_b == 0 

552 result = np.zeros_like(edge_widths_b, dtype=np.float64) 

553 

554 # For non-zero width intervals, compute average = integral / width (vectorized) 

555 non_zero_mask = ~zero_width_mask 

556 result[non_zero_mask] = integral_values_2d[non_zero_mask] / edge_widths_b[non_zero_mask] 

557 

558 # For zero-width intervals, interpolate y-value directly (vectorized) 

559 if np.any(zero_width_mask): 

560 # Positions where zero width occurs; use the left edge's x position. 

561 if n_series_y == 1: 

562 zero_positions = edges_processed[:, :-1][zero_width_mask] # 1D 

563 result[zero_width_mask] = np.interp(zero_positions, x_data_clean, y_data_clean[0]) 

564 else: 

565 # zero_width_mask has shape (n_series_y, n_bins); positions vary per row. 

566 # edges_processed is (1, n_bins+1) here since n_series_x == 1. 

567 edges_left = np.broadcast_to(edges_processed[:, :-1], (n_series, edge_widths.shape[1])) 

568 zero_positions = edges_left[zero_width_mask] 

569 # Interpolate per series using the same x_data_clean. Find bracketing indices 

570 # for each zero-width position, then index into the appropriate y row. 

571 # Get the row index for each zero-width entry. 

572 row_idx_grid = np.broadcast_to(np.arange(n_series)[:, None], (n_series, edge_widths.shape[1])) 

573 zero_rows = row_idx_grid[zero_width_mask] 

574 idx_z = np.searchsorted(x_data_clean, zero_positions).clip(1, len(x_data_clean) - 1) 

575 xl = x_data_clean[idx_z - 1] 

576 xr = x_data_clean[idx_z] 

577 denom_z = np.where(xr == xl, 1.0, xr - xl) 

578 w_z = (zero_positions - xl) / denom_z 

579 yl = y_data_clean[zero_rows, idx_z - 1] 

580 yr = y_data_clean[zero_rows, idx_z] 

581 result[zero_width_mask] = yl * (1.0 - w_z) + yr * w_z 

582 

583 # Handle extrapolation when 'nan' method is used (vectorized). 

584 # Bins must lie entirely within the data range; bins partially outside 

585 # (straddling) are also set to NaN, since the integral over the missing 

586 # portion is undefined and dividing by the full bin width would bias the 

587 # average low. Bins fully outside are likewise NaN. 

588 if extrapolate_method == "nan": 

589 bins_within_range = (x_edges[:, :-1] >= x_data_clean[0]) & (x_edges[:, 1:] <= x_data_clean[-1]) 

590 if n_series_y > 1: 

591 bins_within_range = np.broadcast_to(bins_within_range, (n_series, bins_within_range.shape[1])) 

592 result[~bins_within_range] = np.nan 

593 

594 # With 2D y_data, propagate per-row NaNs from the y series itself: any output bin that 

595 # touches an x_data segment with NaN y in this row must be NaN. This 2-D NaN contract is 

596 # method-independent -- it also holds for 'outer'/'raise', which would otherwise return a 

597 # silently wrong finite average (the NaN trapezoids were zeroed above). Per-row NaN info is 

598 # preserved in y_avg; mark bins whose spanned segments contain a NaN segment for this row. 

599 if n_series_y > 1: 

600 seg_nan = np.isnan(y_avg) # shape (n_series_y, n_unique_x - 1) 

601 seg_nan_cum = np.concatenate([np.zeros((n_series_y, 1)), np.cumsum(seg_nan, axis=1)], axis=1) 

602 nan_count_per_bin = seg_nan_cum[:, edge_indices[0, 1:]] - seg_nan_cum[:, edge_indices[0, :-1]] 

603 result[nan_count_per_bin > 0] = np.nan 

604 

605 return result 

606 

607 

608def time_bin_overlap(*, tedges: npt.ArrayLike, bin_tedges: list[tuple]) -> npt.NDArray[np.floating]: 

609 """ 

610 Calculate the fraction of each time bin that overlaps with each time range. 

611 

612 This function computes an array where element (i, j) represents the fraction 

613 of time bin j that overlaps with time range i. The computation uses 

614 vectorized operations to avoid loops. 

615 

616 Parameters 

617 ---------- 

618 tedges : array-like 

619 1D array of time bin edges in ascending order. For n bins, there 

620 should be n+1 edges. 

621 bin_tedges : list of tuple 

622 List of tuples where each tuple contains ``(start_time, end_time)`` 

623 defining a time range. 

624 

625 Returns 

626 ------- 

627 overlap_array : ndarray 

628 Array of shape (len(bin_tedges), n_bins) where n_bins is the number of 

629 time bins. Each element (i, j) represents the fraction of time bin j 

630 that overlaps with time range i. Values range from 0 (no overlap) to 

631 1 (complete overlap). 

632 

633 Raises 

634 ------ 

635 ValueError 

636 If ``tedges`` is not a 1D array, has fewer than 2 elements, or if 

637 ``bin_tedges`` is empty. 

638 

639 Notes 

640 ----- 

641 - tedges must be sorted in ascending order 

642 - Uses vectorized operations to handle large arrays efficiently 

643 - Time ranges in bin_tedges can be in any order and can overlap 

644 

645 Examples 

646 -------- 

647 >>> import numpy as np 

648 >>> from gwtransport.utils import time_bin_overlap 

649 >>> tedges = np.array([0, 10, 20, 30]) 

650 >>> bin_tedges = [(5, 15), (25, 35)] 

651 >>> time_bin_overlap( 

652 ... tedges=tedges, bin_tedges=bin_tedges 

653 ... ) # doctest: +NORMALIZE_WHITESPACE 

654 array([[0.5, 0.5, 0. ], 

655 [0. , 0. , 0.5]]) 

656 """ 

657 # Convert inputs to numpy arrays 

658 tedges = np.asarray(tedges) 

659 bin_tedges_array = np.asarray(bin_tedges) 

660 

661 # Validate inputs 

662 if tedges.ndim != 1: 

663 msg = "tedges must be a 1D array" 

664 raise ValueError(msg) 

665 if len(tedges) < 2: # noqa: PLR2004 

666 msg = "tedges must have at least 2 elements" 

667 raise ValueError(msg) 

668 if bin_tedges_array.size == 0: 

669 msg = "bin_tedges must be non-empty" 

670 raise ValueError(msg) 

671 

672 # Normalize datetime-like inputs (datetime64 or object arrays of Timestamps/datetimes) to a 

673 # common int64-nanosecond float scale so numeric, datetime64, and Timestamp inputs share one 

674 # arithmetic path; ``np.maximum(0, Timedelta)`` on an object array would otherwise raise. Only 

675 # differences enter the result, so the shared epoch origin cancels and the fractions are exact. 

676 if not np.issubdtype(tedges.dtype, np.number): 

677 tedges = pd.DatetimeIndex(tedges).asi8.astype(float) 

678 if not np.issubdtype(bin_tedges_array.dtype, np.number): 

679 flat = pd.DatetimeIndex(bin_tedges_array.ravel()).asi8.astype(float) 

680 bin_tedges_array = flat.reshape(bin_tedges_array.shape) 

681 

682 # Calculate overlaps for all combinations using broadcasting 

683 overlap_left = np.maximum(bin_tedges_array[:, [0]], tedges[None, :-1]) 

684 overlap_right = np.minimum(bin_tedges_array[:, [1]], tedges[None, 1:]) 

685 overlap_widths = np.maximum(0, overlap_right - overlap_left) 

686 

687 # Calculate fractions (handle division by zero for zero-width bins) 

688 bin_width_bc = np.diff(tedges)[None, :] # Shape: (1, n_bins) 

689 

690 return np.divide( 

691 overlap_widths, bin_width_bc, out=np.zeros_like(overlap_widths, dtype=float), where=bin_width_bc != 0.0 

692 ) 

693 

694 

695def simplify_bins( 

696 *, 

697 edges: npt.ArrayLike, 

698 values: npt.ArrayLike, 

699 flow: npt.ArrayLike | None = None, 

700 tol: float = 0.0, 

701) -> tuple[ 

702 npt.NDArray[np.floating] | pd.DatetimeIndex, 

703 npt.NDArray[np.floating], 

704 npt.NDArray[np.floating] | None, 

705]: 

706 """Simplify a piecewise-constant time series by merging adjacent bins. 

707 

708 Splits at the largest value jump until the peak-to-peak range within 

709 every group does not exceed `tol`. The result is independent of scan 

710 direction. 

711 

712 Parameters 

713 ---------- 

714 edges : array-like 

715 Bin edges with shape ``(n+1,)``. May be numeric or pandas Timestamps. 

716 values : array-like 

717 Bin-averaged values with shape ``(n,)`` (e.g., concentrations). 

718 flow : array-like, optional 

719 Flow rate per bin with shape ``(n,)`` (e.g., m³/day). When provided, 

720 merged-bin values are weighted by volume (flow x bin width) instead of 

721 bin width alone. 

722 tol : float, optional 

723 Maximum peak-to-peak range within a merged group. 

724 Default is 0.0, which merges only runs of identical values. 

725 

726 Returns 

727 ------- 

728 new_edges : ndarray or DatetimeIndex 

729 Simplified bin edges with shape ``(m+1,)``, preserving the type of 

730 `edges`. 

731 new_values : ndarray of float 

732 Volume-weighted (or width-weighted) average values per simplified 

733 bin, with shape ``(m,)``. 

734 new_flow : ndarray of float or None 

735 Time-weighted (width-weighted) average flow per simplified bin, with 

736 shape ``(m,)``. None when `flow` is not provided. 

737 """ 

738 edges = np.asarray(edges) if not isinstance(edges, pd.DatetimeIndex) else edges 

739 values = np.asarray(values, dtype=float) 

740 if len(values) == 0: 

741 flow_out = np.asarray(flow, dtype=float) if flow is not None else None 

742 return edges, values, flow_out 

743 

744 widths = np.asarray(np.diff(edges), dtype=float) 

745 if flow is not None: 

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

747 weights = widths * flow 

748 else: 

749 weights = widths 

750 

751 # Iteratively split each segment at its largest value jump until every group's peak-to-peak 

752 # range is within tol. An explicit LIFO stack replaces the natural recursion, which peels one 

753 # element per level on smooth monotone data (argmax|diff| sits at a segment edge) and overflows 

754 # the interpreter stack for a few thousand points. Every split index is interior to its 

755 # (disjoint) segment, so sorting the collected splits reproduces the recursion's in-order 

756 # output exactly -- the merged bins are identical. 

757 splits: list[int] = [] 

758 stack: list[tuple[int, int]] = [(0, len(values))] 

759 while stack: 

760 lo, hi = stack.pop() 

761 if np.ptp(values[lo:hi]) <= tol: 

762 continue 

763 i = lo + int(np.argmax(np.abs(np.diff(values[lo:hi])))) + 1 

764 splits.append(i) 

765 stack.extend(((lo, i), (i, hi))) 

766 splits.sort() 

767 s = np.array([0, *splits]) 

768 idx = np.append(s, len(values)) 

769 new_edges = edges[idx] 

770 new_widths = np.add.reduceat(widths, s) 

771 weight_sums = np.add.reduceat(weights, s) 

772 new_values = np.add.reduceat(weights * values, s) / weight_sums 

773 # When flow is given, weights == flow * widths, so weight_sums == reduceat(flow * widths, s) exactly. 

774 new_flow = weight_sums / new_widths if flow is not None else None 

775 

776 return new_edges, new_values, new_flow 

777 

778 

779def _generate_failed_coverage_badge() -> None: 

780 """Generate a badge indicating failed coverage.""" 

781 from genbadge import Badge # type: ignore # noqa: PLC0415 

782 

783 b = Badge(left_txt="coverage", right_txt="failed", color="red") 

784 b.write_to("coverage_failed.svg", use_shields=False) 

785 

786 

787def compute_time_edges( 

788 *, 

789 tedges: pd.DatetimeIndex | None, 

790 tstart: pd.DatetimeIndex | None, 

791 tend: pd.DatetimeIndex | None, 

792 number_of_bins: int, 

793) -> pd.DatetimeIndex: 

794 """ 

795 Compute time edges for binning data based on provided time parameters. 

796 

797 This function creates a DatetimeIndex of time bin edges from one of three possible 

798 input formats: explicit edges, start times, or end times. The resulting edges 

799 define the boundaries of time intervals for data binning. 

800 

801 Define either explicit time edges, or start and end times for each bin and leave the others at None. 

802 

803 Parameters 

804 ---------- 

805 tedges : pandas.DatetimeIndex or None 

806 Explicit time edges for the bins. If provided, must have one more element 

807 than the number of bins (n_bins + 1). Takes precedence over tstart and tend. 

808 tstart : pandas.DatetimeIndex or None 

809 Start times for each bin. Must have the same number of elements as the 

810 number of bins. Used when tedges is None. 

811 tend : pandas.DatetimeIndex or None 

812 End times for each bin. Must have the same number of elements as the 

813 number of bins. Used when both tedges and tstart are None. 

814 number_of_bins : int 

815 The expected number of time bins. Used for validation against the provided 

816 time parameters. 

817 

818 Returns 

819 ------- 

820 pandas.DatetimeIndex 

821 Time edges defining the boundaries of the time bins. Has one more element 

822 than number_of_bins. 

823 

824 Raises 

825 ------ 

826 ValueError 

827 If tedges has incorrect length (not number_of_bins + 1). 

828 If tstart has incorrect length (not equal to number_of_bins). 

829 If tend has incorrect length (not equal to number_of_bins). 

830 If none of tedges, tstart, or tend are provided. 

831 

832 Notes 

833 ----- 

834 - When using tstart, the function assumes uniform spacing and extrapolates 

835 the final edge based on the spacing between the last two start times. 

836 - When using tend, the function assumes uniform spacing and extrapolates 

837 the first edge based on the spacing between the first two end times. 

838 - When ``tstart`` or ``tend`` are provided with non-uniformly-spaced bins, 

839 the extrapolated edge uses only the very first or very last interval and 

840 may be physically incorrect: the missing edge is implicitly assigned a 

841 bin width equal to that single neighbouring interval, which is unrelated 

842 to any other interval in the series. In such cases, supply ``tedges`` 

843 directly so that all bin widths are explicit. 

844 - All input time data is converted to pandas.DatetimeIndex for consistency. 

845 """ 

846 if tedges is not None: 

847 if number_of_bins != len(tedges) - 1: 

848 msg = "tedges must have one more element than number_of_bins" 

849 raise ValueError(msg) 

850 tedges = pd.DatetimeIndex(tedges) 

851 # Ensure nanosecond precision while preserving timezone 

852 return tedges.as_unit("ns") 

853 

854 if tstart is not None: 

855 # Assume the index refers to the time at the start of the measurement interval 

856 tstart = pd.DatetimeIndex(tstart).as_unit("ns") 

857 if number_of_bins != len(tstart): 

858 msg = "tstart must have the same number of elements as number_of_bins" 

859 raise ValueError(msg) 

860 if len(tstart) < 2: # noqa: PLR2004 

861 msg = "tstart must have at least 2 elements to infer the bin width; pass tedges for a single bin" 

862 raise ValueError(msg) 

863 

864 # Extrapolate final edge using uniform spacing 

865 final_edge = tstart[-1] + (tstart[-1] - tstart[-2]) 

866 return pd.DatetimeIndex([*list(tstart), final_edge], dtype=tstart.dtype) 

867 

868 if tend is not None: 

869 # Assume the index refers to the time at the end of the measurement interval 

870 tend = pd.DatetimeIndex(tend).as_unit("ns") 

871 if number_of_bins != len(tend): 

872 msg = "tend must have the same number of elements as number_of_bins" 

873 raise ValueError(msg) 

874 if len(tend) < 2: # noqa: PLR2004 

875 msg = "tend must have at least 2 elements to infer the bin width; pass tedges for a single bin" 

876 raise ValueError(msg) 

877 

878 # Extrapolate initial edge using uniform spacing 

879 initial_edge = tend[0] - (tend[1] - tend[0]) 

880 return pd.DatetimeIndex([initial_edge, *list(tend)], dtype=tend.dtype) 

881 

882 msg = "Either provide tedges, tstart, or tend" 

883 raise ValueError(msg) 

884 

885 

886def get_soil_temperature(*, station_number: int = 260, interpolate_missing_values: bool = True) -> pd.DataFrame: 

887 """ 

888 Download soil temperature data from the KNMI and return it as a pandas DataFrame. 

889 

890 The data is available for the following KNMI weather stations: 

891 - 260: De Bilt, the Netherlands (vanaf 1981) 

892 - 273: Marknesse, the Netherlands (vanaf 1989) 

893 - 286: Nieuw Beerta, the Netherlands (vanaf 1990) 

894 - 323: Wilhelminadorp, the Netherlands (vanaf 1989) 

895 

896 TB1 = grondtemperatuur op 5 cm diepte (graden Celsius) tijdens de waarneming 

897 TB2 = grondtemperatuur op 10 cm diepte (graden Celsius) tijdens de waarneming 

898 TB3 = grondtemperatuur op 20 cm diepte (graden Celsius) tijdens de waarneming 

899 TB4 = grondtemperatuur op 50 cm diepte (graden Celsius) tijdens de waarneming 

900 TB5 = grondtemperatuur op 100 cm diepte (graden Celsius) tijdens de waarneming 

901 TNB2 = minimum grondtemperatuur op 10 cm diepte in de afgelopen 6 uur (graden Celsius) 

902 TNB1 = minimum grondtemperatuur op 5 cm diepte in de afgelopen 6 uur (graden Celsius) 

903 TXB1 = maximum grondtemperatuur op 5 cm diepte in de afgelopen 6 uur (graden Celsius) 

904 TXB2 = maximum grondtemperatuur op 10 cm diepte in de afgelopen 6 uur (graden Celsius) 

905 

906 Parameters 

907 ---------- 

908 station_number : int, {260, 273, 286, 323} 

909 The KNMI station number for which to download soil temperature data. 

910 Default is 260 (De Bilt). 

911 interpolate_missing_values : bool, optional 

912 If True, missing values are interpolated and recent NaN values are extrapolated with the previous value. 

913 If False, missing values remain as NaN. Default is True. 

914 

915 Returns 

916 ------- 

917 pandas.DataFrame 

918 DataFrame containing soil temperature data in Celsius with a DatetimeIndex. 

919 Columns include TB1, TB2, TB3, TB4, TB5, TNB1, TNB2, TXB1, TXB2. 

920 

921 Notes 

922 ----- 

923 - KNMI: Royal Netherlands Meteorological Institute 

924 - The timeseries may contain NaN values for missing data. 

925 """ 

926 # File-based daily cache 

927 cache_dir.mkdir(exist_ok=True) 

928 

929 today = date.today().isoformat() # noqa: DTZ011 

930 cache_path = cache_dir / f"soil_temp_{station_number}_{interpolate_missing_values}_{today}.pkl" 

931 

932 # Check if cached file exists and is from today 

933 if cache_path.exists(): 

934 cached = pd.read_pickle(cache_path) # noqa: S301 

935 assert isinstance(cached, pd.DataFrame) # noqa: S101 -- the cache only ever stores DataFrames 

936 return cached 

937 

938 # Clean up old cache files to prevent disk bloat 

939 for old_file in cache_dir.glob(f"soil_temp_{station_number}_{interpolate_missing_values}_*.pkl"): 

940 old_file.unlink(missing_ok=True) 

941 

942 url = f"https://cdn.knmi.nl/knmi/map/page/klimatologie/gegevens/bodemtemps/bodemtemps_{station_number}.zip" 

943 

944 dtypes = { 

945 "YYYYMMDD": "int32", 

946 "HH": "int8", 

947 " TB1": "float32", 

948 " TB3": "float32", 

949 " TB2": "float32", 

950 " TB4": "float32", 

951 " TB5": "float32", 

952 " TNB1": "float32", 

953 " TNB2": "float32", 

954 " TXB1": "float32", 

955 " TXB2": "float32", 

956 } 

957 

958 # Imported lazily so the rest of the module remains importable in environments 

959 # without ``requests`` (e.g. Pyodide/JupyterLite, where this KNMI download is the 

960 # only feature that cannot run client-side). 

961 import requests # noqa: PLC0415 

962 

963 # Download the ZIP file 

964 with requests.get(url, params={"download": "zip"}, timeout=10) as response: 

965 response.raise_for_status() 

966 

967 df = pd.read_csv( # type: ignore[call-overload] # ty: ignore[no-matching-overload] 

968 io.BytesIO(response.content), 

969 compression="zip", 

970 dtype=dtypes, # pyright: ignore[reportArgumentType] 

971 usecols=list(dtypes.keys()), # pyright: ignore[reportArgumentType] 

972 skiprows=16, 

973 sep=",", 

974 na_values=[" "], 

975 engine="c", 

976 parse_dates=False, 

977 ) 

978 

979 df.index = pd.to_datetime(df["YYYYMMDD"].values, format=r"%Y%m%d").tz_localize("UTC") + pd.to_timedelta( 

980 df["HH"].values, unit="h" 

981 ) 

982 

983 df.drop(columns=["YYYYMMDD", "HH"], inplace=True) 

984 df.columns = df.columns.str.strip() 

985 df /= 10.0 

986 

987 if interpolate_missing_values: 

988 # Fill NaN values with interpolate linearly and then forward fill 

989 df.interpolate(method="linear", inplace=True) 

990 df.ffill(inplace=True) 

991 

992 # Save to cache for future use 

993 df.to_pickle(cache_path) 

994 return df 

995 

996 

997def solve_underdetermined_system( 

998 *, 

999 coefficient_matrix: npt.ArrayLike, 

1000 rhs_vector: npt.ArrayLike, 

1001 nullspace_objective: str 

1002 | Callable[ 

1003 [npt.NDArray[np.floating], npt.NDArray[np.floating], npt.NDArray[np.floating]], float 

1004 ] = "squared_differences", 

1005 optimization_method: str = "BFGS", 

1006 rcond: float | None = None, 

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

1008 """ 

1009 Solve an underdetermined linear system with nullspace regularization. 

1010 

1011 For an underdetermined system Ax = b where A has more columns than rows, 

1012 multiple solutions exist. This function computes a least-squares solution 

1013 and then selects a specific solution from the nullspace based on a 

1014 regularization objective. 

1015 

1016 Parameters 

1017 ---------- 

1018 coefficient_matrix : array-like 

1019 Coefficient matrix of shape (m, n) where m < n (underdetermined). 

1020 May contain NaN values in some rows, which will be excluded from the system. 

1021 rhs_vector : array-like 

1022 Right-hand side vector of length m. May contain NaN values corresponding 

1023 to NaN rows in coefficient_matrix, which will be excluded from the system. 

1024 nullspace_objective : str or callable, optional 

1025 Objective function to minimize in the nullspace. Options: 

1026 

1027 * "squared_differences" : Minimize sum of squared differences between 

1028 adjacent elements: ``sum((x[i+1] - x[i])**2)`` 

1029 * "summed_differences" : Minimize sum of absolute differences between 

1030 adjacent elements: ``sum(|x[i+1] - x[i]|)`` 

1031 * callable : Custom objective function with signature 

1032 ``objective(coeffs, x_ls, nullspace_basis)`` where: 

1033 

1034 - coeffs : optimization variables (nullspace coefficients) 

1035 - x_ls : least-squares solution 

1036 - nullspace_basis : nullspace basis matrix 

1037 

1038 Default is "squared_differences". 

1039 optimization_method : str, optional 

1040 Optimization method passed to scipy.optimize.minimize. 

1041 Default is "BFGS". 

1042 rcond : float or None, optional 

1043 Cutoff ratio for small singular values in both ``numpy.linalg.lstsq`` 

1044 and ``scipy.linalg.null_space``. Singular values smaller than 

1045 ``rcond * largest_singular_value`` are treated as zero. 

1046 Default is None, which uses the default of each function. 

1047 Increasing rcond truncates more modes, expanding the nullspace 

1048 available for smoothness optimization. Useful for noisy data. 

1049 

1050 Returns 

1051 ------- 

1052 ndarray 

1053 Solution vector that minimizes the specified nullspace objective. 

1054 Has length n (number of columns in coefficient_matrix). 

1055 

1056 Raises 

1057 ------ 

1058 ValueError 

1059 If optimization fails, if coefficient_matrix and rhs_vector have incompatible shapes, 

1060 or if an unknown nullspace objective is specified. 

1061 

1062 Notes 

1063 ----- 

1064 The algorithm follows these steps: 

1065 

1066 1. Remove rows with NaN values from both coefficient_matrix and rhs_vector 

1067 2. Compute least-squares solution: x_ls = pinv(valid_matrix) @ valid_rhs 

1068 3. Compute nullspace basis: N = null_space(valid_matrix) 

1069 4. Find nullspace coefficients: coeffs = argmin objective(x_ls + N @ coeffs) 

1070 5. Return final solution: x = x_ls + N @ coeffs 

1071 

1072 For the built-in objectives: 

1073 

1074 * "squared_differences" provides smooth solutions, minimizing rapid changes 

1075 * "summed_differences" provides sparse solutions, promoting piecewise constant behavior 

1076 

1077 Examples 

1078 -------- 

1079 Basic usage with default squared differences objective: 

1080 

1081 >>> import numpy as np 

1082 >>> from gwtransport.utils import solve_underdetermined_system 

1083 >>> 

1084 >>> # Create underdetermined system (2 equations, 4 unknowns) 

1085 >>> matrix = np.array([[1, 2, 1, 0], [0, 1, 2, 1]]) 

1086 >>> rhs = np.array([3, 4]) 

1087 >>> 

1088 >>> # Solve with squared differences regularization 

1089 >>> x = solve_underdetermined_system(coefficient_matrix=matrix, rhs_vector=rhs) 

1090 >>> print(f"Solution: {x}") # doctest: +SKIP 

1091 >>> print(f"Residual: {np.linalg.norm(matrix @ x - rhs):.2e}") # doctest: +SKIP 

1092 

1093 With summed differences objective: 

1094 

1095 >>> x_sparse = solve_underdetermined_system( # doctest: +SKIP 

1096 ... coefficient_matrix=matrix, 

1097 ... rhs_vector=rhs, 

1098 ... nullspace_objective="summed_differences", 

1099 ... ) 

1100 

1101 With custom objective function: 

1102 

1103 >>> def custom_objective(coeffs, x_ls, nullspace_basis): 

1104 ... x = x_ls + nullspace_basis @ coeffs 

1105 ... return np.sum(x**2) # Minimize L2 norm 

1106 >>> 

1107 >>> x_custom = solve_underdetermined_system( # doctest: +SKIP 

1108 ... coefficient_matrix=matrix, 

1109 ... rhs_vector=rhs, 

1110 ... nullspace_objective=custom_objective, 

1111 ... ) 

1112 

1113 Handling NaN values: 

1114 

1115 >>> # System with missing data 

1116 >>> matrix_nan = np.array([ 

1117 ... [1, 2, 1, 0], 

1118 ... [np.nan, np.nan, np.nan, np.nan], 

1119 ... [0, 1, 2, 1], 

1120 ... ]) 

1121 >>> rhs_nan = np.array([3, np.nan, 4]) 

1122 >>> 

1123 >>> x_nan = solve_underdetermined_system( 

1124 ... coefficient_matrix=matrix_nan, rhs_vector=rhs_nan 

1125 ... ) # doctest: +SKIP 

1126 """ 

1127 matrix = np.asarray(coefficient_matrix) 

1128 rhs = np.asarray(rhs_vector) 

1129 

1130 if matrix.shape[0] != len(rhs): 

1131 msg = f"coefficient_matrix has {matrix.shape[0]} rows but rhs_vector has {len(rhs)} elements" 

1132 raise ValueError(msg) 

1133 

1134 # Identify valid rows (no NaN values in either matrix or rhs) 

1135 valid_rows = ~np.isnan(matrix).any(axis=1) & ~np.isnan(rhs) 

1136 

1137 if not np.any(valid_rows): 

1138 msg = "No valid rows found (all contain NaN values)" 

1139 raise ValueError(msg) 

1140 

1141 valid_matrix = matrix[valid_rows] 

1142 valid_rhs = rhs[valid_rows] 

1143 

1144 # Compute least-squares solution 

1145 x_ls, *_ = np.linalg.lstsq(valid_matrix, valid_rhs, rcond=rcond) 

1146 

1147 # Compute nullspace 

1148 nullspace_basis = null_space(valid_matrix, rcond=rcond) 

1149 nullrank = nullspace_basis.shape[1] 

1150 

1151 if nullrank == 0: 

1152 # System is determined, return least-squares solution 

1153 return x_ls 

1154 

1155 # Optimize in nullspace 

1156 coeffs = _optimize_nullspace_coefficients( 

1157 x_ls=x_ls, 

1158 nullspace_basis=nullspace_basis, 

1159 nullspace_objective=nullspace_objective, 

1160 optimization_method=optimization_method, 

1161 ) 

1162 

1163 return x_ls + nullspace_basis @ coeffs 

1164 

1165 

1166def _optimize_nullspace_coefficients( 

1167 *, 

1168 x_ls: npt.NDArray[np.floating], 

1169 nullspace_basis: npt.NDArray[np.floating], 

1170 nullspace_objective: str 

1171 | Callable[[npt.NDArray[np.floating], npt.NDArray[np.floating], npt.NDArray[np.floating]], float], 

1172 optimization_method: str, 

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

1174 """Optimize coefficients in the nullspace to minimize the objective. 

1175 

1176 Parameters 

1177 ---------- 

1178 x_ls : ndarray 

1179 Least-squares solution vector. 

1180 nullspace_basis : ndarray 

1181 Nullspace basis matrix of shape (n, nullrank). 

1182 nullspace_objective : str or callable 

1183 Objective to minimize. Supported string values are 

1184 ``'squared_differences'`` and ``'summed_differences'``. A callable 

1185 with signature ``objective(coeffs, x_ls, nullspace_basis)`` is also 

1186 accepted. 

1187 optimization_method : str 

1188 Optimization method passed to ``scipy.optimize.minimize``. 

1189 

1190 Returns 

1191 ------- 

1192 ndarray 

1193 Optimal nullspace coefficient vector of length nullrank. 

1194 

1195 Raises 

1196 ------ 

1197 ValueError 

1198 If iterative optimization fails to converge. 

1199 """ 

1200 # For squared_differences, solve the quadratic form analytically: 

1201 # min ||D(x_ls + N c)||^2 => (N'D'DN) c = -N'D'D x_ls 

1202 coeffs_sq = _solve_squared_differences_analytical(x_ls=x_ls, nullspace_basis=nullspace_basis) 

1203 

1204 if nullspace_objective == "squared_differences": 

1205 return coeffs_sq 

1206 

1207 # For other objectives, use iterative optimization starting from the 

1208 # squared_differences solution for stability 

1209 objective_func = _get_nullspace_objective_function(nullspace_objective=nullspace_objective) 

1210 coeffs_0 = coeffs_sq 

1211 

1212 res = minimize( 

1213 objective_func, 

1214 x0=coeffs_0, 

1215 args=(x_ls, nullspace_basis), 

1216 method=optimization_method, 

1217 ) 

1218 

1219 if not res.success: 

1220 msg = f"Optimization failed: {res.message}" 

1221 raise ValueError(msg) 

1222 

1223 return res.x 

1224 

1225 

1226def _solve_squared_differences_analytical( 

1227 *, 

1228 x_ls: npt.NDArray[np.floating], 

1229 nullspace_basis: npt.NDArray[np.floating], 

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

1231 """Solve the squared-differences nullspace problem analytically. 

1232 

1233 Minimizes ``sum((x[i+1] - x[i])^2)`` where ``x = x_ls + N @ c`` by 

1234 solving the normal equations ``(N^T D^T D N) c = -N^T D^T D x_ls``. 

1235 

1236 Parameters 

1237 ---------- 

1238 x_ls : ndarray 

1239 Least-squares solution vector of length n. 

1240 nullspace_basis : ndarray 

1241 Nullspace basis matrix of shape (n, nullrank). 

1242 

1243 Returns 

1244 ------- 

1245 ndarray 

1246 Optimal nullspace coefficient vector of length nullrank. 

1247 

1248 Raises 

1249 ------ 

1250 numpy.linalg.LinAlgError 

1251 If the normal equations matrix ``(DN)^T(DN)`` is ill-conditioned 

1252 (condition number exceeds 1e12). 

1253 """ 

1254 # D is the (n-1, n) first-difference matrix; D @ x = x[1:] - x[:-1] 

1255 # D^T D is the tridiagonal matrix with 2 on diagonal, -1 on off-diagonals 

1256 # (except corners which have 1 on diagonal) 

1257 # Instead of forming D explicitly, compute D @ N and D @ x_ls directly 

1258 dn = nullspace_basis[1:, :] - nullspace_basis[:-1, :] # (n-1, nullrank) 

1259 dx = x_ls[1:] - x_ls[:-1] # (n-1,) 

1260 

1261 # Normal equations: (DN)^T (DN) c = -(DN)^T (D x_ls) 

1262 dntdn = dn.T @ dn # (nullrank, nullrank) 

1263 rhs = -(dn.T @ dx) # (nullrank,) 

1264 

1265 cond = np.linalg.cond(dntdn) 

1266 cond_threshold = 1e12 

1267 if cond > cond_threshold: 

1268 msg = ( 

1269 f"The normal equations matrix (DN)^T(DN) is ill-conditioned " 

1270 f"(condition number: {cond:.2e}). This typically means the " 

1271 f"nullspace contains a near-constant vector, so the " 

1272 f"squared-differences objective cannot distinguish between " 

1273 f"nullspace directions. Consider using a different " 

1274 f"nullspace_objective (e.g., 'summed_differences'), reducing " 

1275 f"the problem's degrees of freedom, or lowering rcond to " 

1276 f"shrink the nullspace (if the near-constant vector has a " 

1277 f"small but non-zero singular value)." 

1278 ) 

1279 raise np.linalg.LinAlgError(msg) 

1280 

1281 return np.linalg.solve(dntdn, rhs) 

1282 

1283 

1284def compute_reverse_target( 

1285 *, 

1286 coeff_matrix: npt.NDArray[np.floating], 

1287 rhs_vector: npt.NDArray[np.floating], 

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

1289 """Compute reverse matrix target from forward coefficient matrix. 

1290 

1291 Constructs a target solution for the inverse problem by transposing the 

1292 forward coefficient matrix and normalizing rows. For ``W_forward[i,j]`` 

1293 representing the fraction of ``cin[j]`` arriving in ``cout[i]``, the 

1294 transpose-and-normalize approach reconstructs ``cin[j]`` as a weighted 

1295 average of ``cout`` bins, weighted by how much ``cin[j]`` contributed 

1296 to each ``cout`` bin. 

1297 

1298 Parameters 

1299 ---------- 

1300 coeff_matrix : ndarray 

1301 Forward coefficient matrix of shape (n_cout, n_cin). 

1302 rhs_vector : ndarray 

1303 Right-hand side vector of length n_cout (e.g., cout values). 

1304 

1305 Returns 

1306 ------- 

1307 ndarray 

1308 Target solution vector of length n_cin. Entries with near-zero 

1309 column sums in the forward matrix are set to NaN. 

1310 

1311 See Also 

1312 -------- 

1313 solve_tikhonov : Consumes this target as the regularization reference. 

1314 """ 

1315 min_row_sum = 1e-10 

1316 wt = coeff_matrix.T # (n_cin, n_cout) 

1317 row_sums = wt.sum(axis=1) 

1318 valid = row_sums > min_row_sum 

1319 w_reverse = np.zeros_like(wt) 

1320 w_reverse[valid] = wt[valid] / row_sums[valid, None] 

1321 x_target = w_reverse @ rhs_vector 

1322 x_target[~valid] = np.nan 

1323 return x_target 

1324 

1325 

1326def solve_tikhonov( 

1327 *, 

1328 coefficient_matrix: npt.ArrayLike, 

1329 rhs_vector: npt.ArrayLike, 

1330 x_target: npt.NDArray[np.floating], 

1331 regularization_strength: float = 1e-10, 

1332 return_resolution: bool = False, 

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

1334 """Solve a linear system with Tikhonov regularization toward a target. 

1335 

1336 Minimizes ``||A x - b||² + λ ||x - x_target||²`` by solving the 

1337 equivalent augmented least-squares problem:: 

1338 

1339 [A; √λ I_v] x = [b; √λ x_target_v] 

1340 

1341 where ``I_v`` selects only entries where ``x_target`` is not NaN. 

1342 

1343 Well-determined modes (large singular values relative to √λ) are 

1344 dominated by the data; poorly-determined modes are pulled toward 

1345 ``x_target``. The solution varies continuously with λ, unlike the 

1346 hard singular-value cutoff of ``rcond`` in truncated SVD. 

1347 

1348 Parameters 

1349 ---------- 

1350 coefficient_matrix : array-like 

1351 Coefficient matrix of shape (m, n). May contain NaN rows, which 

1352 are excluded from the system. 

1353 rhs_vector : array-like 

1354 Right-hand side vector of length m. May contain NaN values 

1355 corresponding to NaN rows in coefficient_matrix. 

1356 x_target : ndarray 

1357 Target solution of length n, typically from 

1358 :func:`compute_reverse_target`. NaN entries are excluded from the 

1359 regularization term. 

1360 regularization_strength : float, optional 

1361 Tikhonov parameter λ. Controls the tradeoff between fitting the 

1362 data and staying close to ``x_target``. Larger values trust the 

1363 target more; smaller values trust the data more. Default is 1e-10. 

1364 

1365 A good starting value for noisy data is 

1366 ``λ ≈ (noise_std / signal_amplitude)²``. For noiseless synthetic 

1367 data, the default 1e-10 preserves machine precision. 

1368 return_resolution : bool, optional 

1369 If True, also return the per-element fraction of the solution that 

1370 comes from data (vs from the regularization target). Default is 

1371 False. 

1372 

1373 Returns 

1374 ------- 

1375 ndarray or tuple of ndarray 

1376 If ``return_resolution`` is False (default), returns the solution 

1377 vector of length n. 

1378 

1379 If ``return_resolution`` is True, returns ``(x, fraction_data)`` 

1380 where ``fraction_data[j]`` is the diagonal of the model resolution 

1381 matrix ``R = (A^T A + λ D)^{-1} A^T A``: 

1382 

1383 - ``fraction_data[j] ≈ 1``: element *j* is data-driven 

1384 - ``fraction_data[j] ≈ 0``: element *j* is target-driven 

1385 - Non-regularized entries (NaN in ``x_target``): 

1386 ``fraction_data[j] = 1.0`` 

1387 

1388 Raises 

1389 ------ 

1390 ValueError 

1391 If ``coefficient_matrix`` and ``rhs_vector`` have incompatible shapes, or if 

1392 all rows contain NaN values. 

1393 

1394 See Also 

1395 -------- 

1396 compute_reverse_target : Compute the regularization target from the 

1397 forward matrix. 

1398 solve_underdetermined_system : Alternative solver using nullspace 

1399 optimization. 

1400 """ 

1401 matrix = np.asarray(coefficient_matrix) 

1402 rhs = np.asarray(rhs_vector) 

1403 

1404 if matrix.shape[0] != len(rhs): 

1405 msg = f"coefficient_matrix has {matrix.shape[0]} rows but rhs_vector has {len(rhs)} elements" 

1406 raise ValueError(msg) 

1407 

1408 # Filter NaN rows 

1409 valid_rows = ~np.isnan(matrix).any(axis=1) & ~np.isnan(rhs) 

1410 

1411 if not np.any(valid_rows): 

1412 msg = "No valid rows found (all contain NaN values)" 

1413 raise ValueError(msg) 

1414 

1415 valid_matrix = matrix[valid_rows] 

1416 valid_rhs = rhs[valid_rows] 

1417 

1418 n_cin = valid_matrix.shape[1] 

1419 sqrt_lam = np.sqrt(regularization_strength) 

1420 

1421 # Only regularize entries where x_target is valid 

1422 valid_target = ~np.isnan(x_target) 

1423 target_indices = np.where(valid_target)[0] 

1424 

1425 # Build augmented system: [A; √λ I_v] x = [b; √λ x_target_v] 

1426 n_reg = len(target_indices) 

1427 reg_matrix = np.zeros((n_reg, n_cin)) 

1428 reg_matrix[np.arange(n_reg), target_indices] = sqrt_lam 

1429 reg_rhs = sqrt_lam * x_target[target_indices] 

1430 

1431 augmented_matrix = np.vstack([valid_matrix, reg_matrix]) 

1432 augmented_rhs = np.concatenate([valid_rhs, reg_rhs]) 

1433 

1434 x, *_ = np.linalg.lstsq(augmented_matrix, augmented_rhs, rcond=None) 

1435 

1436 if return_resolution: 

1437 # Compute fraction_data from model resolution matrix diagonal: 

1438 # R = G^{-1} A^T A where G = A^T A + λ diag(d) 

1439 # fraction_data[j] = R[j,j] = 1 - λ d[j] G_inv[j,j] 

1440 d_reg = np.zeros(n_cin) 

1441 d_reg[target_indices] = 1.0 

1442 gram = valid_matrix.T @ valid_matrix 

1443 gram[np.arange(n_cin), np.arange(n_cin)] += regularization_strength * d_reg 

1444 gram_inv_diag = np.diag(np.linalg.inv(gram)) 

1445 fraction_data = 1.0 - regularization_strength * gram_inv_diag * d_reg 

1446 return x, fraction_data 

1447 

1448 return x 

1449 

1450 

1451# Numerical tolerance for coefficient sum to determine valid output bins 

1452_EPSILON_COEFF_SUM = 1e-10 

1453 

1454# Corrected semi-normal-equation refinement steps in solve_inverse_transport_banded. One 

1455# step reaches the QR-accurate solution; a second is a cheap, stable safety margin. 

1456_BANDED_REFINEMENT_STEPS = 2 

1457 

1458 

1459def solve_inverse_transport( 

1460 *, 

1461 w_forward: npt.NDArray[np.floating], 

1462 observed: npt.NDArray[np.floating], 

1463 n_output: int, 

1464 regularization_strength: float, 

1465 valid_rows: npt.NDArray[np.bool_] | None = None, 

1466 warn_rank_deficient: bool = False, 

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

1468 """Solve the inverse transport problem via Tikhonov regularization. 

1469 

1470 Given the forward model ``w_forward @ x = observed``, recovers ``x`` by 

1471 building the regularization target from the transpose of ``w_forward`` and 

1472 solving the regularized least-squares problem. 

1473 

1474 Parameters 

1475 ---------- 

1476 w_forward : ndarray 

1477 Forward coefficient matrix with shape ``(n_obs, n_output)``. 

1478 observed : ndarray 

1479 Observed values with shape ``(n_obs,)`` (e.g., extraction 

1480 concentrations). 

1481 n_output : int 

1482 Length of the output vector (e.g., number of cin bins). 

1483 regularization_strength : float 

1484 Tikhonov regularization parameter. 

1485 valid_rows : ndarray of bool, optional 

1486 Which observation rows are valid, with shape ``(n_obs,)``. If None, 

1487 rows with ``row_sum > 1e-10`` are considered valid. 

1488 warn_rank_deficient : bool, optional 

1489 If True, emit a warning when the forward matrix has rank 

1490 deficiency among its active columns. Default is False. 

1491 

1492 Returns 

1493 ------- 

1494 ndarray 

1495 Recovered signal with shape ``(n_output,)``. NaN for bins with no 

1496 active columns. 

1497 

1498 Warns 

1499 ----- 

1500 UserWarning 

1501 When ``warn_rank_deficient=True`` and the matrix is rank-deficient. 

1502 

1503 See Also 

1504 -------- 

1505 solve_inverse_transport_banded : Memory-light banded equivalent. 

1506 """ 

1507 row_sums = w_forward.sum(axis=1) 

1508 col_active: npt.NDArray[np.bool_] = w_forward.sum(axis=0) > 0 

1509 

1510 if not np.any(col_active): 

1511 return np.full(n_output, np.nan) 

1512 

1513 if warn_rank_deficient: 

1514 n_active = int(col_active.sum()) 

1515 rank = np.linalg.matrix_rank(w_forward[:, col_active]) 

1516 if rank < n_active: 

1517 warnings.warn( 

1518 f"Forward matrix is rank-deficient (rank {rank} < {n_active} active " 

1519 f"columns). This occurs with constant flow when the residence time " 

1520 f"is an integer multiple of the time step width. The " 

1521 f"underdetermined modes will be pulled toward the regularization " 

1522 f"target instead of being determined by data. To achieve full rank, " 

1523 f"adjust aquifer_pore_volumes slightly (e.g., multiply by 1.001).", 

1524 stacklevel=2, 

1525 ) 

1526 

1527 valid: npt.NDArray[np.bool_] = row_sums > _EPSILON_COEFF_SUM if valid_rows is None else valid_rows 

1528 

1529 rhs = np.where(valid, row_sums * observed, np.nan) 

1530 w_solve = w_forward.copy() 

1531 w_solve[~valid, :] = np.nan 

1532 

1533 x_target = compute_reverse_target(coeff_matrix=w_forward, rhs_vector=observed) 

1534 

1535 x_solved = solve_tikhonov( 

1536 coefficient_matrix=w_solve, 

1537 rhs_vector=rhs, 

1538 x_target=x_target, 

1539 regularization_strength=regularization_strength, 

1540 ) 

1541 

1542 out = np.full(n_output, np.nan) 

1543 idx = np.flatnonzero(col_active) 

1544 out[idx] = x_solved[idx] 

1545 return out 

1546 

1547 

1548def solve_inverse_transport_banded( 

1549 *, 

1550 band_vals: npt.NDArray[np.floating], 

1551 col_start: npt.NDArray[np.intp], 

1552 observed: npt.NDArray[np.floating], 

1553 n_output: int, 

1554 regularization_strength: float, 

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

1556 """Solve the inverse transport problem from a banded forward operator. 

1557 

1558 Memory-light equivalent of :func:`solve_inverse_transport` for a forward 

1559 weight matrix stored in banded layout: row ``k`` of the dense operator 

1560 ``W`` is ``band_vals[k]`` placed at columns 

1561 ``[col_start[k], col_start[k] + full_band)``. The Tikhonov normal 

1562 equations ``(WᵀW + λ D) x = Wᵀ observed + λ D x_target`` are stored **in 

1563 banded form** -- ``WᵀW`` is symmetric with half-bandwidth ``full_band - 1`` 

1564 -- and Cholesky-factored with :func:`scipy.linalg.cholesky_banded`. The Gram 

1565 matrix ``WᵀW`` is built with a single dense BLAS matmul (``~24x`` a 

1566 per-diagonal scatter) before its sub-diagonals are read into the banded 

1567 layout. Forming ``WᵀW`` squares the condition number, so the bare Cholesky 

1568 solve loses accuracy in the under-determined (spin-up nullspace) directions; 

1569 **corrected semi-normal equations** restore it by refining with the residual 

1570 evaluated through ``W`` itself rather than ``WᵀW`` (matching the dense 

1571 least-squares solution to ~1e-10). The banded Cholesky factor, solve, and 

1572 refinement stay at ``O(n_output * full_band)``; only the one-shot Gram 

1573 assembly transiently materializes ``W`` and ``WᵀW`` densely. 

1574 

1575 The regularization target ``x_target`` is the transpose-and-normalize of 

1576 ``W`` applied to ``observed`` (the banded form of 

1577 :func:`compute_reverse_target`), matching the dense solver. Columns with no 

1578 forward contribution are decoupled (unit diagonal) so the system stays 

1579 symmetric positive definite, and are returned as NaN. 

1580 

1581 Parameters 

1582 ---------- 

1583 band_vals : ndarray 

1584 Banded forward weights of shape ``(n_obs, full_band)``. Rows the caller 

1585 considers invalid must already be zeroed (as ``_resolve_spinup_mask`` 

1586 does); zero rows contribute nothing to the normal equations. 

1587 col_start : ndarray of int 

1588 First output-column index of each row's band, shape ``(n_obs,)``. 

1589 observed : ndarray 

1590 Observed values of shape ``(n_obs,)`` (e.g. extraction concentrations). 

1591 Must not contain NaN. 

1592 n_output : int 

1593 Length of the output vector (number of cin bins). 

1594 regularization_strength : float 

1595 Tikhonov parameter λ. See :func:`solve_inverse_transport`. Must be 

1596 strictly positive: deconvolution is generically rank-deficient, and λ 

1597 is what makes the banded Cholesky factor positive definite (unlike the 

1598 dense least-squares path, this solver cannot return a λ=0 min-norm 

1599 solution). 

1600 

1601 Returns 

1602 ------- 

1603 ndarray 

1604 Recovered signal of shape ``(n_output,)``. NaN for output bins with no 

1605 forward contribution (zero column). 

1606 

1607 Raises 

1608 ------ 

1609 ValueError 

1610 If ``regularization_strength`` is not strictly positive. 

1611 

1612 See Also 

1613 -------- 

1614 solve_inverse_transport : Dense-matrix equivalent. 

1615 ``gwtransport.advection_utils._infiltration_to_extraction_weights`` : Banded builder. 

1616 """ 

1617 if regularization_strength <= 0: 

1618 msg = "regularization_strength must be > 0 for the banded inverse (Tikhonov positive-definiteness)" 

1619 raise ValueError(msg) 

1620 # Precondition: the caller's valid rows sum to 1 (guaranteed by 

1621 # _resolve_spinup_mask), so the data equation is W x ≈ observed and the RHS 

1622 # needs no row_sums scaling -- matching the dense solve_inverse_transport. 

1623 band_vals = np.asarray(band_vals, dtype=float) 

1624 observed = np.asarray(observed, dtype=float) 

1625 full_band = band_vals.shape[1] 

1626 n_cin = n_output 

1627 cols = col_start[:, None] + np.arange(full_band)[None, :] # (n_obs, full_band) output-column index 

1628 in_range = cols < n_cin 

1629 cols_clipped = np.clip(cols, 0, n_cin - 1) 

1630 

1631 # Column sums and Wᵀ observed (the reverse-target numerator) by scattering the band. 

1632 col_sum = np.zeros(n_cin) 

1633 wt_observed = np.zeros(n_cin) 

1634 np.add.at(col_sum, cols_clipped[in_range], band_vals[in_range]) 

1635 np.add.at(wt_observed, cols_clipped[in_range], (band_vals * observed[:, None])[in_range]) 

1636 

1637 col_active = col_sum > 0 

1638 if not np.any(col_active): 

1639 return np.full(n_output, np.nan) 

1640 

1641 # Reverse-target: transpose-and-normalize W applied to observed (banded form of 

1642 # compute_reverse_target). The sliver 0 < col_sum <= _EPSILON_COEFF_SUM is left 

1643 # untargeted (filled with 0) as in the dense path. 

1644 with np.errstate(invalid="ignore", divide="ignore"): 

1645 x_target = np.where(col_sum > _EPSILON_COEFF_SUM, wt_observed / col_sum, 0.0) 

1646 

1647 # Lower-banded WᵀW via a dense BLAS matmul. Materialize the forward operator W densely 

1648 # (row k is band_vals[k] at columns [col_start[k], col_start[k] + full_band)), form the 

1649 # symmetric Gram matrix WᵀW with a single optimized matmul, then read its lower sub-diagonals 

1650 # into the banded layout (band row d is the d-th sub-diagonal, WᵀW[j + d, j]). Each row's 

1651 # in-range band columns are distinct, so the scatter into W needs no accumulation. This is 

1652 # ~24x the per-diagonal np.add.at scatter; the matmul reorders the summation, so ab matches 

1653 # the scatter to ~1e-13 -- well inside the Tikhonov + refinement tolerance. 

1654 n_obs = band_vals.shape[0] 

1655 w_dense = np.zeros((n_obs, n_cin)) 

1656 obs_idx = np.broadcast_to(np.arange(n_obs)[:, None], cols.shape) 

1657 w_dense[obs_idx[in_range], cols_clipped[in_range]] = band_vals[in_range] 

1658 gram = w_dense.T @ w_dense 

1659 ab = np.zeros((full_band, n_cin)) 

1660 for d in range(full_band): 

1661 ab[d, : n_cin - d] = np.diagonal(gram, offset=-d) 

1662 

1663 lam = regularization_strength 

1664 d_reg = lam * col_active 

1665 ab[0] += d_reg 

1666 # d_reg is zero off the active columns, so x_target needs no masking here or in 

1667 # the refinement loop: the product d_reg * x_target vanishes wherever col_active is False. 

1668 rhs = wt_observed + d_reg * x_target 

1669 

1670 # Decouple zero (inactive, unregularized) diagonals so the matrix is SPD. 

1671 dead = ab[0] <= 0.0 

1672 ab[0, dead] = 1.0 

1673 rhs[dead] = 0.0 

1674 

1675 factor = cholesky_banded(ab, lower=True) 

1676 x = cho_solve_banded((factor, True), rhs) 

1677 

1678 # Forming WᵀW squares the condition number, so the bare Cholesky solution loses 

1679 # accuracy in the under-determined (spin-up nullspace) directions. Corrected 

1680 # semi-normal equations recover it: the residual is evaluated through W itself 

1681 # (in observation space) rather than through WᵀW, avoiding the cancellation that 

1682 # makes plain normal-equation refinement stall. One step reaches the QR-accurate 

1683 # solution; the rest are a safety margin (the iteration's fixed point is stable). 

1684 for _ in range(_BANDED_REFINEMENT_STEPS): 

1685 gathered = x[cols_clipped] 

1686 gathered[~in_range] = 0.0 

1687 residual = observed - (band_vals * gathered).sum(axis=1) # b - W x (n_obs,) 

1688 gradient = np.zeros(n_cin) 

1689 np.add.at(gradient, cols_clipped[in_range], (band_vals * residual[:, None])[in_range]) # Wᵀ (b - W x) 

1690 gradient += d_reg * (x_target - x) 

1691 gradient[dead] = 0.0 

1692 x += cho_solve_banded((factor, True), gradient) 

1693 

1694 out = np.full(n_output, np.nan) 

1695 out[col_active] = x[col_active] 

1696 return out 

1697 

1698 

1699def _squared_differences_objective( 

1700 coeffs: npt.NDArray[np.floating], x_ls: npt.NDArray[np.floating], nullspace_basis: npt.NDArray[np.floating] 

1701) -> float: 

1702 """Minimize sum of squared differences between adjacent elements. 

1703 

1704 Parameters 

1705 ---------- 

1706 coeffs : ndarray 

1707 Nullspace coefficient vector. 

1708 x_ls : ndarray 

1709 Least-squares solution vector. 

1710 nullspace_basis : ndarray 

1711 Nullspace basis matrix. 

1712 

1713 Returns 

1714 ------- 

1715 float 

1716 Sum of squared differences between adjacent elements of the solution. 

1717 """ 

1718 x = x_ls + nullspace_basis @ coeffs 

1719 return np.sum(np.square(x[1:] - x[:-1])) 

1720 

1721 

1722def _summed_differences_objective( 

1723 coeffs: npt.NDArray[np.floating], x_ls: npt.NDArray[np.floating], nullspace_basis: npt.NDArray[np.floating] 

1724) -> float: 

1725 """Minimize sum of absolute differences between adjacent elements. 

1726 

1727 Parameters 

1728 ---------- 

1729 coeffs : ndarray 

1730 Nullspace coefficient vector. 

1731 x_ls : ndarray 

1732 Least-squares solution vector. 

1733 nullspace_basis : ndarray 

1734 Nullspace basis matrix. 

1735 

1736 Returns 

1737 ------- 

1738 float 

1739 Sum of absolute differences between adjacent elements of the solution. 

1740 """ 

1741 x = x_ls + nullspace_basis @ coeffs 

1742 return np.sum(np.abs(x[1:] - x[:-1])) 

1743 

1744 

1745def _get_nullspace_objective_function( 

1746 *, 

1747 nullspace_objective: str 

1748 | Callable[[npt.NDArray[np.floating], npt.NDArray[np.floating], npt.NDArray[np.floating]], float], 

1749) -> Callable[[npt.NDArray[np.floating], npt.NDArray[np.floating], npt.NDArray[np.floating]], float]: 

1750 """Get the objective function for nullspace optimization. 

1751 

1752 Parameters 

1753 ---------- 

1754 nullspace_objective : str or callable 

1755 Objective identifier. Supported string values are 

1756 ``'squared_differences'`` and ``'summed_differences'``. A callable 

1757 with signature ``objective(coeffs, x_ls, nullspace_basis)`` is also 

1758 accepted and returned as-is. 

1759 

1760 Returns 

1761 ------- 

1762 callable 

1763 Objective function with signature 

1764 ``(coeffs, x_ls, nullspace_basis) -> float``. 

1765 

1766 Raises 

1767 ------ 

1768 ValueError 

1769 If ``nullspace_objective`` is an unrecognized string. 

1770 """ 

1771 if nullspace_objective == "squared_differences": 

1772 return _squared_differences_objective 

1773 if nullspace_objective == "summed_differences": 

1774 return _summed_differences_objective 

1775 if callable(nullspace_objective): 

1776 return nullspace_objective # type: ignore[return-value] # ty: ignore[invalid-return-type] 

1777 msg = f"Unknown nullspace objective: {nullspace_objective}" 

1778 raise ValueError(msg)