seisvtk 0.1.0__tar.gz

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.
Files changed (49) hide show
  1. seisvtk-0.1.0/LICENSE +21 -0
  2. seisvtk-0.1.0/PKG-INFO +90 -0
  3. seisvtk-0.1.0/README.md +56 -0
  4. seisvtk-0.1.0/pyproject.toml +55 -0
  5. seisvtk-0.1.0/setup.cfg +4 -0
  6. seisvtk-0.1.0/src/seisvtk/__init__.py +0 -0
  7. seisvtk-0.1.0/src/seisvtk/io/__init__.py +1 -0
  8. seisvtk-0.1.0/src/seisvtk/io/browse.py +128 -0
  9. seisvtk-0.1.0/src/seisvtk/io/convert_segy.py +67 -0
  10. seisvtk-0.1.0/src/seisvtk/io/deviation.py +131 -0
  11. seisvtk-0.1.0/src/seisvtk/io/horizon.py +157 -0
  12. seisvtk-0.1.0/src/seisvtk/io/ingest.py +188 -0
  13. seisvtk-0.1.0/src/seisvtk/io/loaders.py +141 -0
  14. seisvtk-0.1.0/src/seisvtk/io/logging_config.py +9 -0
  15. seisvtk-0.1.0/src/seisvtk/io/segy2zarr.py +170 -0
  16. seisvtk-0.1.0/src/seisvtk/io/segy_scan.py +121 -0
  17. seisvtk-0.1.0/src/seisvtk/io/survey.py +193 -0
  18. seisvtk-0.1.0/src/seisvtk/io/tables.py +131 -0
  19. seisvtk-0.1.0/src/seisvtk/io/volume.py +144 -0
  20. seisvtk-0.1.0/src/seisvtk/io/well.py +275 -0
  21. seisvtk-0.1.0/src/seisvtk/viz/actors.py +30 -0
  22. seisvtk-0.1.0/src/seisvtk/viz/app.py +790 -0
  23. seisvtk-0.1.0/src/seisvtk/viz/bridge.py +26 -0
  24. seisvtk-0.1.0/src/seisvtk/viz/colormaps.py +83 -0
  25. seisvtk-0.1.0/src/seisvtk/viz/horizon_actor.py +81 -0
  26. seisvtk-0.1.0/src/seisvtk/viz/interactor.py +62 -0
  27. seisvtk-0.1.0/src/seisvtk/viz/render.py +64 -0
  28. seisvtk-0.1.0/src/seisvtk/viz/scene.py +18 -0
  29. seisvtk-0.1.0/src/seisvtk/viz/service.py +128 -0
  30. seisvtk-0.1.0/src/seisvtk/viz/slice_scene.py +390 -0
  31. seisvtk-0.1.0/src/seisvtk/viz/well_actor.py +70 -0
  32. seisvtk-0.1.0/src/seisvtk.egg-info/PKG-INFO +90 -0
  33. seisvtk-0.1.0/src/seisvtk.egg-info/SOURCES.txt +47 -0
  34. seisvtk-0.1.0/src/seisvtk.egg-info/dependency_links.txt +1 -0
  35. seisvtk-0.1.0/src/seisvtk.egg-info/entry_points.txt +2 -0
  36. seisvtk-0.1.0/src/seisvtk.egg-info/requires.txt +18 -0
  37. seisvtk-0.1.0/src/seisvtk.egg-info/top_level.txt +1 -0
  38. seisvtk-0.1.0/tests/test_browse.py +80 -0
  39. seisvtk-0.1.0/tests/test_deviation.py +80 -0
  40. seisvtk-0.1.0/tests/test_horizon.py +68 -0
  41. seisvtk-0.1.0/tests/test_ingest.py +67 -0
  42. seisvtk-0.1.0/tests/test_loaders.py +117 -0
  43. seisvtk-0.1.0/tests/test_segy.py +159 -0
  44. seisvtk-0.1.0/tests/test_service.py +74 -0
  45. seisvtk-0.1.0/tests/test_slice_scene.py +44 -0
  46. seisvtk-0.1.0/tests/test_survey.py +96 -0
  47. seisvtk-0.1.0/tests/test_tables.py +53 -0
  48. seisvtk-0.1.0/tests/test_volume.py +53 -0
  49. seisvtk-0.1.0/tests/test_well.py +155 -0
seisvtk-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aashrit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
seisvtk-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: seisvtk
3
+ Version: 0.1.0
4
+ Summary: Server-side 3D seismic cross-section viewer (trame + VTK) with a VTK-free I/O core
5
+ Author-email: Aashrit <aashrith12345@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/aashrith48/seisvtk
8
+ Project-URL: Repository, https://github.com/aashrith48/seisvtk
9
+ Keywords: seismic,visualization,vtk,trame,segy,zarr,geoscience
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Scientific/Engineering :: Visualization
14
+ Classifier: Intended Audience :: Science/Research
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy
19
+ Requires-Dist: pandas
20
+ Requires-Dist: segyio
21
+ Requires-Dist: lasio
22
+ Requires-Dist: zarr>=3
23
+ Provides-Extra: viz
24
+ Requires-Dist: vtk; extra == "viz"
25
+ Requires-Dist: trame; extra == "viz"
26
+ Requires-Dist: trame-vtk; extra == "viz"
27
+ Requires-Dist: trame-vuetify; extra == "viz"
28
+ Provides-Extra: s3
29
+ Requires-Dist: fsspec; extra == "s3"
30
+ Requires-Dist: s3fs; extra == "s3"
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest; extra == "test"
33
+ Dynamic: license-file
34
+
35
+ # seisvtk
36
+
37
+ Server-side 3D seismic cross-section viewer (trame + VTK) with a lightweight,
38
+ VTK-free I/O core. The viewer renders inline / crossline / Z slices of a cube and
39
+ streams the rendered frames to the browser, so the client stays thin and the cube
40
+ is read **out-of-core** — only the chunks a visible slice touches are loaded, from
41
+ local disk or straight from object storage (`s3://`). Wells (including deviated)
42
+ and horizons drape on the same grid.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install seisvtk # I/O core: SEG-Y -> Zarr, wells, horizons, geometry
48
+ pip install "seisvtk[viz]" # + the trame/VTK viewer
49
+ pip install "seisvtk[s3]" # + read cubes directly from s3://
50
+ ```
51
+
52
+ The core has no VTK dependency, so a data producer (e.g. a pipeline step that
53
+ only converts SEG-Y and writes overlays) installs just `seisvtk`. For a **headless
54
+ / CPU** render host, install the `vtk-osmesa` wheel in place of `vtk` (see
55
+ `docker/Dockerfile`).
56
+
57
+ ## Use
58
+
59
+ Serve a single cube:
60
+
61
+ ```bash
62
+ python -m seisvtk.viz.app path/to/cube.zarr --mode property --port 8910
63
+ ```
64
+
65
+ Serve a scene manifest (a cube plus well/horizon overlays — `s3://` cubes work):
66
+
67
+ ```bash
68
+ seisvtk-viewer scene.json --port 8910
69
+ ```
70
+
71
+ Convert a SEG-Y to a chunked Zarr from Python:
72
+
73
+ ```python
74
+ from seisvtk.io.loaders import convert_segy
75
+ convert_segy("survey.segy", "survey.zarr") # geometry auto-detected from headers
76
+ ```
77
+
78
+ ## What's in the box
79
+
80
+ - **I/O (`seisvtk.io`, no VTK):** streaming SEG-Y→Zarr conversion with survey-geometry
81
+ recovery; an out-of-core `Volume` reader (local or `s3://`); readers for wells
82
+ (LAS/CSV, deviation surveys, checkshots), horizons, and the survey affine
83
+ (`GridTransform`).
84
+ - **Viewer (`seisvtk.viz`):** a trame app streaming inline/crossline/Z slice planes,
85
+ well tubes coloured by a log, and horizon surfaces; amplitude and property
86
+ colormaps; vertical exaggeration; runtime cube swap.
87
+
88
+ ## License
89
+
90
+ [MIT](LICENSE) © 2026 Aashrit
@@ -0,0 +1,56 @@
1
+ # seisvtk
2
+
3
+ Server-side 3D seismic cross-section viewer (trame + VTK) with a lightweight,
4
+ VTK-free I/O core. The viewer renders inline / crossline / Z slices of a cube and
5
+ streams the rendered frames to the browser, so the client stays thin and the cube
6
+ is read **out-of-core** — only the chunks a visible slice touches are loaded, from
7
+ local disk or straight from object storage (`s3://`). Wells (including deviated)
8
+ and horizons drape on the same grid.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install seisvtk # I/O core: SEG-Y -> Zarr, wells, horizons, geometry
14
+ pip install "seisvtk[viz]" # + the trame/VTK viewer
15
+ pip install "seisvtk[s3]" # + read cubes directly from s3://
16
+ ```
17
+
18
+ The core has no VTK dependency, so a data producer (e.g. a pipeline step that
19
+ only converts SEG-Y and writes overlays) installs just `seisvtk`. For a **headless
20
+ / CPU** render host, install the `vtk-osmesa` wheel in place of `vtk` (see
21
+ `docker/Dockerfile`).
22
+
23
+ ## Use
24
+
25
+ Serve a single cube:
26
+
27
+ ```bash
28
+ python -m seisvtk.viz.app path/to/cube.zarr --mode property --port 8910
29
+ ```
30
+
31
+ Serve a scene manifest (a cube plus well/horizon overlays — `s3://` cubes work):
32
+
33
+ ```bash
34
+ seisvtk-viewer scene.json --port 8910
35
+ ```
36
+
37
+ Convert a SEG-Y to a chunked Zarr from Python:
38
+
39
+ ```python
40
+ from seisvtk.io.loaders import convert_segy
41
+ convert_segy("survey.segy", "survey.zarr") # geometry auto-detected from headers
42
+ ```
43
+
44
+ ## What's in the box
45
+
46
+ - **I/O (`seisvtk.io`, no VTK):** streaming SEG-Y→Zarr conversion with survey-geometry
47
+ recovery; an out-of-core `Volume` reader (local or `s3://`); readers for wells
48
+ (LAS/CSV, deviation surveys, checkshots), horizons, and the survey affine
49
+ (`GridTransform`).
50
+ - **Viewer (`seisvtk.viz`):** a trame app streaming inline/crossline/Z slice planes,
51
+ well tubes coloured by a log, and horizon surfaces; amplitude and property
52
+ colormaps; vertical exaggeration; runtime cube swap.
53
+
54
+ ## License
55
+
56
+ [MIT](LICENSE) © 2026 Aashrit
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "seisvtk"
7
+ version = "0.1.0"
8
+ description = "Server-side 3D seismic cross-section viewer (trame + VTK) with a VTK-free I/O core"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Aashrit", email = "aashrith12345@gmail.com" }]
12
+ license = { text = "MIT" }
13
+ keywords = ["seismic", "visualization", "vtk", "trame", "segy", "zarr", "geoscience"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Scientific/Engineering :: Visualization",
19
+ "Intended Audience :: Science/Research",
20
+ ]
21
+
22
+ # Core = the VTK-free I/O layer (seisvtk.io): read/convert SEG-Y, Zarr, wells,
23
+ # horizons, survey geometry. This is all a producer (e.g. a one-shot pipeline
24
+ # node) needs, and it stays light — no VTK, no trame.
25
+ dependencies = [
26
+ "numpy",
27
+ "pandas",
28
+ "segyio",
29
+ "lasio",
30
+ "zarr>=3",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/aashrith48/seisvtk"
35
+ Repository = "https://github.com/aashrith48/seisvtk"
36
+
37
+ [project.scripts]
38
+ seisvtk-viewer = "seisvtk.viz.service:main"
39
+
40
+ [project.optional-dependencies]
41
+ # Rendering/serving: the trame+VTK viewer (seisvtk.viz). Add this only where you
42
+ # actually render. Use the vtk-osmesa wheel instead of vtk for headless/CPU hosts.
43
+ viz = ["vtk", "trame", "trame-vtk", "trame-vuetify"]
44
+ # Reading a cube straight from object storage (s3://…) via an fsspec backend.
45
+ s3 = ["fsspec", "s3fs"]
46
+ test = ["pytest"]
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["src"]
50
+
51
+ [tool.pytest.ini_options]
52
+ pythonpath = ["src"]
53
+ testpaths = ["tests"]
54
+
55
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1 @@
1
+
@@ -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)