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/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]