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/io.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Reading and writing VES data as CSV files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ertkit.noise import ErrorModel
|
|
12
|
+
from ertkit.ves.data import Sounding
|
|
13
|
+
from ertkit.ves.survey import Survey
|
|
14
|
+
|
|
15
|
+
__all__ = ["read_csv", "write_csv"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_csv(
|
|
19
|
+
path: str | os.PathLike[str],
|
|
20
|
+
error: ErrorModel | None = None,
|
|
21
|
+
*,
|
|
22
|
+
encoding: str = "utf-8-sig",
|
|
23
|
+
) -> Sounding:
|
|
24
|
+
"""Read a sounding from a CSV file.
|
|
25
|
+
|
|
26
|
+
The file has a header row and one row per reading. Column names are not
|
|
27
|
+
case sensitive. Required columns are ``ab2`` and ``mn2`` (half-spacings in
|
|
28
|
+
meters) and either ``rhoa`` (apparent resistivity in ohm meters) or
|
|
29
|
+
``resistance`` (transfer resistance in ohms). Lines starting with ``#``
|
|
30
|
+
are comments, empty rows (such as the ``,,`` rows spreadsheets leave) are
|
|
31
|
+
skipped, and other columns are ignored. Columns may be separated by
|
|
32
|
+
commas, semicolons or tabs; numbers must use a decimal point.
|
|
33
|
+
|
|
34
|
+
For a Wenner sounding with spacing :math:`a`, write ``ab2`` as
|
|
35
|
+
:math:`1.5a` and ``mn2`` as :math:`0.5a`.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
path : str or path-like
|
|
40
|
+
The CSV file.
|
|
41
|
+
error : ErrorModel, optional
|
|
42
|
+
Error model for the data. Default ``ErrorModel()``.
|
|
43
|
+
encoding : str, optional
|
|
44
|
+
Text encoding of the file. The default, ``"utf-8-sig"``, reads UTF-8
|
|
45
|
+
files with or without the byte-order mark that spreadsheet programs
|
|
46
|
+
write; files exported by some instruments need, for example,
|
|
47
|
+
``"latin-1"``.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
Sounding
|
|
52
|
+
|
|
53
|
+
Raises
|
|
54
|
+
------
|
|
55
|
+
ValueError
|
|
56
|
+
If the file cannot be decoded (try another ``encoding``), if the header
|
|
57
|
+
is missing or has duplicate or missing columns, if there are no
|
|
58
|
+
readings, if a value is not a number or a spacing is invalid
|
|
59
|
+
(``MN/2`` must be positive and smaller than ``AB/2``; the message gives
|
|
60
|
+
the line), or if no reading is usable.
|
|
61
|
+
|
|
62
|
+
Warns
|
|
63
|
+
-----
|
|
64
|
+
DataWarning
|
|
65
|
+
If readings are removed (see :class:`Sounding`).
|
|
66
|
+
|
|
67
|
+
Examples
|
|
68
|
+
--------
|
|
69
|
+
A file with these lines::
|
|
70
|
+
|
|
71
|
+
# Site 12, Minna
|
|
72
|
+
ab2,mn2,rhoa
|
|
73
|
+
1.5,0.3,120.3
|
|
74
|
+
2.5,0.3,98.7
|
|
75
|
+
|
|
76
|
+
is read with ``read_csv("site12.csv")``.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
with Path(path).open(encoding=encoding, newline="") as handle:
|
|
80
|
+
numbered = [
|
|
81
|
+
(number, line.lstrip("\ufeff"))
|
|
82
|
+
for number, line in enumerate(handle, start=1)
|
|
83
|
+
if line.strip() and not line.lstrip("\ufeff").lstrip().startswith("#")
|
|
84
|
+
]
|
|
85
|
+
except UnicodeDecodeError as exc:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"{path}: not readable as {encoding} ({exc.reason}); pass the file's "
|
|
88
|
+
"encoding, for example read_csv(path, encoding='latin-1')"
|
|
89
|
+
) from exc
|
|
90
|
+
if not numbered:
|
|
91
|
+
raise ValueError(f"{path}: no header row")
|
|
92
|
+
try:
|
|
93
|
+
delimiter = csv.Sniffer().sniff(numbered[0][1], delimiters=",;\t").delimiter
|
|
94
|
+
except csv.Error:
|
|
95
|
+
delimiter = ","
|
|
96
|
+
parsed = csv.reader([line for _, line in numbered], delimiter=delimiter)
|
|
97
|
+
lines, rows = [], []
|
|
98
|
+
for (number, _), row in zip(numbered, parsed, strict=True):
|
|
99
|
+
if any(cell.strip() for cell in row):
|
|
100
|
+
lines.append(number)
|
|
101
|
+
rows.append(row)
|
|
102
|
+
if not rows:
|
|
103
|
+
raise ValueError(f"{path}: no header row")
|
|
104
|
+
header = [name.strip().lower() for name in rows[0]]
|
|
105
|
+
duplicated = sorted({name for name in header if name and header.count(name) > 1})
|
|
106
|
+
if duplicated:
|
|
107
|
+
raise ValueError(f"{path}: duplicate columns {duplicated}")
|
|
108
|
+
names = {name: k for k, name in enumerate(header)}
|
|
109
|
+
for required in ("ab2", "mn2"):
|
|
110
|
+
if required not in names:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"{path}: missing column {required!r} (columns found: {header})"
|
|
113
|
+
)
|
|
114
|
+
if "rhoa" in names:
|
|
115
|
+
value_col, is_resistance = names["rhoa"], False
|
|
116
|
+
elif "resistance" in names:
|
|
117
|
+
value_col, is_resistance = names["resistance"], True
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(f"{path}: need a 'rhoa' or a 'resistance' column")
|
|
120
|
+
columns = (names["ab2"], names["mn2"], value_col)
|
|
121
|
+
if len(rows) < 2:
|
|
122
|
+
raise ValueError(f"{path}: no readings below the header")
|
|
123
|
+
ab2, mn2, values = [], [], []
|
|
124
|
+
for number, row in zip(lines[1:], rows[1:], strict=True):
|
|
125
|
+
if len(row) <= max(columns):
|
|
126
|
+
raise ValueError(f"{path}, line {number}: too few columns")
|
|
127
|
+
try:
|
|
128
|
+
a, m, v = (float(row[c]) for c in columns)
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
hint = (
|
|
131
|
+
" (numbers must use a decimal point, not a comma)"
|
|
132
|
+
if any("," in cell for cell in row)
|
|
133
|
+
else ""
|
|
134
|
+
)
|
|
135
|
+
raise ValueError(f"{path}, line {number}: {exc}{hint}") from exc
|
|
136
|
+
if not all(np.isfinite([a, m, v])):
|
|
137
|
+
raise ValueError(f"{path}, line {number}: values must be finite")
|
|
138
|
+
if not 0 < m < a:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"{path}, line {number}: need 0 < MN/2 < AB/2, got AB/2 = {a:g} "
|
|
141
|
+
f"and MN/2 = {m:g}"
|
|
142
|
+
)
|
|
143
|
+
ab2.append(a)
|
|
144
|
+
mn2.append(m)
|
|
145
|
+
values.append(v)
|
|
146
|
+
survey = Survey(ab2=np.array(ab2), mn2=np.array(mn2))
|
|
147
|
+
rhoa = (
|
|
148
|
+
np.array(values) * survey.geometric_factor
|
|
149
|
+
if is_resistance
|
|
150
|
+
else np.array(values)
|
|
151
|
+
)
|
|
152
|
+
return Sounding(
|
|
153
|
+
survey=survey, rhoa=rhoa, error=ErrorModel() if error is None else error
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_csv(
|
|
158
|
+
sounding: Sounding, path: str | os.PathLike[str], *, encoding: str = "utf-8"
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Write a sounding to a CSV file readable by :func:`read_csv`.
|
|
161
|
+
|
|
162
|
+
The error model is not written; pass it to :func:`read_csv` again.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
sounding : Sounding
|
|
167
|
+
The data to write.
|
|
168
|
+
path : str or path-like
|
|
169
|
+
Destination file; overwritten if it exists.
|
|
170
|
+
encoding : str, optional
|
|
171
|
+
Text encoding. Default ``"utf-8"``.
|
|
172
|
+
"""
|
|
173
|
+
with Path(path).open("w", encoding=encoding, newline="") as handle:
|
|
174
|
+
writer = csv.writer(handle)
|
|
175
|
+
writer.writerow(["ab2", "mn2", "rhoa"])
|
|
176
|
+
for ab2, mn2, rhoa in zip(
|
|
177
|
+
sounding.survey.ab2, sounding.survey.mn2, sounding.rhoa, strict=True
|
|
178
|
+
):
|
|
179
|
+
writer.writerow([repr(float(ab2)), repr(float(mn2)), repr(float(rhoa))])
|
ertkit/ves/model.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Layered-earth models for vertical electrical sounding."""
|
|
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
|
+
__all__ = ["LayeredModel"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _readonly(values: ArrayLike, name: str) -> NDArray[np.float64]:
|
|
14
|
+
arr = np.array(values, dtype=np.float64, copy=True, ndmin=1)
|
|
15
|
+
if arr.ndim != 1:
|
|
16
|
+
raise ValueError(f"{name} must be one-dimensional, got shape {arr.shape}")
|
|
17
|
+
if not np.all(np.isfinite(arr)) or np.any(arr <= 0):
|
|
18
|
+
raise ValueError(f"{name} must be positive and finite")
|
|
19
|
+
arr.setflags(write=False)
|
|
20
|
+
return arr
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, eq=False, kw_only=True, slots=True)
|
|
24
|
+
class LayeredModel:
|
|
25
|
+
r"""A horizontally layered earth.
|
|
26
|
+
|
|
27
|
+
Layer :math:`i` has resistivity :math:`\rho_i`; layers
|
|
28
|
+
:math:`1, \dots, N-1` have thickness :math:`h_i`, and layer :math:`N`
|
|
29
|
+
is a half-space.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
resistivity : array_like
|
|
34
|
+
Layer resistivities in ohm meters, from the top; length :math:`N`.
|
|
35
|
+
thickness : array_like
|
|
36
|
+
Thicknesses in meters of the upper :math:`N - 1` layers. Empty for a
|
|
37
|
+
homogeneous half-space.
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
ValueError
|
|
42
|
+
If a value is not positive and finite, or if there is not exactly one
|
|
43
|
+
thickness fewer than resistivities.
|
|
44
|
+
|
|
45
|
+
Examples
|
|
46
|
+
--------
|
|
47
|
+
>>> from ertkit.ves import LayeredModel
|
|
48
|
+
>>> model = LayeredModel(resistivity=[150.0, 30.0, 800.0], thickness=[2.0, 15.0])
|
|
49
|
+
>>> model.n_layers
|
|
50
|
+
3
|
|
51
|
+
>>> model.depths.tolist()
|
|
52
|
+
[2.0, 17.0]
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
resistivity: NDArray[np.float64]
|
|
56
|
+
thickness: NDArray[np.float64]
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
rho = _readonly(self.resistivity, "resistivity")
|
|
60
|
+
h = _readonly(self.thickness, "thickness")
|
|
61
|
+
if h.size != rho.size - 1:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"need {rho.size - 1} thicknesses for {rho.size} layers, got {h.size}"
|
|
64
|
+
)
|
|
65
|
+
object.__setattr__(self, "resistivity", rho)
|
|
66
|
+
object.__setattr__(self, "thickness", h)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def n_layers(self) -> int:
|
|
70
|
+
"""Number of layers, including the bottom half-space."""
|
|
71
|
+
return int(self.resistivity.size)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def depths(self) -> NDArray[np.float64]:
|
|
75
|
+
"""Depths in meters of the bottoms of the upper :math:`N - 1` layers."""
|
|
76
|
+
return np.cumsum(self.thickness)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def conductance(self) -> NDArray[np.float64]:
|
|
80
|
+
r"""Longitudinal conductance :math:`S_i = h_i/\rho_i` of the upper layers, in siemens."""
|
|
81
|
+
return self.thickness / self.resistivity[:-1]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def transverse_resistance(self) -> NDArray[np.float64]:
|
|
85
|
+
r"""Transverse resistance :math:`T_i = h_i \rho_i` of the upper layers, in ohm square meters."""
|
|
86
|
+
return self.thickness * self.resistivity[:-1]
|