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/__init__.py +24 -0
- ertkit/_linalg.py +32 -0
- ertkit/_show_versions.py +49 -0
- ertkit/_version.py +24 -0
- ertkit/_warnings.py +18 -0
- ertkit/arrays.py +71 -0
- ertkit/noise.py +134 -0
- ertkit/plot/__init__.py +19 -0
- ertkit/plot/_ves.py +234 -0
- ertkit/py.typed +0 -0
- ertkit/results.py +82 -0
- ertkit/ves/__init__.py +28 -0
- ertkit/ves/data.py +167 -0
- ertkit/ves/forward.py +232 -0
- ertkit/ves/invert.py +632 -0
- ertkit/ves/io.py +179 -0
- ertkit/ves/model.py +86 -0
- ertkit/ves/smooth.py +508 -0
- ertkit/ves/survey.py +119 -0
- ertkit-0.1.0.dist-info/METADATA +156 -0
- ertkit-0.1.0.dist-info/RECORD +23 -0
- ertkit-0.1.0.dist-info/WHEEL +4 -0
- ertkit-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|