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 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
+
@@ -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
+ )