ertkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ertkit/ves/smooth.py ADDED
@@ -0,0 +1,508 @@
1
+ r"""Smooth (Occam) inversion of VES data, with a 1-D depth of investigation.
2
+
3
+ The model has many thin layers with fixed thicknesses that grow
4
+ logarithmically with depth; the unknowns are :math:`m = \ln \rho`. Each
5
+ iteration of Occam's inversion (Constable et al. 1987) solves for the model
6
+ itself, after linearizing about the current model :math:`m_k`:
7
+
8
+ .. math::
9
+
10
+ m(\mu) = \bigl[\mu R + (WJ)^T WJ\bigr]^{-1}
11
+ \bigl[(WJ)^T W \hat d + \mu \alpha_s m_{\mathrm{ref}}\bigr],
12
+ \qquad \hat d = d - f(m_k) + J m_k,
13
+
14
+ where :math:`R = \partial^T \partial + \alpha_s I` combines first-difference
15
+ roughness :math:`\partial` with an optional smallness term. While the target
16
+ :math:`\chi^2 = N_d` cannot be reached, :math:`\mu` minimizes the nonlinear
17
+ misfit and only steps that reduce it are accepted; once the target can be
18
+ reached, :math:`\mu` is the largest value with :math:`\chi^2 = N_d`.
19
+
20
+ The depth of investigation follows Oldenburg and Li (1999): two further
21
+ inversions with reference models of 0.1 and 10 times the geometric mean of the
22
+ apparent resistivities give, layer by layer, the index
23
+
24
+ .. math::
25
+
26
+ R^{\mathrm{DOI}}_j = \frac{m^{(1)}_j - m^{(2)}_j}
27
+ {m^{(1)}_{\mathrm{ref}} - m^{(2)}_{\mathrm{ref}}},
28
+
29
+ which is near 0 where the data control the model and near 1 where the
30
+ reference does. The index is rescaled to 1 at the bottom layer. Near the
31
+ transition the two reference models can overshoot in opposite directions,
32
+ making the index negative there, so its magnitude is used. The depth of
33
+ investigation is the top of the unresolved zone that reaches the bottom of
34
+ the model: going up from the bottom layer, through layers whose rescaled index
35
+ is at least 0.1, bridging resolved gaps narrower than a factor of 2 in depth.
36
+ Bridging ignores the dips below 0.1 that the overshoot produces inside the
37
+ unresolved zone, while a genuinely resolved deeper zone (for example a
38
+ conductive layer below a resistive one whose resistivity and thickness are
39
+ equivalent) is not swallowed by an unresolved band above it. The reference
40
+ inversions aim at the same misfit as the main inversion.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import operator
46
+ import warnings
47
+ from dataclasses import dataclass
48
+
49
+ import numpy as np
50
+ from numpy.typing import NDArray
51
+ from scipy import optimize, stats
52
+
53
+ from ertkit._linalg import matmul
54
+ from ertkit._warnings import ConvergenceWarning, DataWarning
55
+ from ertkit.results import InversionResult
56
+ from ertkit.ves.data import Sounding
57
+ from ertkit.ves.forward import _rhoa
58
+ from ertkit.ves.invert import _POOR_FIT_P_VALUE, _segments, _static_shift_hint
59
+ from ertkit.ves.model import LayeredModel
60
+
61
+ __all__ = ["DOI_THRESHOLD", "SmoothFit", "invert_smooth"]
62
+
63
+ _STEP = 1e-30
64
+ DOI_THRESHOLD = 0.1
65
+ """Rescaled depth-of-investigation index at which a layer counts as unresolved
66
+ (Oldenburg and Li 1999)."""
67
+ _SMOOTHEST_FITS = "the smoothest model fits better than the target misfit"
68
+ _TARGET_REACHED = "target misfit reached and the model is stable"
69
+ _TARGET_UNREACHABLE = "the target misfit cannot be reached; the misfit was minimized"
70
+ _TARGET_RELAXED = (
71
+ "chi2 = N_d cannot be reached; the model fits 1.1 times the smallest misfit"
72
+ )
73
+ _REFIT_FACTOR = 1.1
74
+ """When chi2 = N_d cannot be reached, the misfit targeted instead, as a multiple
75
+ of the smallest misfit found (a smooth model rather than the oscillating
76
+ misfit-minimizing one)."""
77
+ _ALPHA_S_DOI = 0.1
78
+ """Smallness weight, relative to roughness, in the two reference inversions,
79
+ for 30 layers; it is scaled with the layer density (see ``_alpha_s``)."""
80
+ _REFERENCE_FACTORS = (0.1, 10.0)
81
+ """Reference models, as multiples of the geometric mean apparent resistivity."""
82
+ _DOI_GAP_FACTOR = 2.0
83
+ """Resolved gaps narrower than this factor in depth, inside the unresolved zone
84
+ that reaches the bottom of the model, are bridged when the depth of
85
+ investigation is placed."""
86
+
87
+
88
+ @dataclass(frozen=True, eq=False, kw_only=True, slots=True)
89
+ class SmoothFit(InversionResult):
90
+ r"""Result of a smooth (Occam) inversion.
91
+
92
+ Attributes
93
+ ----------
94
+ model : LayeredModel
95
+ The smooth model.
96
+ mu : float
97
+ Final regularization weight :math:`\mu`.
98
+ sensitivity : ndarray
99
+ Error-weighted sensitivity of the data to each layer,
100
+ :math:`\lVert J_{:,j} / s \rVert`, with :math:`J` the Jacobian of
101
+ :math:`\ln \rho_a` with respect to :math:`\ln \rho_j`. It is per
102
+ layer, not per meter; since the layers thicken with depth, it is
103
+ roughly a sensitivity per unit of logarithmic depth.
104
+ doi_index : ndarray
105
+ Magnitude of the rescaled Oldenburg and Li index of each layer: near 0
106
+ where the data control the model, near 1 where they do not. ``nan`` if
107
+ the depth of investigation was not computed or could not be
108
+ determined.
109
+ doi : float
110
+ Depth of investigation in meters: the top of the unresolved zone that
111
+ reaches the bottom of the model (rescaled index at least
112
+ :data:`DOI_THRESHOLD`, bridging resolved gaps narrower than a factor
113
+ of 2 in depth). Below it the model reflects the regularization. Some
114
+ shallower layers can also be unresolved (for example inside a thin
115
+ resistive layer whose thickness and resistivity are equivalent);
116
+ plots shade them too. ``0.0`` if no layer is resolved, ``nan`` if not
117
+ computed or not determined.
118
+
119
+ See Also
120
+ --------
121
+ ertkit.InversionResult : The attributes common to all results.
122
+ """
123
+
124
+ model: LayeredModel
125
+ mu: float
126
+ sensitivity: NDArray[np.float64]
127
+ doi_index: NDArray[np.float64]
128
+ doi: float
129
+
130
+ def _details(self) -> list[str]:
131
+ doi = "not computed" if np.isnan(self.doi) else f"{self.doi:.3g} m"
132
+ return [
133
+ (
134
+ f"{self.model.n_layers} layers, the deepest interface at "
135
+ f"{self.model.depths[-1]:.3g} m; mu = {self.mu:.3g}"
136
+ ),
137
+ f"depth of investigation: {doi}",
138
+ ]
139
+
140
+
141
+ def _layer_thicknesses(
142
+ data: Sounding, n_layers: int, depth_range: tuple[float, float] | None
143
+ ) -> NDArray[np.float64]:
144
+ if depth_range is None:
145
+ # From well above the shallowest sensitivity to well below the deepest.
146
+ depth_range = (
147
+ 0.3 * float(data.survey.ab2.min()),
148
+ 3.0 * float(data.survey.ab2.max()),
149
+ )
150
+ top, bottom = depth_range
151
+ if not (0 < top < bottom and np.isfinite(bottom)):
152
+ raise ValueError("depth_range must satisfy 0 < top < bottom")
153
+ interfaces = np.geomspace(top, bottom, n_layers - 1)
154
+ return np.diff(np.concatenate([[0.0], interfaces]))
155
+
156
+
157
+ class _Occam:
158
+ """One smooth inversion problem: data, layers and regularization."""
159
+
160
+ def __init__(
161
+ self,
162
+ data: Sounding,
163
+ thickness: NDArray[np.float64],
164
+ alpha_s: float,
165
+ m_ref: float,
166
+ ) -> None:
167
+ self.data = data
168
+ self.h = thickness
169
+ self.n = thickness.size + 1
170
+ self.d = data.log_data
171
+ self.w = 1.0 / data.log_std
172
+ diff = np.diff(np.eye(self.n), axis=0)
173
+ self.alpha_s = alpha_s
174
+ self.reg = matmul(diff.T, diff) + alpha_s * np.eye(self.n)
175
+ self.reg_rhs = alpha_s * m_ref * np.ones(self.n)
176
+ # Models outside this range cannot be meaningful for these data, and
177
+ # far outside it the forward model overflows.
178
+ self.m_lo = float(np.log(data.rhoa.min() / 1e3))
179
+ self.m_hi = float(np.log(data.rhoa.max() * 1e3))
180
+
181
+ def predict(
182
+ self, m: NDArray[np.float64] | NDArray[np.complex128]
183
+ ) -> NDArray[np.complex128]:
184
+ rhoa = _rhoa(np.exp(m), self.h, self.data.survey.ab2, self.data.survey.mn2)
185
+ return np.asarray(np.log(rhoa.astype(complex)))
186
+
187
+ def chi2(self, m: NDArray[np.float64]) -> float:
188
+ """Misfit of model m; ``inf`` for models outside the meaningful range."""
189
+ if not np.all(np.isfinite(m)) or m.min() < self.m_lo or m.max() > self.m_hi:
190
+ return float("inf")
191
+ with np.errstate(all="ignore"):
192
+ value = float(np.sum((self.w * (self.d - self.predict(m).real)) ** 2))
193
+ return value if np.isfinite(value) else float("inf")
194
+
195
+ def jacobian(self, m: NDArray[np.float64]) -> NDArray[np.float64]:
196
+ jac = np.empty((self.d.size, self.n))
197
+ for k in range(self.n):
198
+ bumped = m.astype(complex)
199
+ bumped[k] += 1j * _STEP
200
+ jac[:, k] = self.predict(bumped).imag / _STEP
201
+ return jac
202
+
203
+ def model_for(
204
+ self, mu: float, gram: NDArray[np.float64], rhs: NDArray[np.float64]
205
+ ) -> NDArray[np.float64]:
206
+ return np.asarray(
207
+ np.linalg.solve(gram + mu * self.reg, rhs + mu * self.reg_rhs)
208
+ )
209
+
210
+ def step(
211
+ self, m: NDArray[np.float64], target: float
212
+ ) -> tuple[NDArray[np.float64], float, bool, bool]:
213
+ """One Occam iteration.
214
+
215
+ Returns the new model, mu, whether the target is reachable, and whether
216
+ even the smoothest model tried fits better than the target.
217
+ """
218
+ jac = self.jacobian(m)
219
+ wj = self.w[:, np.newaxis] * jac
220
+ gram = matmul(wj.T, wj)
221
+ d_hat = self.d - self.predict(m).real + matmul(jac, m)
222
+ rhs = matmul(wj.T, self.w * d_hat)
223
+ mu_ref = float(np.trace(gram) / np.trace(self.reg))
224
+
225
+ def misfit(log_mu: float) -> float:
226
+ return self.chi2(self.model_for(mu_ref * 10.0**log_mu, gram, rhs))
227
+
228
+ grid = np.linspace(-6.0, 6.0, 49)
229
+ values = np.array([misfit(x) for x in grid])
230
+ reachable = bool(np.any(values <= target))
231
+ smoothest = bool(values[-1] <= target)
232
+ if not np.any(np.isfinite(values)):
233
+ return m, float("nan"), False, False
234
+ if not reachable:
235
+ i = int(np.argmin(values))
236
+ lo, hi = grid[max(i - 1, 0)], grid[min(i + 1, grid.size - 1)]
237
+ best = optimize.minimize_scalar(misfit, bounds=(lo, hi), method="bounded")
238
+ log_mu = float(best.x) if best.fun <= values[i] else float(grid[i])
239
+ if best.fun <= target:
240
+ # The grid missed a narrow range of mu that reaches the target:
241
+ # take the largest mu with chi2 = target between the refined
242
+ # minimum and the next grid point above it.
243
+ reachable = True
244
+ above = grid[(grid > log_mu) & (values > target)]
245
+ if above.size:
246
+ log_mu = float(
247
+ optimize.brentq(
248
+ lambda x: misfit(x) - target, log_mu, above[0], xtol=1e-6
249
+ )
250
+ )
251
+ else:
252
+ below = np.flatnonzero(values <= target)
253
+ i = int(below.max())
254
+ if i == grid.size - 1:
255
+ log_mu = float(grid[-1])
256
+ else:
257
+ log_mu = float(
258
+ optimize.brentq(
259
+ lambda x: misfit(x) - target, grid[i], grid[i + 1], xtol=1e-6
260
+ )
261
+ )
262
+ mu = mu_ref * 10.0**log_mu
263
+ return self.model_for(mu, gram, rhs), mu, reachable, smoothest
264
+
265
+ def run(
266
+ self, m0: NDArray[np.float64], max_iter: int, target: float | None = None
267
+ ) -> tuple[NDArray[np.float64], float, bool, str, int]:
268
+ target = float(self.d.size) if target is None else float(target)
269
+ m, mu = m0, float("nan")
270
+ current = self.chi2(m)
271
+ for k in range(1, max_iter + 1):
272
+ new, new_mu, reachable, smoothest = self.step(m, target)
273
+ new_chi2 = self.chi2(new)
274
+ if not reachable:
275
+ # Accept only steps that reduce the misfit; halve otherwise.
276
+ for _ in range(20):
277
+ if new_chi2 < current:
278
+ break
279
+ new = m + 0.5 * (new - m)
280
+ new_chi2 = self.chi2(new)
281
+ decrease = (
282
+ (current - new_chi2) / current if np.isfinite(current) else 1.0
283
+ )
284
+ if not new_chi2 < current or decrease < 1e-3:
285
+ if new_chi2 < current:
286
+ m, mu, current = new, new_mu, new_chi2
287
+ return m, mu, True, _TARGET_UNREACHABLE, k
288
+ change = float(np.linalg.norm(new - m) / np.sqrt(self.n))
289
+ m, mu, current = new, new_mu, new_chi2
290
+ if change < 1e-3 and smoothest:
291
+ return m, mu, True, _SMOOTHEST_FITS, k
292
+ if reachable and abs(current - target) <= 0.01 * target and change < 1e-3:
293
+ return m, mu, True, _TARGET_REACHED, k
294
+ return m, mu, False, f"stopped after {max_iter} iterations", max_iter
295
+
296
+
297
+ def _alpha_s(n_layers: int) -> float:
298
+ """Smallness weight for the reference inversions.
299
+
300
+ Scaled with the square of the layer density, so that the reference models
301
+ act over the same depth range whatever the number of layers.
302
+ """
303
+ return _ALPHA_S_DOI * (28.0 / max(n_layers - 2, 1)) ** 2
304
+
305
+
306
+ def _doi(
307
+ data: Sounding,
308
+ thickness: NDArray[np.float64],
309
+ max_iter: int,
310
+ target: float,
311
+ ) -> tuple[NDArray[np.float64], float]:
312
+ """Oldenburg and Li (1999) index and depth of investigation.
313
+
314
+ The two reference inversions aim at the same misfit as the main inversion.
315
+ """
316
+ mean = float(np.exp(np.mean(data.log_data)))
317
+ refs = tuple(np.log(factor * mean) for factor in _REFERENCE_FACTORS)
318
+ models = []
319
+ for ref in refs:
320
+ problem = _Occam(data, thickness, _alpha_s(thickness.size + 1), ref)
321
+ m, _, converged, reason, _ = problem.run(
322
+ np.full(problem.n, ref), max_iter, target=target
323
+ )
324
+ if not converged:
325
+ warnings.warn(
326
+ f"a reference inversion for the depth of investigation did not "
327
+ f"converge ({reason}); the depth of investigation may be unreliable",
328
+ ConvergenceWarning,
329
+ stacklevel=3,
330
+ )
331
+ models.append(m)
332
+ index = (models[0] - models[1]) / (refs[0] - refs[1])
333
+ bottom = float(index[-1])
334
+ if bottom <= 0.2:
335
+ warnings.warn(
336
+ "the depth of investigation could not be determined: the reference "
337
+ "models still agree at the bottom of the model; increase depth_range",
338
+ DataWarning,
339
+ stacklevel=3,
340
+ )
341
+ return np.full(index.size, np.nan), float("nan")
342
+ scaled = np.abs(index) / bottom
343
+ return scaled, _doi_depth(scaled, np.concatenate([[0.0], np.cumsum(thickness)]))
344
+
345
+
346
+ def _doi_depth(scaled: NDArray[np.float64], tops: NDArray[np.float64]) -> float:
347
+ """Top of the unresolved zone that reaches the bottom of the model.
348
+
349
+ Starting from the bottom layer (index 1 by construction), the zone extends
350
+ upward through layers whose index reaches the threshold, bridging resolved
351
+ gaps narrower than ``_DOI_GAP_FACTOR`` in depth. A wider resolved gap ends
352
+ it; unresolved layers above that gap do not set the depth of investigation
353
+ (plots shade them separately).
354
+ """
355
+ unresolved = scaled >= DOI_THRESHOLD
356
+ if np.all(unresolved):
357
+ return 0.0
358
+ j = unresolved.size - 1
359
+ while j > 0:
360
+ if unresolved[j - 1]:
361
+ j -= 1
362
+ continue
363
+ k = j - 1 # find the top of the resolved gap above layer j
364
+ while k >= 0 and not unresolved[k]:
365
+ k -= 1
366
+ if k >= 0 and tops[j] < _DOI_GAP_FACTOR * max(tops[k + 1], tops[1]):
367
+ j = k # a narrow gap with unresolved layers above it: bridge it
368
+ continue
369
+ break
370
+ return float(tops[j])
371
+
372
+
373
+ def invert_smooth(
374
+ data: Sounding,
375
+ *,
376
+ n_layers: int = 30,
377
+ depth_range: tuple[float, float] | None = None,
378
+ depth_of_investigation: bool = True,
379
+ max_iter: int = 30,
380
+ ) -> SmoothFit:
381
+ r"""Smooth (Occam) inversion with sensitivity and depth of investigation.
382
+
383
+ Parameters
384
+ ----------
385
+ data : Sounding
386
+ The data and their error model.
387
+ n_layers : int, optional
388
+ Number of layers, including the bottom half-space. Default 30.
389
+ depth_range : (float, float), optional
390
+ Depths in meters of the first and last layer interfaces, which are
391
+ spaced logarithmically. Default: 0.3 times the smallest and 3 times
392
+ the largest AB/2.
393
+ depth_of_investigation : bool, optional
394
+ Also compute the Oldenburg and Li depth of investigation (two further
395
+ inversions). Default True.
396
+ max_iter : int, optional
397
+ Maximum number of Occam iterations. Default 30.
398
+
399
+ Returns
400
+ -------
401
+ SmoothFit
402
+
403
+ Raises
404
+ ------
405
+ TypeError
406
+ If ``data`` is not a :class:`Sounding`.
407
+ ValueError
408
+ If ``n_layers`` is less than 2, ``max_iter`` is less than 1, or
409
+ ``depth_range`` is invalid.
410
+
411
+ Warns
412
+ -----
413
+ ConvergenceWarning
414
+ If the iterations stop before the model is stable, or a reference
415
+ inversion for the depth of investigation does not converge.
416
+ DataWarning
417
+ If even the smallest misfit is improbably large (goodness-of-fit
418
+ p-value below 0.01: the errors may be underestimated), if even the
419
+ smoothest model fits better than the target (the errors may be
420
+ overestimated), or if the depth of investigation cannot be determined
421
+ (extend ``depth_range``).
422
+
423
+ Notes
424
+ -----
425
+ The method is Occam's inversion (Constable et al. 1987). If no model
426
+ reaches :math:`\chi^2 = N_d` (which happens for a few percent of soundings
427
+ even with correct errors), the inversion fits 1.1 times the smallest
428
+ misfit instead, recorded in ``chi2_target`` and ``stop_reason``, and warns
429
+ only if that misfit is improbably large.
430
+
431
+ The depth of investigation follows Oldenburg and Li (1999), with reference
432
+ models of 0.1 and 10 times the geometric mean apparent resistivity (a
433
+ factor of 100 apart) that aim at the same misfit. The module
434
+ documentation gives the details.
435
+
436
+ Examples
437
+ --------
438
+ >>> import numpy as np
439
+ >>> from ertkit.ves import LayeredModel, Sounding, Survey, invert_smooth
440
+ >>> truth = LayeredModel(resistivity=[150.0, 30.0, 800.0], thickness=[2.0, 15.0])
441
+ >>> survey = Survey.schlumberger(ab2=np.geomspace(2.5, 200.0, 22), mn2=0.5)
442
+ >>> smooth = invert_smooth(Sounding.synthetic(truth, survey, rng=5))
443
+ >>> smooth.model.n_layers, smooth.converged
444
+ (30, True)
445
+ """
446
+ data_obj: object = data
447
+ if not isinstance(data_obj, Sounding):
448
+ raise TypeError(f"data must be a Sounding, got {type(data_obj).__name__}")
449
+ n_layers = operator.index(n_layers)
450
+ max_iter = operator.index(max_iter)
451
+ if n_layers < 2:
452
+ raise ValueError("a smooth model needs at least 2 layers")
453
+ if max_iter < 1:
454
+ raise ValueError("max_iter must be at least 1")
455
+ thickness = _layer_thicknesses(data, n_layers, depth_range)
456
+ start_value = float(np.mean(data.log_data))
457
+ problem = _Occam(data, thickness, alpha_s=0.0, m_ref=start_value)
458
+ start = np.full(n_layers, start_value)
459
+ target = float(data.n_data)
460
+ m, mu, converged, reason, n_iter = problem.run(start, max_iter)
461
+ if converged and reason == _TARGET_UNREACHABLE:
462
+ # The misfit-minimizing model oscillates; fit a little less closely.
463
+ chi2_min = problem.chi2(m)
464
+ target = _REFIT_FACTOR * chi2_min
465
+ m, mu, converged, refit_reason, refit_iter = problem.run(
466
+ start, max_iter, target=target
467
+ )
468
+ n_iter += refit_iter
469
+ reason = _TARGET_RELAXED if converged else refit_reason
470
+ if stats.chi2.sf(chi2_min, data.n_data) < _POOR_FIT_P_VALUE:
471
+ n_segments = int(_segments(data.survey.mn2).max()) + 1
472
+ warnings.warn(
473
+ f"poor fit: the smallest misfit is chi2 = {chi2_min:.1f} for "
474
+ f"{data.n_data} data; the data errors may be underestimated, or "
475
+ f"the earth is not layered{_static_shift_hint(n_segments)}",
476
+ DataWarning,
477
+ stacklevel=2,
478
+ )
479
+ if not converged:
480
+ warnings.warn(f"Occam inversion: {reason}", ConvergenceWarning, stacklevel=2)
481
+ elif reason == _SMOOTHEST_FITS:
482
+ warnings.warn(
483
+ f"{reason}: the data errors may be overestimated", DataWarning, stacklevel=2
484
+ )
485
+
486
+ jac = problem.jacobian(m)
487
+ sensitivity = np.linalg.norm(problem.w[:, np.newaxis] * jac, axis=0)
488
+ if depth_of_investigation:
489
+ doi_index, doi = _doi(data, thickness, max_iter, target)
490
+ else:
491
+ doi_index, doi = np.full(n_layers, np.nan), float("nan")
492
+
493
+ predicted = problem.predict(m).real
494
+ return SmoothFit(
495
+ predicted=np.exp(predicted),
496
+ residuals=(data.log_data - predicted) / data.log_std,
497
+ chi2=problem.chi2(m),
498
+ chi2_target=target,
499
+ n_data=data.n_data,
500
+ converged=converged,
501
+ stop_reason=reason,
502
+ n_iterations=n_iter,
503
+ model=LayeredModel(resistivity=np.exp(m), thickness=thickness),
504
+ mu=float(mu),
505
+ sensitivity=sensitivity,
506
+ doi_index=doi_index,
507
+ doi=doi,
508
+ )
ertkit/ves/survey.py ADDED
@@ -0,0 +1,119 @@
1
+ """Symmetric four-electrode soundings (Schlumberger and Wenner)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ from numpy.typing import ArrayLike, NDArray
9
+
10
+ from ertkit.arrays import geometric_factor
11
+
12
+ __all__ = ["Survey"]
13
+
14
+
15
+ @dataclass(frozen=True, eq=False, kw_only=True, slots=True)
16
+ class Survey:
17
+ r"""Readings of a symmetric collinear sounding.
18
+
19
+ The current electrodes sit at :math:`\pm AB/2` and the potential
20
+ electrodes at :math:`\pm MN/2` about the sounding center, so each reading
21
+ is given by its half-spacings. Schlumberger soundings with a changing
22
+ :math:`MN` and Wenner soundings (:math:`MN/2 = AB/6`) are both covered.
23
+
24
+ Parameters
25
+ ----------
26
+ ab2 : array_like
27
+ Half the current-electrode spacing, :math:`AB/2`, in meters, one value
28
+ per reading.
29
+ mn2 : array_like
30
+ Half the potential-electrode spacing, :math:`MN/2`, in meters: a single
31
+ value for all readings or one per reading.
32
+
33
+ Raises
34
+ ------
35
+ ValueError
36
+ If spacings are not positive and finite, or if :math:`MN/2 \ge AB/2`
37
+ for any reading.
38
+
39
+ See Also
40
+ --------
41
+ Survey.schlumberger, Survey.wenner
42
+
43
+ Examples
44
+ --------
45
+ >>> from ertkit.ves import Survey
46
+ >>> survey = Survey.schlumberger(ab2=[3.0, 10.0, 30.0], mn2=0.5)
47
+ >>> survey.n_readings
48
+ 3
49
+ >>> Survey.wenner([2.0, 5.0]).mn2.tolist()
50
+ [1.0, 2.5]
51
+ """
52
+
53
+ ab2: NDArray[np.float64]
54
+ mn2: NDArray[np.float64]
55
+
56
+ def __post_init__(self) -> None:
57
+ ab2 = np.array(self.ab2, dtype=np.float64, copy=True, ndmin=1)
58
+ mn2 = np.array(
59
+ np.broadcast_to(np.asarray(self.mn2, dtype=np.float64), ab2.shape),
60
+ dtype=np.float64,
61
+ copy=True,
62
+ )
63
+ if ab2.ndim != 1 or ab2.size == 0:
64
+ raise ValueError("ab2 must be a non-empty one-dimensional array")
65
+ for name, arr in (("ab2", ab2), ("mn2", mn2)):
66
+ if not np.all(np.isfinite(arr)) or np.any(arr <= 0):
67
+ raise ValueError(f"{name} must be positive and finite")
68
+ if np.any(mn2 >= ab2):
69
+ raise ValueError("MN/2 must be smaller than AB/2 for every reading")
70
+ ab2.setflags(write=False)
71
+ mn2.setflags(write=False)
72
+ object.__setattr__(self, "ab2", ab2)
73
+ object.__setattr__(self, "mn2", mn2)
74
+
75
+ @classmethod
76
+ def schlumberger(cls, ab2: ArrayLike, mn2: ArrayLike) -> Survey:
77
+ """Create a Schlumberger sounding.
78
+
79
+ Parameters
80
+ ----------
81
+ ab2, mn2 : array_like
82
+ Half-spacings in meters, as in :class:`Survey`.
83
+
84
+ Returns
85
+ -------
86
+ Survey
87
+ """
88
+ return cls(
89
+ ab2=np.asarray(ab2, dtype=np.float64), mn2=np.asarray(mn2, dtype=np.float64)
90
+ )
91
+
92
+ @classmethod
93
+ def wenner(cls, a: ArrayLike) -> Survey:
94
+ """Create a Wenner sounding with electrode spacings ``a``.
95
+
96
+ Parameters
97
+ ----------
98
+ a : array_like
99
+ Spacing between adjacent electrodes, in meters, one per reading.
100
+
101
+ Returns
102
+ -------
103
+ Survey
104
+ Readings with :math:`AB/2 = 1.5a` and :math:`MN/2 = 0.5a`.
105
+ """
106
+ a = np.asarray(a, dtype=np.float64)
107
+ return cls(ab2=1.5 * a, mn2=0.5 * a)
108
+
109
+ @property
110
+ def n_readings(self) -> int:
111
+ """Number of readings."""
112
+ return int(self.ab2.size)
113
+
114
+ @property
115
+ def geometric_factor(self) -> NDArray[np.float64]:
116
+ r"""Geometric factor :math:`K` of each reading, in meters."""
117
+ near = self.ab2 - self.mn2
118
+ far = self.ab2 + self.mn2
119
+ return geometric_factor(near, far, far, near)