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/__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
|
ertkit/_show_versions.py
ADDED
|
@@ -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))
|
ertkit/plot/__init__.py
ADDED
|
@@ -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 []
|