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 ADDED
@@ -0,0 +1,24 @@
1
+ """ertkit: resistivity inversion for groundwater exploration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from ertkit._show_versions import show_versions
8
+ from ertkit._warnings import ConvergenceWarning, DataWarning
9
+ from ertkit.noise import ErrorModel
10
+
11
+ try:
12
+ from ertkit._version import __version__
13
+ except ImportError: # pragma: no cover - only when the package is not installed
14
+ __version__ = "0+unknown"
15
+
16
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
17
+
18
+ __all__ = [
19
+ "ConvergenceWarning",
20
+ "DataWarning",
21
+ "ErrorModel",
22
+ "__version__",
23
+ "show_versions",
24
+ ]
ertkit/_linalg.py ADDED
@@ -0,0 +1,32 @@
1
+ """Small linear-algebra helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+
12
+ def matmul(a: NDArray[Any], b: NDArray[Any]) -> NDArray[Any]:
13
+ """Matrix product that checks its result instead of the floating-point flags.
14
+
15
+ With numpy 2.2 and earlier on recent macOS, the Accelerate BLAS can set
16
+ floating-point flags (divide by zero, overflow) during a matrix product of
17
+ ordinary finite values, which numpy then reports as spurious warnings. The
18
+ flags are therefore ignored during the product, and a genuine problem is
19
+ detected from the result: if finite inputs give a non-finite result, a
20
+ ``RuntimeWarning`` is raised.
21
+ """
22
+ with np.errstate(all="ignore"):
23
+ out = np.asarray(a @ b)
24
+ if (
25
+ not np.all(np.isfinite(out))
26
+ and np.all(np.isfinite(a))
27
+ and np.all(np.isfinite(b))
28
+ ):
29
+ warnings.warn(
30
+ "non-finite values in a matrix product", RuntimeWarning, stacklevel=2
31
+ )
32
+ return out
@@ -0,0 +1,49 @@
1
+ """Report the versions of ertkit, Python and the main dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import sys
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ _PACKAGES = ("ertkit", "numpy", "scipy", "libdlf", "matplotlib")
10
+
11
+
12
+ def _versions() -> dict[str, str]:
13
+ """Return the versions of Python, the platform and the main packages.
14
+
15
+ Returns
16
+ -------
17
+ dict of str to str
18
+ Name to version string; packages that are not installed are reported
19
+ as ``"not installed"``.
20
+ """
21
+ info = {
22
+ "python": sys.version.split()[0],
23
+ "platform": platform.platform(),
24
+ }
25
+ for name in _PACKAGES:
26
+ try:
27
+ info[name] = version(name)
28
+ except PackageNotFoundError:
29
+ info[name] = "not installed"
30
+ return info
31
+
32
+
33
+ def show_versions() -> None:
34
+ """Print the versions of ertkit, Python and the main dependencies.
35
+
36
+ Include this output when you report a problem.
37
+
38
+ Examples
39
+ --------
40
+ >>> import ertkit
41
+ >>> ertkit.show_versions() # doctest: +SKIP
42
+ python 3.13.16
43
+ ...
44
+ """
45
+ info = _versions()
46
+ width = max(len(name) for name in info)
47
+ for name, value in info.items():
48
+ # Printing is the purpose of this function.
49
+ print(f"{name:<{width}} {value}") # noqa: T201
ertkit/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
ertkit/_warnings.py ADDED
@@ -0,0 +1,18 @@
1
+ """Warning categories raised by ertkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["ConvergenceWarning", "DataWarning"]
6
+
7
+
8
+ class ConvergenceWarning(UserWarning):
9
+ """An iterative method stopped before meeting its convergence criterion."""
10
+
11
+
12
+ class DataWarning(UserWarning):
13
+ """Data were removed, or the data cannot support a result as requested.
14
+
15
+ Raised for removed readings, a poor fit, parameters the data do not
16
+ resolve, an undetermined depth of investigation, and errors that appear
17
+ over- or underestimated.
18
+ """
ertkit/arrays.py ADDED
@@ -0,0 +1,71 @@
1
+ """Electrode configurations and geometric factors.
2
+
3
+ All distances are in meters. A remote (infinitely distant) electrode is
4
+ given as ``np.inf``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from numpy.typing import ArrayLike, NDArray
11
+
12
+ __all__ = ["geometric_factor"]
13
+
14
+
15
+ def geometric_factor(
16
+ am: ArrayLike, bm: ArrayLike, an: ArrayLike, bn: ArrayLike
17
+ ) -> NDArray[np.float64]:
18
+ r"""Geometric factor of a four-electrode array on a half-space.
19
+
20
+ For current electrodes :math:`A`, :math:`B` and potential electrodes
21
+ :math:`M`, :math:`N` on the surface of a homogeneous half-space,
22
+
23
+ .. math::
24
+
25
+ K = \frac{2\pi}{1/AM - 1/BM - 1/AN + 1/BN},
26
+
27
+ so that the apparent resistivity is :math:`\rho_a = K \Delta V / I` with
28
+ :math:`\Delta V = V_M - V_N`.
29
+
30
+ Parameters
31
+ ----------
32
+ am, bm, an, bn : array_like
33
+ Distances in meters between the electrodes named by each argument.
34
+ They broadcast against each other. Use ``np.inf`` for a remote
35
+ electrode (for example ``bm = bn = np.inf`` for a pole-dipole array).
36
+
37
+ Returns
38
+ -------
39
+ ndarray of float
40
+ Geometric factor in meters. It is negative when :math:`V_M < V_N` on a
41
+ half-space, as in a dipole-dipole array ordered A, B, M, N, where
42
+ :math:`M` is nearer than :math:`N` to the current sink :math:`B`.
43
+
44
+ Raises
45
+ ------
46
+ ValueError
47
+ If a distance is not positive, or if the configuration measures no
48
+ potential difference on a half-space (zero denominator).
49
+
50
+ Examples
51
+ --------
52
+ A Wenner array with spacing 10 m:
53
+
54
+ >>> import numpy as np
55
+ >>> from ertkit.arrays import geometric_factor
56
+ >>> float(geometric_factor(10.0, 20.0, 20.0, 10.0) / (2 * np.pi))
57
+ 10.0
58
+ """
59
+ dists = np.broadcast_arrays(
60
+ *(np.asarray(d, dtype=np.float64) for d in (am, bm, an, bn))
61
+ )
62
+ if any(np.any(~(d > 0)) for d in dists):
63
+ raise ValueError("electrode distances must be positive (np.inf for remote)")
64
+ d_am, d_bm, d_an, d_bn = dists
65
+ denom = 1.0 / d_am - 1.0 / d_bm - 1.0 / d_an + 1.0 / d_bn
66
+ scale = 1.0 / np.minimum.reduce(dists)
67
+ if np.any(np.abs(denom) <= 1e-12 * scale):
68
+ raise ValueError(
69
+ "the configuration measures no potential difference on a half-space"
70
+ )
71
+ return np.asarray(2.0 * np.pi / denom, dtype=np.float64)
ertkit/noise.py ADDED
@@ -0,0 +1,134 @@
1
+ """Measurement-error models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import warnings
7
+ from dataclasses import dataclass
8
+
9
+ import numpy as np
10
+ from numpy.typing import ArrayLike, NDArray
11
+
12
+ from ertkit._warnings import DataWarning
13
+
14
+ __all__ = ["ErrorModel"]
15
+
16
+
17
+ @dataclass(frozen=True, kw_only=True, slots=True)
18
+ class ErrorModel:
19
+ r"""Standard deviation of measurement errors on the transfer resistance.
20
+
21
+ Errors are specified on the transfer resistance :math:`R = \Delta V / I`
22
+ (LaBrecque et al. 1996) as
23
+
24
+ .. math::
25
+
26
+ s_R = a + b \, |R|,
27
+
28
+ with an absolute part :math:`a` in ohms (voltage noise) and a relative part
29
+ :math:`b`. Since apparent resistivity is :math:`\rho_a = K R`, the data
30
+ :math:`d = \ln \rho_a` have log-space standard deviation
31
+ :math:`s = b + a / |R|`.
32
+
33
+ Parameters
34
+ ----------
35
+ relative : float, optional
36
+ Relative part :math:`b`, as a fraction: 0.03 means 3%. The default,
37
+ 0.03, is a common choice; 2 to 5% is typical, and up to 10% where the
38
+ earth is far from layered. It should include a floor for modeling
39
+ errors: reciprocal or repeated readings (Tso et al. 2017) measure the
40
+ noise only, so they do not justify a value below about 2%.
41
+ absolute : float, optional
42
+ Absolute part :math:`a`, in ohms: about the voltage noise divided by
43
+ the current. Default 0.
44
+
45
+ Raises
46
+ ------
47
+ ValueError
48
+ If a part is negative or not finite, or if both are zero.
49
+
50
+ Warns
51
+ -----
52
+ DataWarning
53
+ If ``relative`` is above 0.5, which suggests a percentage given where
54
+ a fraction is expected.
55
+
56
+ Examples
57
+ --------
58
+ >>> from ertkit import ErrorModel
59
+ >>> noise = ErrorModel(relative=0.03, absolute=1e-4)
60
+ >>> round(float(noise.log_std(0.01)[0]), 6)
61
+ 0.04
62
+ """
63
+
64
+ relative: float = 0.03
65
+ absolute: float = 0.0
66
+
67
+ def __post_init__(self) -> None:
68
+ for name in ("relative", "absolute"):
69
+ value = getattr(self, name)
70
+ if not (math.isfinite(value) and value >= 0):
71
+ raise ValueError(f"{name} error must be finite and non-negative")
72
+ if self.relative == 0 and self.absolute == 0:
73
+ raise ValueError("at least one of relative and absolute must be positive")
74
+ if self.relative > 0.5:
75
+ warnings.warn(
76
+ f"relative error {self.relative:g} is {100 * self.relative:g}%; "
77
+ "it is a fraction: 0.03 means 3%",
78
+ DataWarning,
79
+ stacklevel=3,
80
+ )
81
+ object.__setattr__(self, "relative", float(self.relative))
82
+ object.__setattr__(self, "absolute", float(self.absolute))
83
+
84
+ def resistance_std(self, resistance: ArrayLike) -> NDArray[np.float64]:
85
+ r"""Standard deviation :math:`s_R = a + b|R|` of transfer resistances.
86
+
87
+ Parameters
88
+ ----------
89
+ resistance : array_like
90
+ Transfer resistances :math:`R` in ohms.
91
+
92
+ Returns
93
+ -------
94
+ ndarray of float
95
+ Standard deviations in ohms.
96
+ """
97
+ r = np.atleast_1d(np.asarray(resistance, dtype=np.float64))
98
+ return self.absolute + self.relative * np.abs(r)
99
+
100
+ def log_std(self, resistance: ArrayLike) -> NDArray[np.float64]:
101
+ r"""Standard deviation :math:`s = b + a/|R|` of :math:`\ln \rho_a`.
102
+
103
+ Parameters
104
+ ----------
105
+ resistance : array_like
106
+ Transfer resistances :math:`R` in ohms.
107
+
108
+ Returns
109
+ -------
110
+ ndarray of float
111
+ Standard deviations of the natural logarithm of the data.
112
+ """
113
+ r = np.atleast_1d(np.asarray(resistance, dtype=np.float64))
114
+ return np.asarray(self.relative + self.absolute / np.abs(r), dtype=np.float64)
115
+
116
+ def usable(self, resistance: ArrayLike) -> NDArray[np.bool_]:
117
+ """Readings that can be inverted in log space.
118
+
119
+ A reading is usable when :math:`R > 0` and, if there is an absolute
120
+ part, :math:`|R| \\ge 3a`; below that the logarithm is dominated by
121
+ noise.
122
+
123
+ Parameters
124
+ ----------
125
+ resistance : array_like
126
+ Transfer resistances :math:`R` in ohms.
127
+
128
+ Returns
129
+ -------
130
+ ndarray of bool
131
+ True for each usable reading.
132
+ """
133
+ r = np.atleast_1d(np.asarray(resistance, dtype=np.float64))
134
+ return np.asarray(np.isfinite(r) & (r > 0) & (np.abs(r) >= 3.0 * self.absolute))
@@ -0,0 +1,19 @@
1
+ """Plots of soundings and layered models.
2
+
3
+ Needs matplotlib, which the ``plot`` extra installs::
4
+
5
+ pip install "ertkit[plot]"
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ try:
11
+ import matplotlib # noqa: F401
12
+ except ImportError as exc: # pragma: no cover - exercised in a subprocess test
13
+ raise ImportError(
14
+ 'ertkit.plot needs matplotlib; install it with: pip install "ertkit[plot]"'
15
+ ) from exc
16
+
17
+ from ertkit.plot._ves import layered_model, sounding
18
+
19
+ __all__ = ["layered_model", "sounding"]
ertkit/plot/_ves.py ADDED
@@ -0,0 +1,234 @@
1
+ """Plots for vertical electrical soundings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+ from ertkit.results import InversionResult
12
+ from ertkit.ves.data import Sounding
13
+ from ertkit.ves.invert import LayerFit, _segments
14
+ from ertkit.ves.model import LayeredModel
15
+ from ertkit.ves.smooth import DOI_THRESHOLD, SmoothFit
16
+
17
+ if TYPE_CHECKING:
18
+ from matplotlib.axes import Axes
19
+
20
+ __all__ = ["layered_model", "sounding"]
21
+
22
+ _MAX_FACTOR = 100.0
23
+ """Resistivity error bars are drawn to at most this factor either side."""
24
+
25
+
26
+ def _axes(ax: Axes | None) -> Axes:
27
+ if ax is not None:
28
+ return ax
29
+ import matplotlib.pyplot as plt
30
+
31
+ _, new = plt.subplots(figsize=(6.0, 4.5), layout="constrained")
32
+ return new
33
+
34
+
35
+ def sounding(
36
+ data: Sounding,
37
+ *fits: InversionResult,
38
+ labels: Sequence[str] | None = None,
39
+ ax: Axes | None = None,
40
+ ) -> Axes:
41
+ """Plot apparent resistivity against AB/2, with the data errors and fitted curves.
42
+
43
+ Fitted curves are drawn separately for each MN segment (readings with the
44
+ same MN/2), so that static shifts between segments show as steps. When
45
+ MN changes with every reading, as in a Wenner sounding, one curve is drawn.
46
+
47
+ Parameters
48
+ ----------
49
+ data : Sounding
50
+ The observed data; error bars show one standard deviation.
51
+ *fits : InversionResult
52
+ Inversion results whose predicted data are drawn as lines.
53
+ labels : sequence of str, optional
54
+ One label per fit. Default "fit 1", "fit 2", and so on.
55
+ ax : matplotlib.axes.Axes, optional
56
+ Axes to draw on; a new figure is made if omitted.
57
+
58
+ Returns
59
+ -------
60
+ matplotlib.axes.Axes
61
+ """
62
+ ax = _axes(ax)
63
+ ab2, rhoa, s = data.survey.ab2, data.rhoa, data.log_std
64
+ yerr = np.vstack([rhoa * (1.0 - np.exp(-s)), rhoa * (np.exp(s) - 1.0)])
65
+ ax.errorbar(ab2, rhoa, yerr=yerr, fmt="o", ms=4, color="black", label="data")
66
+ labels = (
67
+ [f"fit {i + 1}" for i in range(len(fits))] if labels is None else list(labels)
68
+ )
69
+ segment = _segments(data.survey.mn2)
70
+ if np.bincount(segment).max() < 2:
71
+ segment = np.zeros_like(segment)
72
+ for fit, label in zip(fits, labels, strict=True):
73
+ color = None
74
+ for k in np.unique(segment):
75
+ # Readings of one MN segment, in order of AB/2.
76
+ sel = np.flatnonzero(segment == k)
77
+ sel = sel[np.argsort(ab2[sel], kind="stable")]
78
+ (line,) = ax.plot(
79
+ ab2[sel],
80
+ fit.predicted[sel],
81
+ color=color,
82
+ label=label if k == segment[0] else None,
83
+ )
84
+ color = line.get_color()
85
+ ax.set_xscale("log")
86
+ ax.set_yscale("log")
87
+ ax.set_xlabel("AB/2 (m)")
88
+ ax.set_ylabel(r"apparent resistivity ($\Omega$ m)")
89
+ ax.grid(True, which="both", alpha=0.3)
90
+ ax.legend()
91
+ return ax
92
+
93
+
94
+ def _steps(
95
+ model: LayeredModel, depth_max: float, depth_min: float = 0.0
96
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
97
+ """Resistivity and depth arrays for a step plot from depth_min to depth_max."""
98
+ tops = np.concatenate([[depth_min], model.depths])
99
+ bottoms = np.concatenate([model.depths, [max(depth_max, tops[-1])]])
100
+ depth = np.ravel(np.column_stack([tops, bottoms]))
101
+ rho = np.repeat(model.resistivity, 2)
102
+ return rho, depth
103
+
104
+
105
+ def _default_depth_max(
106
+ items: Sequence[LayeredModel | LayerFit | SmoothFit], models: list[LayeredModel]
107
+ ) -> float:
108
+ """1.5 times the deepest depth of investigation, else the deepest interface."""
109
+ dois = [
110
+ item.doi
111
+ for item in items
112
+ if isinstance(item, SmoothFit) and np.isfinite(item.doi) and item.doi > 0
113
+ ]
114
+ interfaces = [
115
+ float(m.depths[-1])
116
+ for item, m in zip(items, models, strict=True)
117
+ if m.depths.size and not isinstance(item, SmoothFit)
118
+ ]
119
+ return 1.5 * max(dois or interfaces or [10.0])
120
+
121
+
122
+ def layered_model(
123
+ *items: LayeredModel | LayerFit | SmoothFit,
124
+ labels: Sequence[str] | None = None,
125
+ depth_max: float | None = None,
126
+ log_depth: bool = False,
127
+ ax: Axes | None = None,
128
+ ) -> Axes:
129
+ """Plot layered models as resistivity against depth.
130
+
131
+ Few-layer fits show one standard deviation of each layer's resistivity
132
+ and of each interface depth; error bars are cut at the edges of the plot,
133
+ and resistivity error bars at a factor of 100 (``LayerFit.factor`` gives
134
+ the values). Smooth fits shade the region below their depth of
135
+ investigation, where the model reflects the regularization rather than the
136
+ data.
137
+
138
+ Parameters
139
+ ----------
140
+ *items : LayeredModel, LayerFit or SmoothFit
141
+ Models, or inversion results whose models are drawn.
142
+ labels : sequence of str, optional
143
+ One label per item. Default "model 1", "model 2", and so on.
144
+ depth_max : float, optional
145
+ Deepest depth shown, in meters. Default: 1.5 times the depth of
146
+ investigation of the smooth fits, or else 1.5 times the deepest
147
+ interface.
148
+ log_depth : bool, optional
149
+ Use a logarithmic depth axis, which suits smooth models whose layers
150
+ thicken with depth. Default False.
151
+ ax : matplotlib.axes.Axes, optional
152
+ Axes to draw on; a new figure is made if omitted.
153
+
154
+ Returns
155
+ -------
156
+ matplotlib.axes.Axes
157
+ """
158
+ ax = _axes(ax)
159
+ labels = (
160
+ [f"model {i + 1}" for i in range(len(items))]
161
+ if labels is None
162
+ else list(labels)
163
+ )
164
+ models = [item if isinstance(item, LayeredModel) else item.model for item in items]
165
+ if depth_max is None:
166
+ depth_max = _default_depth_max(items, models)
167
+ shallowest = [float(m.depths[0]) for m in models if m.depths.size]
168
+ depth_min = 0.5 * min(shallowest, default=1.0) if log_depth else 0.0
169
+ for item, model, label in zip(items, models, labels, strict=True):
170
+ rho, depth = _steps(model, depth_max, depth_min)
171
+ (line,) = ax.plot(rho, depth, label=label)
172
+ if isinstance(item, LayerFit):
173
+ n = model.n_layers
174
+ tops = np.concatenate([[depth_min], model.depths])
175
+ bottoms = np.concatenate([model.depths, [depth_max]])
176
+ mid = np.sqrt(tops * bottoms) if log_depth else 0.5 * (tops + bottoms)
177
+ factor = np.minimum(item.factor[:n], _MAX_FACTOR)
178
+ ax.errorbar(
179
+ model.resistivity,
180
+ mid,
181
+ xerr=[
182
+ model.resistivity * (1 - 1 / factor),
183
+ model.resistivity * (factor - 1),
184
+ ],
185
+ fmt="none",
186
+ color=line.get_color(),
187
+ capsize=3,
188
+ )
189
+ if n > 1:
190
+ ax.errorbar(
191
+ np.sqrt(model.resistivity[:-1] * model.resistivity[1:]),
192
+ model.depths,
193
+ # Error bars stop at the edges of the plot.
194
+ yerr=[
195
+ np.minimum(item.depth_std, model.depths - depth_min).clip(0),
196
+ np.minimum(item.depth_std, depth_max - model.depths).clip(0),
197
+ ],
198
+ fmt="none",
199
+ color=line.get_color(),
200
+ capsize=3,
201
+ )
202
+ if (
203
+ isinstance(item, SmoothFit)
204
+ and np.isfinite(item.doi)
205
+ and item.doi < depth_max
206
+ ):
207
+ ax.axhspan(
208
+ max(item.doi, depth_min),
209
+ depth_max,
210
+ color=line.get_color(),
211
+ alpha=0.12,
212
+ label=f"below depth of investigation ({label})",
213
+ )
214
+ # Isolated layers above the depth of investigation whose index
215
+ # also reaches the threshold are unresolved too.
216
+ tops = np.concatenate([[0.0], model.depths])
217
+ bottoms = np.concatenate([model.depths, [depth_max]])
218
+ for j in np.flatnonzero(item.doi_index >= DOI_THRESHOLD):
219
+ if bottoms[j] <= item.doi:
220
+ ax.axhspan(
221
+ max(tops[j], depth_min),
222
+ bottoms[j],
223
+ color=line.get_color(),
224
+ alpha=0.12,
225
+ )
226
+ ax.set_xscale("log")
227
+ if log_depth:
228
+ ax.set_yscale("log")
229
+ ax.set_ylim(depth_max, depth_min)
230
+ ax.set_xlabel(r"resistivity ($\Omega$ m)")
231
+ ax.set_ylabel("depth (m)")
232
+ ax.grid(True, which="both", alpha=0.3)
233
+ ax.legend()
234
+ return ax
ertkit/py.typed ADDED
File without changes
ertkit/results.py ADDED
@@ -0,0 +1,82 @@
1
+ """Result objects shared by the inversion methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, fields
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+
10
+ __all__ = ["InversionResult"]
11
+
12
+
13
+ @dataclass(frozen=True, eq=False, kw_only=True, slots=True)
14
+ class InversionResult:
15
+ r"""Common part of every inversion result.
16
+
17
+ Results are immutable: their arrays are read-only.
18
+
19
+ Attributes
20
+ ----------
21
+ predicted : ndarray
22
+ Data predicted by the final model (apparent resistivity, ohm meters).
23
+ residuals : ndarray
24
+ Normalized residuals :math:`(d_i - f_i)/s_i` of the log data, one per
25
+ reading; their squares sum to ``chi2``. For a fit to the noise level
26
+ they scatter around 0 with a spread of about 1, without trends.
27
+ chi2 : float
28
+ Final misfit :math:`\chi^2` of the log data.
29
+ chi2_target : float
30
+ The misfit the method aimed at: :math:`N_d - p`, the value expected
31
+ for a fit with :math:`p` free parameters that matches the data to
32
+ their errors; :math:`N_d` for a smooth inversion, or 1.1 times the
33
+ smallest misfit when :math:`N_d` cannot be reached.
34
+ n_data : int
35
+ Number of data :math:`N_d`.
36
+ converged : bool
37
+ Whether the method met its convergence criterion.
38
+ stop_reason : str
39
+ Why the iteration stopped.
40
+ n_iterations : int
41
+ Number of iterations (or function evaluations, for methods that count
42
+ those).
43
+ """
44
+
45
+ predicted: NDArray[np.float64]
46
+ residuals: NDArray[np.float64]
47
+ chi2: float
48
+ chi2_target: float
49
+ n_data: int
50
+ converged: bool
51
+ stop_reason: str
52
+ n_iterations: int
53
+
54
+ def __post_init__(self) -> None:
55
+ for f in fields(self):
56
+ value = getattr(self, f.name)
57
+ if isinstance(value, np.ndarray):
58
+ value.setflags(write=False)
59
+
60
+ @property
61
+ def chi2_ratio(self) -> float:
62
+ r""":math:`\chi^2` divided by its target; about 1 for a fit to the noise level."""
63
+ return float(self.chi2 / self.chi2_target)
64
+
65
+ def summary(self) -> str:
66
+ """Readable summary of the fit and the model.
67
+
68
+ Uncertainty factors are written ``x/ f``: the value multiplied or
69
+ divided by ``f``.
70
+ """
71
+ lines = [
72
+ (
73
+ f"{type(self).__name__}: chi2 = {self.chi2:.4g} for {self.n_data} data "
74
+ f"(target {self.chi2_target:.4g}, ratio {self.chi2_ratio:.3g})"
75
+ ),
76
+ f"converged: {self.converged} ({self.stop_reason})",
77
+ ]
78
+ return "\n".join(lines + self._details())
79
+
80
+ def _details(self) -> list[str]:
81
+ """Method-specific lines of the summary."""
82
+ return []