Skip to content

Commit cb9b0bb

Browse files
committed
Look at adding zgy converter
1 parent 3d01a6d commit cb9b0bb

6 files changed

Lines changed: 317 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dependencies = [
3838
cloud = ["s3fs>=2025.9.0", "gcsfs>=2025.9.0", "adlfs>=2025.8.0"]
3939
distributed = ["distributed>=2025.9.1", "bokeh>=3.8.0"]
4040
lossy = ["zfpy>=1.0.1"]
41+
zgy = ["pyzgy>=0.1.1"]
4142

4243
[project.urls]
4344
homepage = "https://mdio.dev/"

src/mdio/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from mdio.api.io import to_mdio
99
from mdio.converters import mdio_to_segy
1010
from mdio.converters import segy_to_mdio
11+
from mdio.converters import zgy_to_mdio
1112
from mdio.optimize.access_pattern import OptimizedAccessPatternConfig
1213
from mdio.optimize.access_pattern import optimize_access_patterns
1314

@@ -23,6 +24,7 @@
2324
"to_mdio",
2425
"mdio_to_segy",
2526
"segy_to_mdio",
27+
"zgy_to_mdio",
2628
"OptimizedAccessPatternConfig",
2729
"optimize_access_patterns",
2830
]

src/mdio/converters/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
from mdio.converters.mdio import mdio_to_segy
44
from mdio.converters.segy import segy_to_mdio
5+
from mdio.converters.zgy import zgy_to_mdio
56

6-
__all__ = ["mdio_to_segy", "segy_to_mdio"]
7+
__all__ = ["mdio_to_segy", "segy_to_mdio", "zgy_to_mdio"]

src/mdio/converters/zgy.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Conversion from ZGY to MDIO v1 format."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING
7+
8+
import numpy as np
9+
10+
from mdio.api.io import _normalize_path
11+
from mdio.api.io import to_mdio
12+
13+
if TYPE_CHECKING:
14+
from pathlib import Path
15+
16+
from upath import UPath
17+
from xarray import Dataset as xr_Dataset
18+
19+
from mdio.builder.templates.base import AbstractDatasetTemplate
20+
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
def _import_pyzgy() -> type:
26+
"""Import pyzgy and return the module.
27+
28+
Returns:
29+
The pyzgy module.
30+
31+
Raises:
32+
ImportError: If pyzgy is not installed.
33+
"""
34+
try:
35+
import pyzgy # noqa: PLC0415
36+
37+
return pyzgy
38+
except ImportError as e:
39+
msg = (
40+
"The 'pyzgy' package is required for ZGY file support. "
41+
"Install via 'pip install multidimio[zgy]' or 'pip install pyzgy'."
42+
)
43+
raise ImportError(msg) from e
44+
45+
46+
def zgy_to_mdio(
47+
input_path: UPath | Path | str,
48+
output_path: UPath | Path | str,
49+
mdio_template: AbstractDatasetTemplate | None = None, # noqa: ARG001
50+
overwrite: bool = False,
51+
) -> None:
52+
"""Convert a ZGY file to an MDIO v1 file.
53+
54+
ZGY is a seismic data format developed by Schlumberger that provides fast random access
55+
to 3D seismic volumes. This function converts ZGY files to MDIO format using pyzgy's
56+
xarray backend for seamless data handling.
57+
58+
Args:
59+
input_path: The universal path to the input ZGY file.
60+
output_path: The universal path for the output MDIO v1 file.
61+
mdio_template: Optional MDIO template for customization. Currently unused as
62+
the dataset structure is derived directly from the ZGY file.
63+
overwrite: Whether to overwrite the output file if it already exists. Defaults to False.
64+
65+
Raises:
66+
FileExistsError: If the output location already exists and overwrite is False.
67+
68+
Example:
69+
>>> from mdio.converters.zgy import zgy_to_mdio
70+
>>> zgy_to_mdio("input.zgy", "output.mdio")
71+
"""
72+
import xarray as xr # noqa: PLC0415
73+
74+
# Validate pyzgy is available
75+
_import_pyzgy()
76+
77+
input_path = _normalize_path(input_path)
78+
output_path = _normalize_path(output_path)
79+
80+
if not overwrite and output_path.exists():
81+
err = f"Output location '{output_path.as_posix()}' exists. Set `overwrite=True` if intended."
82+
raise FileExistsError(err)
83+
84+
# Open ZGY file using pyzgy's xarray backend
85+
logger.info("Opening ZGY file: %s", input_path.as_posix())
86+
zgy_ds: xr_Dataset = xr.open_dataset(input_path.as_posix(), engine="pyzgy")
87+
88+
# Rename dimensions to match MDIO conventions
89+
dim_mapping = {"iline": "inline", "xline": "crossline"}
90+
zgy_ds = zgy_ds.rename({k: v for k, v in dim_mapping.items() if k in zgy_ds.dims})
91+
92+
# Rename data variable if needed
93+
if "data" in zgy_ds.data_vars:
94+
zgy_ds = zgy_ds.rename_vars({"data": "amplitude"})
95+
96+
# Add MDIO metadata
97+
zgy_ds.attrs["name"] = _get_template_name(zgy_ds)
98+
zgy_ds.attrs["attributes"] = {
99+
"surveyType": "3D",
100+
"gatherType": "stacked",
101+
"defaultVariableName": "amplitude",
102+
}
103+
104+
# Create trace mask (all True for ZGY - no dead traces)
105+
spatial_dims = [d for d in zgy_ds.dims if d not in ("time", "depth", "sample")]
106+
mask_shape = tuple(zgy_ds.sizes[d] for d in spatial_dims)
107+
zgy_ds["trace_mask"] = (spatial_dims, np.ones(mask_shape, dtype=bool))
108+
109+
# Write to MDIO
110+
logger.info("Writing MDIO file: %s", output_path.as_posix())
111+
to_mdio(zgy_ds, output_path=output_path, mode="w", compute=True)
112+
113+
logger.info("ZGY to MDIO conversion complete")
114+
115+
116+
def _get_template_name(ds: xr_Dataset) -> str:
117+
"""Determine template name based on dataset dimensions."""
118+
if "depth" in ds.dims:
119+
return "PostStack3DDepth"
120+
return "PostStack3DTime"

test_convert.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from mdio import open_mdio
2+
from datetime import datetime, UTC
3+
from mdio import to_mdio
4+
5+
input_mdio = "tmp.mdio"
6+
output_mdio = "tmp_output.mdio"
7+
8+
ds = open_mdio(input_mdio, chunks={})
9+
10+
print("Dataset when first opened:")
11+
print(ds)
12+
13+
for coord in ds.coords:
14+
ds[coord] = ds[coord].compute()
15+
16+
ds = ds.drop_vars(["raw_headers"])
17+
for var in ds.data_vars:
18+
if "fast" in var:
19+
ds = ds.drop_vars([var])
20+
21+
ds = ds.rename_vars(amplitude="probability")
22+
del ds["probability"].attrs["statsV1"]
23+
ds["probability"].encoding["fill_value"] = 0
24+
ds["probability"].encoding["_FillValue"] = 0
25+
26+
ds["support"] = ds["probability"].copy().astype("uint32")
27+
ds["support"].encoding = ds["probability"].encoding
28+
ds["support"].encoding.pop("dtype")
29+
30+
ds.attrs["createdOn"] = datetime.now(UTC).isoformat()
31+
ds.attrs["name"] = "Sfm3DInfereneResult"
32+
ds.attrs["attributes"]["defaultVariableName"] = "probability"
33+
34+
mode = "w"
35+
36+
print("Dataset before writing:")
37+
print(ds)
38+
39+
to_mdio(ds, output_mdio, compute=False, mode=mode)

tests/unit/test_zgy_converter.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Unit tests for ZGY to MDIO conversion."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
from unittest.mock import MagicMock
7+
from unittest.mock import patch
8+
9+
import numpy as np
10+
import pytest
11+
import xarray as xr
12+
13+
from mdio.api.io import open_mdio
14+
from mdio.converters.zgy import zgy_to_mdio
15+
16+
if TYPE_CHECKING:
17+
from pathlib import Path
18+
19+
20+
def _create_mock_zgy_dataset(
21+
n_ilines: int = 5,
22+
n_xlines: int = 10,
23+
n_samples: int = 50,
24+
vertical_dim: str = "time",
25+
) -> xr.Dataset:
26+
"""Create a mock xarray Dataset simulating pyzgy output.
27+
28+
Args:
29+
n_ilines: Number of inlines.
30+
n_xlines: Number of crosslines.
31+
n_samples: Number of samples.
32+
vertical_dim: Name of vertical dimension ('time' or 'depth').
33+
34+
Returns:
35+
Mock xarray Dataset.
36+
"""
37+
rng = np.random.default_rng(seed=42)
38+
39+
data = rng.standard_normal((n_ilines, n_xlines, n_samples)).astype(np.float32)
40+
41+
return xr.Dataset(
42+
{"data": (["iline", "xline", vertical_dim], data)},
43+
coords={
44+
"iline": np.arange(1, n_ilines + 1),
45+
"xline": np.arange(1, n_xlines + 1),
46+
vertical_dim: np.arange(0, n_samples * 4, 4),
47+
},
48+
)
49+
50+
51+
class TestZgyToMdio:
52+
"""Tests for zgy_to_mdio function."""
53+
54+
def test_output_exists_no_overwrite(self, tmp_path: Path) -> None:
55+
"""Test that FileExistsError is raised when output exists and overwrite=False."""
56+
output_path = tmp_path / "output.mdio"
57+
output_path.mkdir()
58+
59+
mock_pyzgy = MagicMock()
60+
with (
61+
patch.dict("sys.modules", {"pyzgy": mock_pyzgy}),
62+
pytest.raises(FileExistsError, match="exists"),
63+
):
64+
zgy_to_mdio("input.zgy", output_path, overwrite=False)
65+
66+
def test_pyzgy_not_installed(self, tmp_path: Path) -> None:
67+
"""Test that ImportError is raised when pyzgy is not installed."""
68+
output_path = tmp_path / "output.mdio"
69+
70+
with (
71+
patch.dict("sys.modules", {"pyzgy": None}),
72+
pytest.raises(ImportError, match="pyzgy"),
73+
):
74+
zgy_to_mdio("input.zgy", output_path)
75+
76+
def test_full_conversion_time_domain(self, tmp_path: Path) -> None:
77+
"""Test full conversion workflow for time-domain data."""
78+
mock_ds = _create_mock_zgy_dataset(n_ilines=5, n_xlines=10, n_samples=50, vertical_dim="time")
79+
output_path = tmp_path / "output.mdio"
80+
81+
mock_pyzgy = MagicMock()
82+
with (
83+
patch.dict("sys.modules", {"pyzgy": mock_pyzgy}),
84+
patch("xarray.open_dataset", return_value=mock_ds),
85+
):
86+
zgy_to_mdio("test.zgy", output_path, overwrite=True)
87+
88+
# Verify output
89+
assert output_path.exists()
90+
91+
ds = open_mdio(output_path)
92+
93+
# Check dimensions were renamed
94+
assert "inline" in ds.dims
95+
assert "crossline" in ds.dims
96+
assert "time" in ds.dims
97+
assert ds.sizes["inline"] == 5
98+
assert ds.sizes["crossline"] == 10
99+
assert ds.sizes["time"] == 50
100+
101+
# Check data variable renamed
102+
assert "amplitude" in ds.data_vars
103+
104+
# Check trace mask created
105+
assert "trace_mask" in ds.data_vars
106+
assert ds["trace_mask"].shape == (5, 10)
107+
assert ds["trace_mask"].values.all()
108+
109+
# Check metadata
110+
assert ds.attrs["name"] == "PostStack3DTime"
111+
112+
def test_full_conversion_depth_domain(self, tmp_path: Path) -> None:
113+
"""Test full conversion workflow for depth-domain data."""
114+
mock_ds = _create_mock_zgy_dataset(n_ilines=3, n_xlines=4, n_samples=25, vertical_dim="depth")
115+
output_path = tmp_path / "output_depth.mdio"
116+
117+
mock_pyzgy = MagicMock()
118+
with (
119+
patch.dict("sys.modules", {"pyzgy": mock_pyzgy}),
120+
patch("xarray.open_dataset", return_value=mock_ds),
121+
):
122+
zgy_to_mdio("test.zgy", output_path, overwrite=True)
123+
124+
ds = open_mdio(output_path)
125+
126+
assert "depth" in ds.dims
127+
assert "time" not in ds.dims
128+
assert ds.attrs["name"] == "PostStack3DDepth"
129+
130+
def test_overwrite_existing(self, tmp_path: Path) -> None:
131+
"""Test that overwrite=True allows overwriting existing files."""
132+
mock_ds = _create_mock_zgy_dataset()
133+
output_path = tmp_path / "output.mdio"
134+
135+
mock_pyzgy = MagicMock()
136+
137+
# First write
138+
with (
139+
patch.dict("sys.modules", {"pyzgy": mock_pyzgy}),
140+
patch("xarray.open_dataset", return_value=mock_ds),
141+
):
142+
zgy_to_mdio("test.zgy", output_path, overwrite=True)
143+
144+
assert output_path.exists()
145+
146+
# Second write with overwrite
147+
with (
148+
patch.dict("sys.modules", {"pyzgy": mock_pyzgy}),
149+
patch("xarray.open_dataset", return_value=mock_ds),
150+
):
151+
zgy_to_mdio("test.zgy", output_path, overwrite=True)
152+
153+
assert output_path.exists()

0 commit comments

Comments
 (0)