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/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """Vertical electrical sounding (VES): layered-earth modeling and inversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ertkit.ves.data import Sounding
6
+ from ertkit.ves.forward import forward, hankel_j0, potential, resistivity_transform
7
+ from ertkit.ves.invert import LayerFit, invert_few_layer
8
+ from ertkit.ves.io import read_csv, write_csv
9
+ from ertkit.ves.model import LayeredModel
10
+ from ertkit.ves.smooth import DOI_THRESHOLD, SmoothFit, invert_smooth
11
+ from ertkit.ves.survey import Survey
12
+
13
+ __all__ = [
14
+ "DOI_THRESHOLD",
15
+ "LayerFit",
16
+ "LayeredModel",
17
+ "SmoothFit",
18
+ "Sounding",
19
+ "Survey",
20
+ "forward",
21
+ "hankel_j0",
22
+ "invert_few_layer",
23
+ "invert_smooth",
24
+ "potential",
25
+ "read_csv",
26
+ "resistivity_transform",
27
+ "write_csv",
28
+ ]
ertkit/ves/data.py ADDED
@@ -0,0 +1,167 @@
1
+ """Observed or synthetic VES data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from dataclasses import dataclass, field
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+ from ertkit._warnings import DataWarning
12
+ from ertkit.noise import ErrorModel
13
+ from ertkit.ves.forward import forward
14
+ from ertkit.ves.model import LayeredModel
15
+ from ertkit.ves.survey import Survey
16
+
17
+ __all__ = ["Sounding"]
18
+
19
+
20
+ @dataclass(frozen=True, eq=False, kw_only=True, slots=True)
21
+ class Sounding:
22
+ r"""Apparent resistivities of a sounding with their error model.
23
+
24
+ Readings that cannot be inverted in log space (transfer resistance
25
+ :math:`R \le 0`, or :math:`|R|` below three times the absolute error) are
26
+ removed with a :class:`~ertkit.DataWarning`.
27
+
28
+ Parameters
29
+ ----------
30
+ survey : Survey
31
+ Electrode half-spacings of the readings.
32
+ rhoa : array_like
33
+ Apparent resistivity in ohm meters, one value per reading.
34
+ error : ErrorModel or None, optional
35
+ Measurement-error model. Default (or None): ``ErrorModel()``, 3%
36
+ relative.
37
+
38
+ Raises
39
+ ------
40
+ TypeError
41
+ If ``survey`` or ``error`` has the wrong type.
42
+ ValueError
43
+ If ``rhoa`` does not have one finite value per reading, or if no
44
+ reading is usable.
45
+
46
+ Warns
47
+ -----
48
+ DataWarning
49
+ If readings are removed.
50
+
51
+ Notes
52
+ -----
53
+ The data weights use the observed transfer resistances, while
54
+ :meth:`synthetic` draws noise with standard deviations computed from the
55
+ noise-free ones; with an absolute error part, readings that happen to come
56
+ out low are therefore weighted slightly less.
57
+
58
+ Examples
59
+ --------
60
+ >>> from ertkit.ves import Sounding, Survey
61
+ >>> survey = Survey.schlumberger(ab2=[3.0, 10.0, 30.0], mn2=0.5)
62
+ >>> Sounding(survey=survey, rhoa=[120.0, 80.0, 150.0]).n_data
63
+ 3
64
+ """
65
+
66
+ survey: Survey
67
+ rhoa: NDArray[np.float64]
68
+ error: ErrorModel = field(default_factory=ErrorModel)
69
+
70
+ def __post_init__(self) -> None:
71
+ # Checked through plain objects: callers may pass anything at runtime.
72
+ survey_obj: object = self.survey
73
+ error_obj: object = self.error
74
+ if not isinstance(survey_obj, Survey):
75
+ raise TypeError(f"survey must be a Survey, got {type(survey_obj).__name__}")
76
+ if error_obj is None:
77
+ object.__setattr__(self, "error", ErrorModel())
78
+ elif not isinstance(error_obj, ErrorModel):
79
+ raise TypeError(
80
+ f"error must be an ErrorModel, got {type(error_obj).__name__}"
81
+ )
82
+ rhoa = np.array(self.rhoa, dtype=np.float64, copy=True, ndmin=1)
83
+ if rhoa.shape != (self.survey.n_readings,):
84
+ raise ValueError(
85
+ f"need {self.survey.n_readings} apparent resistivities, got shape {rhoa.shape}"
86
+ )
87
+ if not np.all(np.isfinite(rhoa)):
88
+ raise ValueError("apparent resistivities must be finite")
89
+ keep = self.error.usable(rhoa / self.survey.geometric_factor)
90
+ if not np.any(keep):
91
+ raise ValueError(
92
+ "no reading is usable (all R <= 0 or below the noise level)"
93
+ )
94
+ survey = self.survey
95
+ if not np.all(keep):
96
+ dropped = np.flatnonzero(~keep)
97
+ spacings = ", ".join(f"{v:.4g}" for v in self.survey.ab2[dropped])
98
+ warnings.warn(
99
+ f"removed {dropped.size} reading(s) with R <= 0 or |R| below three "
100
+ f"times the absolute error: indices {dropped.tolist()} "
101
+ f"(AB/2 = {spacings} m)",
102
+ DataWarning,
103
+ stacklevel=3,
104
+ )
105
+ survey = Survey(ab2=survey.ab2[keep], mn2=survey.mn2[keep])
106
+ rhoa = rhoa[keep]
107
+ rhoa.setflags(write=False)
108
+ object.__setattr__(self, "survey", survey)
109
+ object.__setattr__(self, "rhoa", rhoa)
110
+
111
+ @classmethod
112
+ def synthetic(
113
+ cls,
114
+ model: LayeredModel,
115
+ survey: Survey,
116
+ error: ErrorModel | None = None,
117
+ rng: int | np.random.Generator | None = None,
118
+ ) -> Sounding:
119
+ r"""Simulate noisy data from a layered model.
120
+
121
+ Noise is drawn from the error model in log space:
122
+ :math:`\ln \rho_a^{\mathrm{obs}} = \ln \rho_a + s \, \varepsilon`, with
123
+ :math:`\varepsilon \sim N(0, 1)` and :math:`s` evaluated at the
124
+ noise-free data.
125
+
126
+ Parameters
127
+ ----------
128
+ model : LayeredModel
129
+ The true model.
130
+ survey : Survey
131
+ Electrode half-spacings.
132
+ error : ErrorModel, optional
133
+ Error model used both to draw the noise and to weight the data.
134
+ Default ``ErrorModel()``.
135
+ rng : int, numpy.random.Generator or None, optional
136
+ Seed or generator, normalized with :func:`numpy.random.default_rng`.
137
+
138
+ Returns
139
+ -------
140
+ Sounding
141
+ """
142
+ error = ErrorModel() if error is None else error
143
+ generator = np.random.default_rng(rng)
144
+ clean = forward(model, survey)
145
+ s = error.log_std(clean / survey.geometric_factor)
146
+ noisy = clean * np.exp(s * generator.standard_normal(clean.size))
147
+ return cls(survey=survey, rhoa=noisy, error=error)
148
+
149
+ @property
150
+ def n_data(self) -> int:
151
+ """Number of readings."""
152
+ return int(self.rhoa.size)
153
+
154
+ @property
155
+ def resistance(self) -> NDArray[np.float64]:
156
+ r"""Transfer resistance :math:`R = \rho_a / K`, in ohms."""
157
+ return self.rhoa / self.survey.geometric_factor
158
+
159
+ @property
160
+ def log_data(self) -> NDArray[np.float64]:
161
+ r"""The inverted data :math:`d = \ln \rho_a`."""
162
+ return np.log(self.rhoa)
163
+
164
+ @property
165
+ def log_std(self) -> NDArray[np.float64]:
166
+ r"""Standard deviation of :math:`\ln \rho_a` for each reading."""
167
+ return self.error.log_std(self.resistance)
ertkit/ves/forward.py ADDED
@@ -0,0 +1,232 @@
1
+ r"""Forward modeling of vertical electrical soundings over a layered earth.
2
+
3
+ The potential on the surface at distance :math:`r` from a point source of
4
+ current :math:`I` is
5
+
6
+ .. math::
7
+
8
+ V(r) = \frac{I}{2\pi} \int_0^\infty \mathcal{T}_1(\lambda) J_0(\lambda r) \, d\lambda,
9
+
10
+ where :math:`\mathcal{T}_1` is the resistivity transform of the layered earth
11
+ (Pekeris 1940; Koefoed 1979). The integral is evaluated with the 120-point
12
+ digital linear filter of Guptasarma and Singh (1997), taken from libdlf
13
+ (https://github.com/emsig/libdlf; filter values licensed CC BY 4.0). Its
14
+ relative error is below about 1e-6 for resistivity contrasts up to 1e4 between
15
+ layers, and grows in proportion to the contrast beyond that.
16
+
17
+ The functions here accept complex resistivities and thicknesses, so that
18
+ derivatives can be computed by the complex-step method.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable
24
+ from functools import cache
25
+ from typing import Any
26
+
27
+ import libdlf
28
+ import numpy as np
29
+ from numpy.typing import ArrayLike, NDArray
30
+
31
+ from ertkit.ves.model import LayeredModel
32
+ from ertkit.ves.survey import Survey
33
+
34
+ __all__ = ["forward", "hankel_j0", "potential", "resistivity_transform"]
35
+
36
+
37
+ @cache
38
+ def _filter() -> tuple[NDArray[np.float64], NDArray[np.float64]]:
39
+ """Base and J0 weights of the Guptasarma and Singh (1997) 120-point filter."""
40
+ base, weights = libdlf.hankel.gupt_120_1997()
41
+ return np.asarray(base, dtype=np.float64), np.asarray(weights, dtype=np.float64)
42
+
43
+
44
+ def _tanh(z: NDArray[Any]) -> NDArray[Any]:
45
+ """Hyperbolic tangent that is safe for complex arguments with Re(z) >= 0.
46
+
47
+ numpy's complex ``tanh`` overflows for large real parts on some platforms
48
+ (Windows). Since :math:`\\lambda h` always has a positive real part here,
49
+ :math:`\\tanh z = -\\operatorname{expm1}(-2z)/(2 + \\operatorname{expm1}(-2z))`,
50
+ which cannot overflow, is used for complex input.
51
+ """
52
+ if not np.iscomplexobj(z):
53
+ return np.tanh(z)
54
+ em = np.expm1(-2.0 * z)
55
+ return np.asarray(-em / (2.0 + em))
56
+
57
+
58
+ def resistivity_transform(
59
+ lam: ArrayLike, resistivity: ArrayLike, thickness: ArrayLike
60
+ ) -> NDArray[Any]:
61
+ r"""Resistivity transform :math:`\mathcal{T}_1(\lambda)` of a layered earth.
62
+
63
+ Computed by the recurrence
64
+
65
+ .. math::
66
+
67
+ \mathcal{T}_N = \rho_N, \qquad
68
+ \mathcal{T}_i = \frac{\mathcal{T}_{i+1} + \rho_i \tanh(\lambda h_i)}
69
+ {1 + \mathcal{T}_{i+1} \tanh(\lambda h_i)/\rho_i},
70
+ \qquad i = N-1, \dots, 1.
71
+
72
+ Parameters
73
+ ----------
74
+ lam : array_like
75
+ Hankel-transform variable :math:`\lambda`, in 1/m; any shape.
76
+ resistivity : array_like
77
+ Layer resistivities in ohm meters, length :math:`N`.
78
+ thickness : array_like
79
+ Thicknesses in meters of the upper :math:`N - 1` layers.
80
+
81
+ Returns
82
+ -------
83
+ ndarray
84
+ :math:`\mathcal{T}_1(\lambda)` in ohm meters, with the shape of ``lam``.
85
+ Complex if any input is complex.
86
+ """
87
+ rho = np.asarray(resistivity)
88
+ h = np.asarray(thickness)
89
+ lam = np.asarray(lam)
90
+ if rho.ndim != 1 or h.shape != (rho.size - 1,):
91
+ raise ValueError(
92
+ f"need {rho.size - 1} thicknesses for {rho.size} resistivities, "
93
+ f"got shape {h.shape}"
94
+ )
95
+ out = np.broadcast_to(rho[-1], lam.shape).astype(np.result_type(lam, rho, h, 1.0))
96
+ for i in range(rho.size - 2, -1, -1):
97
+ # A thickness with no imaginary part (all but the perturbed one, in a
98
+ # complex-step derivative) takes the much faster real tanh.
99
+ h_i = h[i].real if np.iscomplexobj(h) and h[i].imag == 0 else h[i]
100
+ t = _tanh(lam * h_i)
101
+ out = (out + rho[i] * t) / (1.0 + out * t / rho[i])
102
+ return out
103
+
104
+
105
+ def hankel_j0(
106
+ kernel: Callable[[NDArray[np.float64]], NDArray[Any]], r: ArrayLike
107
+ ) -> NDArray[Any]:
108
+ r"""Zeroth-order Hankel transform by digital linear filtering.
109
+
110
+ Approximates
111
+
112
+ .. math::
113
+
114
+ F(r) = \int_0^\infty f(\lambda) J_0(\lambda r) \, d\lambda
115
+ \approx \frac{1}{r} \sum_j f(b_j / r) \, w_j,
116
+
117
+ with the base :math:`b_j` and weights :math:`w_j` of the 120-point filter
118
+ of Guptasarma and Singh (1997).
119
+
120
+ Parameters
121
+ ----------
122
+ kernel : callable
123
+ The function :math:`f`. It receives an array of :math:`\lambda` values
124
+ of shape ``(len(r), 120)`` and must return an array of the same shape.
125
+ r : array_like
126
+ Positive distances in meters, one-dimensional.
127
+
128
+ Returns
129
+ -------
130
+ ndarray
131
+ :math:`F(r)`, one value per distance.
132
+
133
+ Raises
134
+ ------
135
+ ValueError
136
+ If a distance is not positive and finite.
137
+
138
+ Notes
139
+ -----
140
+ Filter values from libdlf (https://github.com/emsig/libdlf), licensed
141
+ CC BY 4.0.
142
+ """
143
+ base, weights = _filter()
144
+ r = np.atleast_1d(np.asarray(r, dtype=np.float64))
145
+ if r.ndim != 1 or np.any(~(r > 0)) or not np.all(np.isfinite(r)):
146
+ raise ValueError("distances must be a one-dimensional array of positive values")
147
+ lam = base[np.newaxis, :] / r[:, np.newaxis]
148
+ values = np.asarray(kernel(lam))
149
+ # An explicit sum rather than a matrix product: some BLAS builds (Accelerate
150
+ # with numpy 2.0) raise spurious floating-point warnings in matmul.
151
+ return np.asarray((values * weights).sum(axis=-1) / r)
152
+
153
+
154
+ def _rhoa(
155
+ resistivity: NDArray[Any],
156
+ thickness: NDArray[Any],
157
+ ab2: NDArray[np.float64],
158
+ mn2: NDArray[np.float64],
159
+ ) -> NDArray[Any]:
160
+ """Apparent resistivity of symmetric soundings; complex inputs allowed."""
161
+ near = ab2 - mn2
162
+ far = ab2 + mn2
163
+ r = np.concatenate([near, far])
164
+ v = hankel_j0(lambda lam: resistivity_transform(lam, resistivity, thickness), r) / (
165
+ 2.0 * np.pi
166
+ )
167
+ v_near, v_far = v[: ab2.size], v[ab2.size :]
168
+ # Delta V = V(AM) - V(BM) - V(AN) + V(BN) = 2 [V(near) - V(far)] for I = 1 A.
169
+ k = 2.0 * np.pi / (2.0 / near - 2.0 / far)
170
+ return np.asarray(k * 2.0 * (v_near - v_far))
171
+
172
+
173
+ def potential(
174
+ model: LayeredModel, r: ArrayLike, current: float = 1.0
175
+ ) -> NDArray[np.float64]:
176
+ """Surface potential at distance ``r`` from a point current source.
177
+
178
+ Parameters
179
+ ----------
180
+ model : LayeredModel
181
+ The layered earth.
182
+ r : array_like
183
+ Distances in meters from the source, positive.
184
+ current : float, optional
185
+ Source current in amperes. Default 1.
186
+
187
+ Returns
188
+ -------
189
+ ndarray of float
190
+ Potential in volts, one value per distance.
191
+
192
+ Raises
193
+ ------
194
+ ValueError
195
+ If a distance is not positive and finite.
196
+ """
197
+ r = np.atleast_1d(np.asarray(r, dtype=np.float64))
198
+ if np.any(~(r > 0)) or not np.all(np.isfinite(r)):
199
+ raise ValueError("distances must be positive and finite")
200
+ integral = hankel_j0(
201
+ lambda lam: resistivity_transform(lam, model.resistivity, model.thickness), r
202
+ )
203
+ return np.asarray(current * integral.real / (2.0 * np.pi), dtype=np.float64)
204
+
205
+
206
+ def forward(model: LayeredModel, survey: Survey) -> NDArray[np.float64]:
207
+ """Apparent resistivities of a sounding over a layered earth.
208
+
209
+ Parameters
210
+ ----------
211
+ model : LayeredModel
212
+ The layered earth.
213
+ survey : Survey
214
+ The readings, with their half-spacings.
215
+
216
+ Returns
217
+ -------
218
+ ndarray of float
219
+ Apparent resistivity in ohm meters, one value per reading. The finite
220
+ potential-electrode spacing is modeled exactly.
221
+
222
+ Examples
223
+ --------
224
+ >>> import numpy as np
225
+ >>> from ertkit.ves import LayeredModel, Survey, forward
226
+ >>> survey = Survey.schlumberger(ab2=[3.0, 30.0, 300.0], mn2=0.5)
227
+ >>> model = LayeredModel(resistivity=[100.0], thickness=[])
228
+ >>> np.allclose(forward(model, survey), 100.0)
229
+ True
230
+ """
231
+ rhoa = _rhoa(model.resistivity, model.thickness, survey.ab2, survey.mn2)
232
+ return np.asarray(rhoa.real, dtype=np.float64)