seisvtk 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.
- seisvtk/__init__.py +0 -0
- seisvtk/io/__init__.py +1 -0
- seisvtk/io/browse.py +128 -0
- seisvtk/io/convert_segy.py +67 -0
- seisvtk/io/deviation.py +131 -0
- seisvtk/io/horizon.py +157 -0
- seisvtk/io/ingest.py +188 -0
- seisvtk/io/loaders.py +141 -0
- seisvtk/io/logging_config.py +9 -0
- seisvtk/io/segy2zarr.py +170 -0
- seisvtk/io/segy_scan.py +121 -0
- seisvtk/io/survey.py +193 -0
- seisvtk/io/tables.py +131 -0
- seisvtk/io/volume.py +144 -0
- seisvtk/io/well.py +275 -0
- seisvtk/viz/actors.py +30 -0
- seisvtk/viz/app.py +790 -0
- seisvtk/viz/bridge.py +26 -0
- seisvtk/viz/colormaps.py +83 -0
- seisvtk/viz/horizon_actor.py +81 -0
- seisvtk/viz/interactor.py +62 -0
- seisvtk/viz/render.py +64 -0
- seisvtk/viz/scene.py +18 -0
- seisvtk/viz/service.py +128 -0
- seisvtk/viz/slice_scene.py +390 -0
- seisvtk/viz/well_actor.py +70 -0
- seisvtk-0.1.0.dist-info/METADATA +90 -0
- seisvtk-0.1.0.dist-info/RECORD +32 -0
- seisvtk-0.1.0.dist-info/WHEEL +5 -0
- seisvtk-0.1.0.dist-info/entry_points.txt +2 -0
- seisvtk-0.1.0.dist-info/licenses/LICENSE +21 -0
- seisvtk-0.1.0.dist-info/top_level.txt +1 -0
seisvtk/__init__.py
ADDED
|
File without changes
|
seisvtk/io/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
seisvtk/io/browse.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Server-side directory listing for the viewer's file-picker popup.
|
|
2
|
+
|
|
3
|
+
The trame viewer renders where the data lives, so files are chosen by browsing
|
|
4
|
+
the *server's* filesystem (its own disk when run locally), not by uploading from
|
|
5
|
+
the browser — which only yields bytes, never a path, and cannot handle multi-GB
|
|
6
|
+
cubes. :func:`browse` turns a directory into a sorted, typed entry list the popup
|
|
7
|
+
renders; it is pure and unit-tested, with the trame dialog kept in the app layer.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import string
|
|
13
|
+
|
|
14
|
+
# A Zarr "file" is really a directory; treat ``*.zarr`` as a selectable store
|
|
15
|
+
# rather than a folder to descend into.
|
|
16
|
+
STORE_SUFFIXES = (".zarr",)
|
|
17
|
+
|
|
18
|
+
_ICON = {"up": "mdi-arrow-up-left", "drive": "mdi-harddisk",
|
|
19
|
+
"dir": "mdi-folder", "store": "mdi-database", "file": "mdi-file-outline"}
|
|
20
|
+
# Vuetify theme colours, so the list reads at a glance (folders warm, data cool).
|
|
21
|
+
_COLOR = {"up": "grey", "drive": "blue-grey", "dir": "amber-darken-1",
|
|
22
|
+
"store": "blue", "file": "blue-grey-lighten-1"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _entry(name, path, kind):
|
|
26
|
+
return {"name": name, "path": path, "type": kind,
|
|
27
|
+
"icon": _ICON[kind], "color": _COLOR[kind]}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _crumbs(path: str) -> list[dict]:
|
|
31
|
+
"""Breadcrumb trail for *path*: ``[{label, path}]`` from "This PC" down."""
|
|
32
|
+
crumbs = [{"label": "This PC", "path": ""}]
|
|
33
|
+
if not path:
|
|
34
|
+
return crumbs
|
|
35
|
+
path = os.path.abspath(path)
|
|
36
|
+
drive, tail = os.path.splitdrive(path)
|
|
37
|
+
if drive:
|
|
38
|
+
acc = drive + os.sep
|
|
39
|
+
crumbs.append({"label": drive, "path": acc})
|
|
40
|
+
else:
|
|
41
|
+
acc = os.sep
|
|
42
|
+
crumbs.append({"label": "/", "path": acc})
|
|
43
|
+
for part in [p for p in tail.split(os.sep) if p]:
|
|
44
|
+
acc = os.path.join(acc, part)
|
|
45
|
+
crumbs.append({"label": part, "path": acc})
|
|
46
|
+
return crumbs
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_drives() -> list[str]:
|
|
50
|
+
"""Mounted Windows drive roots (``['C:\\\\', 'D:\\\\', ...]``); ``[]`` off Windows.
|
|
51
|
+
|
|
52
|
+
Uses the ``GetLogicalDrives`` bitmask, which reports assigned drive letters
|
|
53
|
+
instantly without spinning up removable media — probing ``A:\\`` or an empty
|
|
54
|
+
optical drive with ``os.path.exists`` can block for tens of seconds.
|
|
55
|
+
"""
|
|
56
|
+
if os.name != "nt":
|
|
57
|
+
return []
|
|
58
|
+
import ctypes
|
|
59
|
+
|
|
60
|
+
bitmask = ctypes.windll.kernel32.GetLogicalDrives()
|
|
61
|
+
return [f"{string.ascii_uppercase[i]}:\\" for i in range(26) if bitmask & (1 << i)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_drive_root(path: str) -> bool:
|
|
65
|
+
drive, tail = os.path.splitdrive(os.path.abspath(path))
|
|
66
|
+
return bool(drive) and tail in ("", os.sep, "/")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parent(path: str) -> str | None:
|
|
70
|
+
"""The directory above *path*; ``""`` at a drive root (→ drive list), None at top."""
|
|
71
|
+
if _is_drive_root(path):
|
|
72
|
+
return "" # one level up from C:\ is "pick a drive"
|
|
73
|
+
up = os.path.dirname(os.path.abspath(path).rstrip("\\/"))
|
|
74
|
+
return up or None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _drives_view() -> dict:
|
|
78
|
+
"""The top level: a row per drive (Windows) or root (POSIX)."""
|
|
79
|
+
roots = list_drives() or ["/"]
|
|
80
|
+
entries = [_entry(r, r, "drive") for r in roots]
|
|
81
|
+
return {"cwd": "", "parent": None, "entries": entries, "crumbs": _crumbs("")}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def browse(path: str | None, *, exts=None) -> dict:
|
|
85
|
+
"""List *path* for the picker: ``{cwd, parent, entries}``.
|
|
86
|
+
|
|
87
|
+
Each entry is ``{name, path, type, icon}`` with ``type`` one of ``up`` (the
|
|
88
|
+
parent row), ``dir`` (navigable folder), ``store`` (a selectable ``*.zarr``),
|
|
89
|
+
or ``file`` (selectable). Files are kept only if their extension is in ``exts``
|
|
90
|
+
(lowercase, dotted, e.g. ``{'.segy', '.sgy'}``); ``None`` keeps all. Folders
|
|
91
|
+
are always listed so the user can navigate. Hidden/system entries and
|
|
92
|
+
unreadable children are skipped rather than raising. An empty/None *path*
|
|
93
|
+
yields the drive list.
|
|
94
|
+
"""
|
|
95
|
+
if not path:
|
|
96
|
+
return _drives_view()
|
|
97
|
+
path = os.path.abspath(path)
|
|
98
|
+
entries: list[dict] = []
|
|
99
|
+
parent = _parent(path)
|
|
100
|
+
if parent is not None:
|
|
101
|
+
entries.append(_entry("..", parent, "up"))
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
children = list(os.scandir(path))
|
|
105
|
+
except OSError: # permission denied, gone, etc.
|
|
106
|
+
children = []
|
|
107
|
+
|
|
108
|
+
dirs, stores, files = [], [], []
|
|
109
|
+
exts = {e.lower() for e in exts} if exts else None
|
|
110
|
+
for e in children:
|
|
111
|
+
name = e.name
|
|
112
|
+
if name.startswith(".") or name.startswith("$"):
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
is_dir = e.is_dir()
|
|
116
|
+
except OSError:
|
|
117
|
+
continue
|
|
118
|
+
if is_dir:
|
|
119
|
+
if name.lower().endswith(STORE_SUFFIXES):
|
|
120
|
+
stores.append(_entry(name, e.path, "store"))
|
|
121
|
+
else:
|
|
122
|
+
dirs.append(_entry(name, e.path, "dir"))
|
|
123
|
+
elif exts is None or os.path.splitext(name)[1].lower() in exts:
|
|
124
|
+
files.append(_entry(name, e.path, "file"))
|
|
125
|
+
|
|
126
|
+
key = lambda d: d["name"].lower()
|
|
127
|
+
entries += sorted(dirs, key=key) + sorted(stores, key=key) + sorted(files, key=key)
|
|
128
|
+
return {"cwd": path, "parent": parent, "entries": entries, "crumbs": _crumbs(path)}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from seisvtk.io.segy_scan import detect_geometry, open_auto
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
#Logging
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
import segyio
|
|
10
|
+
import zarr
|
|
11
|
+
from seisvtk.io.logging_config import setup_logging
|
|
12
|
+
from seisvtk.io.segy2zarr import convert_segy_to_zarr
|
|
13
|
+
|
|
14
|
+
#Find relative path for output files
|
|
15
|
+
REPO_PATH = Path(__file__).resolve().parents[2]
|
|
16
|
+
output_folder=REPO_PATH / "Testing" / "Output"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
#Logger setup
|
|
24
|
+
logger = logging.getLogger(__spec__.name if __spec__ else __name__)
|
|
25
|
+
setup_logging()
|
|
26
|
+
|
|
27
|
+
#Argument Parser (Setup sys.argv to receive script arguments)
|
|
28
|
+
parser = argparse.ArgumentParser(description="Tool to automatically convert a SEGY to optimized format")
|
|
29
|
+
parser.add_argument('filepath', help="Full file path to .segy file")
|
|
30
|
+
parser.add_argument('-o','--out',help="Full output path for converted file")
|
|
31
|
+
|
|
32
|
+
parser.add_argument('--auto', action='store_true', help='Autmatically scan SEGY for header information and geometry')
|
|
33
|
+
|
|
34
|
+
parser.add_argument('--iline', type=int, help='Byte location for INLINE values in traces')
|
|
35
|
+
parser.add_argument('--xline', type=int, help='Byte location for CROSSLINE values in traces')
|
|
36
|
+
|
|
37
|
+
#Parse sys.argv
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
|
|
40
|
+
#Normalizing output path
|
|
41
|
+
out = os.path.join(output_folder,args.out+'.zarr')
|
|
42
|
+
|
|
43
|
+
#Automatically scan SEGY for geometry
|
|
44
|
+
# Resolve iline/xline bytes from either --auto or the manual flags.
|
|
45
|
+
if args.auto:
|
|
46
|
+
geom = detect_geometry(args.filepath)
|
|
47
|
+
logger.info(f'Scanned SEGY Geometry: {geom}')
|
|
48
|
+
iline, xline = geom["iline_byte"], geom["xline_byte"]
|
|
49
|
+
elif args.iline is not None and args.xline is not None:
|
|
50
|
+
iline, xline = args.iline, args.xline
|
|
51
|
+
logger.info(f'Using ILINE:{iline} XLINE:{xline} byte locations')
|
|
52
|
+
else:
|
|
53
|
+
parser.error('--iline and --xline are required when --auto is not set.')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
convert_segy_to_zarr(args.filepath, iline, xline, out, logger=logger)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == '__main__':
|
|
62
|
+
main()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
seisvtk/io/deviation.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Well deviation surveys: turn a trajectory file into XY/TVD along the hole.
|
|
2
|
+
|
|
3
|
+
A well is rarely a clean vertical line; to drape it on a cube we need the
|
|
4
|
+
horizontal position at every logged depth. Surveys come in two shapes:
|
|
5
|
+
|
|
6
|
+
* **Directional** — ``(MD, inclination, azimuth)``. The classic minimum-curvature
|
|
7
|
+
method (:func:`minimum_curvature`) integrates these angle stations into
|
|
8
|
+
east/north/TVD offsets from the wellhead. This is the industry-standard
|
|
9
|
+
survey-to-position calculation.
|
|
10
|
+
* **XYZ** — ``(MD, X, Y, Z)`` positions already computed by the acquisition
|
|
11
|
+
software; read straight through.
|
|
12
|
+
|
|
13
|
+
Both land in a :class:`Deviation`: measured depth plus east/north offsets from
|
|
14
|
+
the first station and true vertical depth, which :mod:`seisvtk.io.well` combines
|
|
15
|
+
with a wellhead ``(X, Y)`` and a time-depth relation to place the log.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from seisvtk.io.tables import pick_column, read_table, to_float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Deviation:
|
|
28
|
+
"""A trajectory sampled on measured depth.
|
|
29
|
+
|
|
30
|
+
``east``/``north`` are horizontal offsets from the first station (metres in
|
|
31
|
+
the survey's own units); ``tvd`` is true vertical depth below it. Add the
|
|
32
|
+
wellhead ``(X, Y)`` to ``east``/``north`` for absolute position.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
md: np.ndarray
|
|
36
|
+
east: np.ndarray
|
|
37
|
+
north: np.ndarray
|
|
38
|
+
tvd: np.ndarray
|
|
39
|
+
|
|
40
|
+
def at(self, md) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
41
|
+
"""``(east, north, tvd)`` offsets at measured depths ``md``.
|
|
42
|
+
|
|
43
|
+
Linear within the surveyed interval and *linearly extrapolated* along the
|
|
44
|
+
end tangents outside it (not clamped): a log sample above the first
|
|
45
|
+
station continues up the opening trajectory — vertical for a survey that
|
|
46
|
+
starts vertical — and one below the last station continues down the final
|
|
47
|
+
tangent, rather than freezing horizontally at the last station.
|
|
48
|
+
"""
|
|
49
|
+
md = np.asarray(md, float)
|
|
50
|
+
return (self._interp(md, self.east),
|
|
51
|
+
self._interp(md, self.north),
|
|
52
|
+
self._interp(md, self.tvd))
|
|
53
|
+
|
|
54
|
+
def _interp(self, md, vals) -> np.ndarray:
|
|
55
|
+
t = np.interp(md, self.md, vals)
|
|
56
|
+
if self.md.size >= 2: # extend the end tangents
|
|
57
|
+
s0 = (vals[1] - vals[0]) / (self.md[1] - self.md[0])
|
|
58
|
+
s1 = (vals[-1] - vals[-2]) / (self.md[-1] - self.md[-2])
|
|
59
|
+
t = np.where(md < self.md[0], vals[0] + s0 * (md - self.md[0]), t)
|
|
60
|
+
t = np.where(md > self.md[-1], vals[-1] + s1 * (md - self.md[-1]), t)
|
|
61
|
+
return t
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def minimum_curvature(md, inc_deg, azi_deg) -> Deviation:
|
|
65
|
+
"""Minimum-curvature trajectory from angle stations.
|
|
66
|
+
|
|
67
|
+
``inc_deg`` is inclination from vertical (0 = straight down) and ``azi_deg``
|
|
68
|
+
is azimuth clockwise from north, both in degrees. Returns cumulative
|
|
69
|
+
east/north/TVD offsets from the first station (which sits at the origin).
|
|
70
|
+
"""
|
|
71
|
+
md = np.asarray(md, float)
|
|
72
|
+
inc = np.radians(np.asarray(inc_deg, float))
|
|
73
|
+
azi = np.radians(np.asarray(azi_deg, float))
|
|
74
|
+
n = md.size
|
|
75
|
+
east = np.zeros(n)
|
|
76
|
+
north = np.zeros(n)
|
|
77
|
+
tvd = np.zeros(n)
|
|
78
|
+
|
|
79
|
+
for i in range(1, n):
|
|
80
|
+
dmd = md[i] - md[i - 1]
|
|
81
|
+
i1, i2 = inc[i - 1], inc[i]
|
|
82
|
+
a1, a2 = azi[i - 1], azi[i]
|
|
83
|
+
# dogleg angle between the two station directions
|
|
84
|
+
cos_dl = np.cos(i2 - i1) - np.sin(i1) * np.sin(i2) * (1.0 - np.cos(a2 - a1))
|
|
85
|
+
dl = np.arccos(np.clip(cos_dl, -1.0, 1.0))
|
|
86
|
+
rf = 1.0 if dl < 1e-9 else (2.0 / dl) * np.tan(dl / 2.0) # ratio factor
|
|
87
|
+
half = dmd / 2.0 * rf
|
|
88
|
+
north[i] = north[i - 1] + half * (np.sin(i1) * np.cos(a1) + np.sin(i2) * np.cos(a2))
|
|
89
|
+
east[i] = east[i - 1] + half * (np.sin(i1) * np.sin(a1) + np.sin(i2) * np.sin(a2))
|
|
90
|
+
tvd[i] = tvd[i - 1] + half * (np.cos(i1) + np.cos(i2))
|
|
91
|
+
|
|
92
|
+
return Deviation(md, east, north, tvd)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read_deviation(path: str, *, kind: str = "directional",
|
|
96
|
+
cols: tuple[int, ...] | None = None,
|
|
97
|
+
delimiter: str | None = None) -> Deviation:
|
|
98
|
+
"""Read a deviation survey file into a :class:`Deviation`.
|
|
99
|
+
|
|
100
|
+
``kind="directional"`` expects ``(MD, inc, azi)`` columns (default columns
|
|
101
|
+
1,2,3); ``kind="xyz"`` expects ``(MD, X, Y, Z)`` (default 1,2,3,4) and the
|
|
102
|
+
XY are stored as offsets from the first row so the model stays wellhead-
|
|
103
|
+
relative. ``cols`` overrides the 1-based column numbers; ``delimiter`` is
|
|
104
|
+
sniffed when ``None``. Rows with a non-finite value are dropped and the
|
|
105
|
+
survey is sorted by measured depth, since minimum-curvature accumulates down
|
|
106
|
+
the hole and :meth:`Deviation.at` needs ``md`` ascending.
|
|
107
|
+
"""
|
|
108
|
+
df = read_table(path, delimiter=delimiter)
|
|
109
|
+
|
|
110
|
+
if kind == "directional":
|
|
111
|
+
c = cols or (1, 2, 3)
|
|
112
|
+
md, (inc, azi) = _clean_survey(df, c[0], (c[1], c[2]))
|
|
113
|
+
return minimum_curvature(md, inc, azi)
|
|
114
|
+
|
|
115
|
+
if kind == "xyz":
|
|
116
|
+
c = cols or (1, 2, 3, 4)
|
|
117
|
+
md, (x, y, z) = _clean_survey(df, c[0], (c[1], c[2], c[3]))
|
|
118
|
+
return Deviation(md, x - x[0], y - y[0], z - z[0])
|
|
119
|
+
|
|
120
|
+
raise ValueError(f"kind must be 'directional' or 'xyz', got {kind!r}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _clean_survey(df, md_col, value_cols):
|
|
124
|
+
"""Pull MD + value columns, drop non-finite rows, sort ascending by MD."""
|
|
125
|
+
md = to_float(df[pick_column(df, md_col)])
|
|
126
|
+
values = [to_float(df[pick_column(df, c)]) for c in value_cols]
|
|
127
|
+
good = np.isfinite(md)
|
|
128
|
+
for v in values:
|
|
129
|
+
good &= np.isfinite(v)
|
|
130
|
+
order = np.argsort(md[good], kind="stable")
|
|
131
|
+
return md[good][order], tuple(v[good][order] for v in values)
|
seisvtk/io/horizon.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Interpreted horizon surfaces: read common export formats into one model.
|
|
2
|
+
|
|
3
|
+
A :class:`Horizon` is a set of interpreted picks in *survey coordinates* —
|
|
4
|
+
``(inline, crossline, z)`` where ``z`` is the vertical value in the cube's unit
|
|
5
|
+
(two-way time in ms, or depth). It is deliberately scene-independent: the same
|
|
6
|
+
horizon drapes onto any cube that shares the survey, and the viewer converts
|
|
7
|
+
line numbers to array indices when it builds the surface actor.
|
|
8
|
+
|
|
9
|
+
Readers cover the usual interpretation exports:
|
|
10
|
+
|
|
11
|
+
* :func:`read_horizon_xyz` — ``X Y Z`` world coordinates (no line numbers); needs
|
|
12
|
+
a :class:`~seisvtk.io.survey.GridTransform` to invert world -> grid. This is
|
|
13
|
+
the OpendTect / Petrel "XYZ" point export (e.g. the F3 horizons).
|
|
14
|
+
* :func:`read_horizon_ilxl` — columns that already include inline/crossline.
|
|
15
|
+
* :meth:`Horizon.from_arrays` — build directly from arrays, for a host app that
|
|
16
|
+
reads its own internal horizon type (map it to ``il/xl/z`` and hand it over).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from seisvtk.io.survey import GridTransform
|
|
25
|
+
from seisvtk.io.tables import read_table, to_float
|
|
26
|
+
|
|
27
|
+
# Common "no value" sentinels in interpretation exports.
|
|
28
|
+
NULLS = (-999.25, 999.25, 1.0e30, -1.0e30)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Horizon:
|
|
33
|
+
"""Interpreted picks on a survey grid: parallel ``il``, ``xl``, ``z`` arrays."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
il: np.ndarray # inline line numbers (int)
|
|
37
|
+
xl: np.ndarray # crossline line numbers (int)
|
|
38
|
+
z: np.ndarray # vertical value (ms or m), same units as the cube's z axis
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_arrays(cls, name, il, xl, z) -> "Horizon":
|
|
42
|
+
il = np.asarray(il).astype(np.int64)
|
|
43
|
+
xl = np.asarray(xl).astype(np.int64)
|
|
44
|
+
z = np.asarray(z).astype(float)
|
|
45
|
+
keep = np.isfinite(z) & ~np.isin(z, NULLS)
|
|
46
|
+
return cls(name, il[keep], xl[keep], z[keep])
|
|
47
|
+
|
|
48
|
+
def to_grid(self):
|
|
49
|
+
"""Scatter the picks onto their ``(inline, crossline)`` lattice.
|
|
50
|
+
|
|
51
|
+
Returns ``(il_axis, xl_axis, Z)`` where ``il_axis`` / ``xl_axis`` are the
|
|
52
|
+
sorted unique line numbers and ``Z`` is a ``(n_il, n_xl)`` array of ``z``
|
|
53
|
+
with ``NaN`` in cells that were never picked (the surface's holes).
|
|
54
|
+
"""
|
|
55
|
+
iu = np.unique(self.il)
|
|
56
|
+
xu = np.unique(self.xl)
|
|
57
|
+
Z = np.full((iu.size, xu.size), np.nan, dtype=float)
|
|
58
|
+
Z[np.searchsorted(iu, self.il), np.searchsorted(xu, self.xl)] = self.z
|
|
59
|
+
return iu, xu, Z
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def z_range(self) -> tuple[float, float]:
|
|
63
|
+
return float(np.nanmin(self.z)), float(np.nanmax(self.z))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_columns(path: str, cols: tuple[int, ...], *,
|
|
67
|
+
delimiter: str | None = None) -> np.ndarray:
|
|
68
|
+
"""Read numeric columns ``cols`` (0-based) as a float array, in that order.
|
|
69
|
+
|
|
70
|
+
Any delimiter (comma/tab/semicolon/whitespace) is sniffed, and a header row,
|
|
71
|
+
comment lines and null sentinels are handled by :mod:`seisvtk.io.tables`.
|
|
72
|
+
"""
|
|
73
|
+
df = read_table(path, delimiter=delimiter)
|
|
74
|
+
return np.column_stack([to_float(df.iloc[:, c]) for c in cols])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_horizon_xyz(path: str, transform: GridTransform, *,
|
|
78
|
+
name: str | None = None,
|
|
79
|
+
cols: tuple[int, int, int] = (0, 1, 2),
|
|
80
|
+
z_scale: float = 1.0,
|
|
81
|
+
delimiter: str | None = None) -> Horizon:
|
|
82
|
+
"""Read an ``X Y Z`` world-coordinate horizon, inverting ``transform`` to grid.
|
|
83
|
+
|
|
84
|
+
``z_scale`` converts the file's vertical unit to the cube's (e.g. ``1000`` for
|
|
85
|
+
a seconds export against a millisecond cube). Line numbers are rounded to the
|
|
86
|
+
nearest bin. ``delimiter`` is sniffed when ``None``.
|
|
87
|
+
"""
|
|
88
|
+
data = _load_columns(path, cols, delimiter=delimiter)
|
|
89
|
+
x, y, z = data[:, 0], data[:, 1], data[:, 2] * z_scale
|
|
90
|
+
il_f, xl_f = transform.world_to_grid(x, y)
|
|
91
|
+
il = np.rint(il_f).astype(np.int64)
|
|
92
|
+
xl = np.rint(xl_f).astype(np.int64)
|
|
93
|
+
return Horizon.from_arrays(name or _stem(path), il, xl, z)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_horizon_ilxl(path: str, *,
|
|
97
|
+
name: str | None = None,
|
|
98
|
+
cols: tuple[int, int, int] = (0, 1, 2),
|
|
99
|
+
z_scale: float = 1.0,
|
|
100
|
+
delimiter: str | None = None) -> Horizon:
|
|
101
|
+
"""Read a horizon whose columns already carry inline/crossline.
|
|
102
|
+
|
|
103
|
+
``cols`` selects ``(inline, crossline, z)`` column indices (0-based); the
|
|
104
|
+
default suits an ``IL XL Z`` file, and e.g. ``cols=(2, 3, 4)`` an
|
|
105
|
+
``X Y IL XL Z`` Petrel/Kingdom grid. ``delimiter`` is sniffed when ``None``.
|
|
106
|
+
"""
|
|
107
|
+
data = _load_columns(path, cols, delimiter=delimiter)
|
|
108
|
+
return Horizon.from_arrays(name or _stem(path),
|
|
109
|
+
data[:, 0], data[:, 1], data[:, 2] * z_scale)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_horizon_rows(path: str, *, cols: tuple[int, int, int, int] = (0, 1, 2, 3),
|
|
113
|
+
z_scale: float = 1.0,
|
|
114
|
+
delimiter: str | None = None) -> list[Horizon]:
|
|
115
|
+
"""Read a ``Name IL XL Z`` file holding one or more horizons.
|
|
116
|
+
|
|
117
|
+
``cols`` is the 0-based ``(name, inline, crossline, z)`` columns. Rows are
|
|
118
|
+
grouped by the name column, so a single file can carry several horizons;
|
|
119
|
+
returns one :class:`Horizon` per distinct name, in first-seen order.
|
|
120
|
+
"""
|
|
121
|
+
df = read_table(path, delimiter=delimiter)
|
|
122
|
+
name_c, il_c, xl_c, z_c = (df.columns[c] for c in cols)
|
|
123
|
+
il = to_float(df[il_c])
|
|
124
|
+
xl = to_float(df[xl_c])
|
|
125
|
+
z = to_float(df[z_c]) * z_scale
|
|
126
|
+
names = df[name_c].astype(str).to_numpy()
|
|
127
|
+
out = []
|
|
128
|
+
for nm in dict.fromkeys(names): # distinct, first-seen order
|
|
129
|
+
m = names == nm
|
|
130
|
+
out.append(Horizon.from_arrays(nm.strip(), il[m], xl[m], z[m]))
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_horizon_columns(path: str, *, il_col: int = 0, xl_col: int = 1,
|
|
135
|
+
z_scale: float = 1.0,
|
|
136
|
+
delimiter: str | None = None) -> list[Horizon]:
|
|
137
|
+
"""Read an ``IL XL H1 H2 ...`` file: one horizon per Z column.
|
|
138
|
+
|
|
139
|
+
Needs a header row naming the horizon columns; ``il_col``/``xl_col`` are the
|
|
140
|
+
0-based inline/crossline columns and every *other* column is a horizon whose
|
|
141
|
+
name is its header. Cells holding a null become holes in that surface.
|
|
142
|
+
"""
|
|
143
|
+
df = read_table(path, header=True, delimiter=delimiter)
|
|
144
|
+
il = to_float(df.iloc[:, il_col])
|
|
145
|
+
xl = to_float(df.iloc[:, xl_col])
|
|
146
|
+
out = []
|
|
147
|
+
for j, col in enumerate(df.columns):
|
|
148
|
+
if j in (il_col, xl_col):
|
|
149
|
+
continue
|
|
150
|
+
out.append(Horizon.from_arrays(str(col).strip(), il, xl,
|
|
151
|
+
to_float(df[col]) * z_scale))
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _stem(path: str) -> str:
|
|
156
|
+
import os
|
|
157
|
+
return os.path.splitext(os.path.basename(path))[0]
|
seisvtk/io/ingest.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Generic volume -> chunked Zarr ingest, shared by every input format.
|
|
2
|
+
|
|
3
|
+
``convert_segy_to_zarr`` (ragged SEG-Y traces) and any other source — a dense
|
|
4
|
+
numpy cube, an HDF5 dataset, a property volume produced by a modelling tool —
|
|
5
|
+
all funnel through here, so every store ends up with identical chunking,
|
|
6
|
+
compression, ``live`` mask and geometry-attribute conventions. Downstream
|
|
7
|
+
readers (:class:`seisvtk.io.volume.Volume`) therefore never care where a cube
|
|
8
|
+
came from.
|
|
9
|
+
|
|
10
|
+
Two entry points:
|
|
11
|
+
|
|
12
|
+
* :func:`write_bands_to_zarr` — the low-level writer. The caller supplies an
|
|
13
|
+
iterator of inline bands ``(b0, b1, buf, mask)``; this creates the arrays,
|
|
14
|
+
writes each band once, and stamps the geometry attributes. Ragged sources
|
|
15
|
+
(SEG-Y) that must scatter traces into a grid use this directly.
|
|
16
|
+
* :func:`write_array_to_zarr` — convenience for a source that is already a
|
|
17
|
+
dense ``(n_inline, n_crossline, n_sample)`` array-like (a numpy array or a
|
|
18
|
+
lazily-sliceable ``h5py``/``zarr`` dataset). It streams one inline band at a
|
|
19
|
+
time, so peak memory is a single band, never the whole cube.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import math
|
|
25
|
+
from typing import Any, Iterable, Mapping
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import zarr
|
|
29
|
+
from zarr.codecs import BloscCodec, BloscShuffle
|
|
30
|
+
|
|
31
|
+
# (inline, crossline, sample). The sample axis is short and read whole far more
|
|
32
|
+
# often than the lateral axes, so it gets the largest chunk.
|
|
33
|
+
DEFAULT_CHUNKS: tuple[int, int, int] = (64, 64, 128)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_compressor() -> BloscCodec:
|
|
37
|
+
"""The shared on-disk codec: Blosc+zstd, byte-shuffled, level 3."""
|
|
38
|
+
return BloscCodec(cname="zstd", clevel=3, shuffle=BloscShuffle.shuffle)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _logger(logger: logging.Logger | None) -> logging.Logger:
|
|
42
|
+
return logger or logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_fill(fill: float) -> float | str:
|
|
46
|
+
"""Zarr attributes are JSON, which has no NaN literal; store it as a string."""
|
|
47
|
+
return "nan" if (isinstance(fill, float) and math.isnan(fill)) else float(fill)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _geometry_attrs(
|
|
51
|
+
geometry: Mapping[str, Any],
|
|
52
|
+
shape: tuple[int, int, int],
|
|
53
|
+
fill: float,
|
|
54
|
+
n_live: int,
|
|
55
|
+
extra: Mapping[str, Any] | None,
|
|
56
|
+
) -> dict:
|
|
57
|
+
"""Assemble the canonical attribute dict every store carries.
|
|
58
|
+
|
|
59
|
+
``geometry`` must provide survey-coordinate ranges and the vertical sampling
|
|
60
|
+
so a reader can translate inline/crossline/z *labels* to array indices:
|
|
61
|
+
``iline_range``, ``xline_range``, ``z_range``, ``sample_interval`` (and
|
|
62
|
+
optionally ``z_start``, defaulting to ``z_range[0]``).
|
|
63
|
+
"""
|
|
64
|
+
n_il, n_xl, n_samp = shape
|
|
65
|
+
il_lo, il_hi = geometry["iline_range"]
|
|
66
|
+
xl_lo, xl_hi = geometry["xline_range"]
|
|
67
|
+
z_lo, z_hi = geometry["z_range"]
|
|
68
|
+
attrs = {
|
|
69
|
+
"iline_range": [int(il_lo), int(il_hi)],
|
|
70
|
+
"xline_range": [int(xl_lo), int(xl_hi)],
|
|
71
|
+
"z_range": [float(z_lo), float(z_hi)],
|
|
72
|
+
"sample_interval": float(geometry["sample_interval"]),
|
|
73
|
+
"z_start": float(geometry.get("z_start", z_lo)),
|
|
74
|
+
"n_samples": int(n_samp),
|
|
75
|
+
"grid_cells": int(n_il * n_xl),
|
|
76
|
+
"live_traces": int(n_live),
|
|
77
|
+
"missing_traces": int(n_il * n_xl - n_live),
|
|
78
|
+
"fill_value": _json_fill(fill),
|
|
79
|
+
}
|
|
80
|
+
transform = geometry.get("transform")
|
|
81
|
+
if transform is not None: # a host that knows its georeference
|
|
82
|
+
from seisvtk.io.survey import GridTransform
|
|
83
|
+
attrs["survey_transform"] = GridTransform.from_dict(transform).as_dict()
|
|
84
|
+
if extra:
|
|
85
|
+
attrs.update(dict(extra))
|
|
86
|
+
return attrs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_bands_to_zarr(
|
|
90
|
+
out: str,
|
|
91
|
+
*,
|
|
92
|
+
shape: tuple[int, int, int],
|
|
93
|
+
geometry: Mapping[str, Any],
|
|
94
|
+
bands: Iterable[tuple[int, int, np.ndarray, np.ndarray]],
|
|
95
|
+
chunks: tuple[int, int, int] = DEFAULT_CHUNKS,
|
|
96
|
+
fill: float = 0.0,
|
|
97
|
+
dtype: str = "float32",
|
|
98
|
+
compressor: BloscCodec | None = None,
|
|
99
|
+
extra_attrs: Mapping[str, Any] | None = None,
|
|
100
|
+
logger: logging.Logger | None = None,
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""Write a cube to a chunked, compressed Zarr from an iterator of inline bands.
|
|
103
|
+
|
|
104
|
+
Each item in ``bands`` is ``(b0, b1, buf, mask)``: the half-open inline range
|
|
105
|
+
``[b0, b1)``, the dense samples ``buf`` of shape ``(b1 - b0, n_xl, n_samp)``,
|
|
106
|
+
and a boolean ``mask`` of shape ``(b1 - b0, n_xl)`` marking which (inline,
|
|
107
|
+
crossline) cells hold a real trace. Bands are written once, in order; nothing
|
|
108
|
+
is accumulated, so memory is bounded by the largest band the caller yields.
|
|
109
|
+
|
|
110
|
+
Returns the store's final attribute dict.
|
|
111
|
+
"""
|
|
112
|
+
n_il, n_xl, n_samp = shape
|
|
113
|
+
comp = compressor or default_compressor()
|
|
114
|
+
log = _logger(logger)
|
|
115
|
+
|
|
116
|
+
cube = zarr.create_array(
|
|
117
|
+
store=out, shape=(n_il, n_xl, n_samp), chunks=chunks,
|
|
118
|
+
dtype=dtype, fill_value=fill, compressors=[comp],
|
|
119
|
+
)
|
|
120
|
+
live = zarr.create_array(
|
|
121
|
+
store=f"{out}/live", shape=(n_il, n_xl),
|
|
122
|
+
chunks=(chunks[0], n_xl), dtype="bool", fill_value=False,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
log.info(f"Writing {n_il} x {n_xl} x {n_samp} -> {out}")
|
|
126
|
+
written = 0
|
|
127
|
+
for b0, b1, buf, mask in bands:
|
|
128
|
+
cube[b0:b1] = buf
|
|
129
|
+
live[b0:b1] = mask
|
|
130
|
+
written += b1 - b0
|
|
131
|
+
log.info(f" inlines {b0}..{b1} ({written}/{n_il})")
|
|
132
|
+
|
|
133
|
+
n_live = int(live[:].sum())
|
|
134
|
+
attrs = _geometry_attrs(geometry, shape, fill, n_live, extra_attrs)
|
|
135
|
+
cube.attrs.update(attrs)
|
|
136
|
+
log.info(f"Done. live {n_live}/{n_il * n_xl}, missing {n_il * n_xl - n_live}")
|
|
137
|
+
return dict(cube.attrs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_array_to_zarr(
|
|
141
|
+
out: str,
|
|
142
|
+
array: Any,
|
|
143
|
+
geometry: Mapping[str, Any],
|
|
144
|
+
*,
|
|
145
|
+
chunk_il: int = 64,
|
|
146
|
+
chunk_xl: int = 64,
|
|
147
|
+
chunk_samp: int = 128,
|
|
148
|
+
fill: float = float("nan"),
|
|
149
|
+
stride: tuple[int, int, int] = (1, 1, 1),
|
|
150
|
+
logger: logging.Logger | None = None,
|
|
151
|
+
extra_attrs: Mapping[str, Any] | None = None,
|
|
152
|
+
) -> dict:
|
|
153
|
+
"""Stream a dense ``(n_inline, n_crossline, n_sample)`` array into a Zarr.
|
|
154
|
+
|
|
155
|
+
``array`` is anything that supports lazy band slicing ``array[b0:b1]`` and has
|
|
156
|
+
``.shape`` — a numpy array, an ``h5py`` dataset, another ``zarr`` array. Only
|
|
157
|
+
one inline band is in memory at a time.
|
|
158
|
+
|
|
159
|
+
Undefined cells are expected to be ``NaN`` (the natural "no data" marker for a
|
|
160
|
+
property volume living between horizons). ``fill`` defaults to ``NaN`` so the
|
|
161
|
+
stored cube preserves them exactly, and a cell is ``live`` if any sample in its
|
|
162
|
+
column is finite. ``stride`` decimates ``(il, xl, z)`` for a quick preview.
|
|
163
|
+
|
|
164
|
+
Note: geometry ``iline_range``/``xline_range``/``z_range`` describe the data
|
|
165
|
+
*as written*, so a caller that strides must pass already-strided ranges/step.
|
|
166
|
+
"""
|
|
167
|
+
log = _logger(logger)
|
|
168
|
+
si, sj, sk = stride
|
|
169
|
+
n_il = len(range(0, array.shape[0], si))
|
|
170
|
+
n_xl = len(range(0, array.shape[1], sj))
|
|
171
|
+
n_samp = len(range(0, array.shape[2], sk))
|
|
172
|
+
chunks = (chunk_il, chunk_xl, chunk_samp)
|
|
173
|
+
src_band = chunk_il * si # source inlines consumed per written band
|
|
174
|
+
|
|
175
|
+
def bands():
|
|
176
|
+
for b0 in range(0, n_il, chunk_il):
|
|
177
|
+
s0 = b0 * si
|
|
178
|
+
s1 = min(s0 + src_band, array.shape[0])
|
|
179
|
+
raw = np.asarray(array[s0:s1], dtype="float32")
|
|
180
|
+
buf = raw[::si, ::sj, ::sk]
|
|
181
|
+
b1 = b0 + buf.shape[0]
|
|
182
|
+
mask = np.isfinite(buf).any(axis=2)
|
|
183
|
+
yield b0, b1, buf, mask
|
|
184
|
+
|
|
185
|
+
return write_bands_to_zarr(
|
|
186
|
+
out, shape=(n_il, n_xl, n_samp), geometry=geometry, bands=bands(),
|
|
187
|
+
chunks=chunks, fill=fill, logger=logger, extra_attrs=extra_attrs,
|
|
188
|
+
)
|