Skip to content

Commit b43fd63

Browse files
authored
Extract SEG-Y ingestion logic into modular framework (TGSAI#819)
* Bump version for release * Modularize segy ingestion code * Remove unnecessary compat imports * Implement unit tests for refactored helper functions * Precommit * pre-commit * Remove extranious validations * Cleanup magic numbers in coordinate unit getter * Add useful deleted comments back * pre-commit
1 parent 2e5c057 commit b43fd63

23 files changed

Lines changed: 1845 additions & 623 deletions

src/mdio/converters/segy.py

Lines changed: 8 additions & 346 deletions
Large diffs are not rendered by default.

src/mdio/ingestion/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""MDIO ingestion helpers."""

src/mdio/ingestion/coordinates.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Generic coordinate population for ingestion."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import numpy as np
8+
9+
from mdio.segy.scalar import SCALE_COORDINATE_KEYS
10+
from mdio.segy.scalar import _apply_coordinate_scalar
11+
12+
if TYPE_CHECKING:
13+
from segy.arrays import HeaderArray as SegyHeaderArray
14+
from xarray import Dataset as xr_Dataset
15+
16+
from mdio.core.grid import Grid
17+
18+
19+
def populate_dim_coordinates(
20+
dataset: xr_Dataset, grid: Grid, drop_vars_delayed: list[str]
21+
) -> tuple[xr_Dataset, list[str]]:
22+
"""Populate the xarray dataset with dimension coordinate variables."""
23+
for dim in grid.dims:
24+
dataset[dim.name].values[:] = dim.coords
25+
drop_vars_delayed.append(dim.name)
26+
return dataset, drop_vars_delayed
27+
28+
29+
def populate_non_dim_coordinates(
30+
dataset: xr_Dataset,
31+
grid: Grid,
32+
coordinates: dict[str, SegyHeaderArray],
33+
drop_vars_delayed: list[str],
34+
spatial_coordinate_scalar: int,
35+
) -> tuple[xr_Dataset, list[str]]:
36+
"""Populate the xarray dataset with coordinate variables.
37+
38+
Memory optimization: Processes coordinates one at a time and explicitly
39+
releases intermediate arrays to reduce peak memory usage.
40+
"""
41+
non_data_domain_dims = grid.dim_names[:-1]
42+
43+
coord_names = list(coordinates.keys())
44+
for coord_name in coord_names:
45+
coord_values = coordinates.pop(coord_name)
46+
da_coord = dataset[coord_name]
47+
48+
coord_shape = da_coord.shape
49+
50+
fill_value = da_coord.encoding.get("_FillValue") or da_coord.encoding.get("fill_value")
51+
if fill_value is None:
52+
fill_value = np.nan
53+
tmp_coord_values = np.full(coord_shape, fill_value, dtype=da_coord.dtype)
54+
55+
coord_axes = tuple(non_data_domain_dims.index(coord_dim) for coord_dim in da_coord.dims)
56+
coord_slices = tuple(slice(None) if idx in coord_axes else 0 for idx in range(len(non_data_domain_dims)))
57+
58+
coord_trace_indices = np.asarray(grid.map[coord_slices])
59+
60+
not_null = coord_trace_indices != grid.map.fill_value
61+
62+
if not_null.any():
63+
valid_indices = coord_trace_indices[not_null]
64+
tmp_coord_values[not_null] = coord_values[valid_indices]
65+
66+
if coord_name in SCALE_COORDINATE_KEYS:
67+
tmp_coord_values = _apply_coordinate_scalar(tmp_coord_values, spatial_coordinate_scalar)
68+
69+
dataset[coord_name][:] = tmp_coord_values
70+
drop_vars_delayed.append(coord_name)
71+
72+
del tmp_coord_values, coord_trace_indices, not_null, coord_values
73+
74+
# TODO(Altay): Add verification of reduced coordinates being the same as the first
75+
# https://github.com/TGSAI/mdio-python/issues/645
76+
77+
return dataset, drop_vars_delayed

src/mdio/ingestion/grid_qc.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Grid sparsity quality control for ingestion."""
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.converters.exceptions import GridTraceSparsityError
11+
from mdio.core.config import MDIOSettings
12+
13+
if TYPE_CHECKING:
14+
from mdio.core.grid import Grid
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def grid_density_qc(grid: Grid, num_traces: int) -> None:
20+
"""Quality control for sensible grid density during SEG-Y to MDIO conversion.
21+
22+
This function checks the density of the proposed grid by comparing the total possible traces
23+
(`grid_traces`) to the actual number of traces in the SEG-Y file (`num_traces`). A warning is
24+
logged if the sparsity ratio (`grid_traces / num_traces`) exceeds a configurable threshold,
25+
indicating potential inefficiency or misconfiguration.
26+
27+
The warning threshold is set via the environment variable `MDIO__GRID__SPARSITY_RATIO_WARN`
28+
(default 2), and the error threshold via `MDIO__GRID__SPARSITY_RATIO_LIMIT` (default 10). To
29+
suppress the exception (but still log warnings), set `MDIO_IGNORE_CHECKS=1`.
30+
31+
Args:
32+
grid: The Grid instance to check.
33+
num_traces: Expected number of traces from the SEG-Y file.
34+
35+
Raises:
36+
GridTraceSparsityError: If the sparsity ratio exceeds `MDIO__GRID__SPARSITY_RATIO_LIMIT`
37+
and `MDIO_IGNORE_CHECKS` is not set to a truthy value (e.g., "1", "true").
38+
"""
39+
settings = MDIOSettings()
40+
grid_traces = np.prod(grid.shape[:-1], dtype=np.uint64)
41+
42+
sparsity_ratio = float("inf") if num_traces == 0 else grid_traces / num_traces
43+
44+
warning_ratio = settings.grid_sparsity_ratio_warn
45+
error_ratio = settings.grid_sparsity_ratio_limit
46+
ignore_checks = settings.ignore_checks
47+
48+
should_warn = sparsity_ratio > warning_ratio
49+
should_error = sparsity_ratio > error_ratio and not ignore_checks
50+
51+
if not should_warn and not should_error:
52+
return
53+
54+
dims = dict(zip(grid.dim_names, grid.shape, strict=True))
55+
msg = (
56+
f"Ingestion grid is sparse. Sparsity ratio: {sparsity_ratio:.2f}. "
57+
f"Ingestion grid: {dims}. "
58+
f"SEG-Y trace count: {num_traces}, grid trace count: {grid_traces}."
59+
)
60+
for dim_name in grid.dim_names:
61+
dim_min = grid.get_min(dim_name)
62+
dim_max = grid.get_max(dim_name)
63+
msg += f"\n{dim_name} min: {dim_min} max: {dim_max}"
64+
65+
if should_warn:
66+
logger.warning(msg)
67+
68+
if should_error:
69+
raise GridTraceSparsityError(grid.shape, num_traces, msg)

src/mdio/ingestion/metadata.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Generic dataset metadata helpers for ingestion."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
from typing import Any
7+
8+
if TYPE_CHECKING:
9+
from mdio.builder.schemas import Dataset
10+
11+
12+
def _add_grid_override_to_metadata(dataset: Dataset, grid_overrides: dict[str, Any] | None) -> None:
13+
"""Add grid override to Dataset metadata if needed."""
14+
if dataset.metadata.attributes is None:
15+
dataset.metadata.attributes = {}
16+
17+
if grid_overrides is not None:
18+
dataset.metadata.attributes["gridOverrides"] = grid_overrides
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""SEG-Y specific ingestion helpers."""
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Coordinate extraction and unit resolution for SEG-Y ingestion."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING
7+
8+
import numpy as np
9+
from segy.standards.codes import MeasurementSystem as SegyMeasurementSystem
10+
from segy.standards.fields import binary as binary_header_fields
11+
12+
from mdio.builder.schemas.v1.units import AngleUnitEnum
13+
from mdio.builder.schemas.v1.units import AngleUnitModel
14+
from mdio.builder.schemas.v1.units import LengthUnitEnum
15+
from mdio.builder.schemas.v1.units import LengthUnitModel
16+
from mdio.ingestion.coordinates import populate_dim_coordinates
17+
from mdio.ingestion.coordinates import populate_non_dim_coordinates
18+
19+
if TYPE_CHECKING:
20+
from segy.arrays import HeaderArray as SegyHeaderArray
21+
from xarray import Dataset as xr_Dataset
22+
23+
from mdio.builder.templates.base import AbstractDatasetTemplate
24+
from mdio.core.dimension import Dimension
25+
from mdio.core.grid import Grid
26+
from mdio.segy.file import SegyFileInfo
27+
28+
logger = logging.getLogger(__name__)
29+
30+
31+
MEASUREMENT_SYSTEM_KEY = binary_header_fields.Rev0.MEASUREMENT_SYSTEM_CODE.model.name
32+
ANGLE_UNIT_KEYS = ["angle", "azimuth"]
33+
SPATIAL_UNIT_KEYS = [
34+
"cdp_x",
35+
"cdp_y",
36+
"source_coord_x",
37+
"source_coord_y",
38+
"group_coord_x",
39+
"group_coord_y",
40+
"offset",
41+
]
42+
43+
44+
def _get_coordinates(
45+
grid: Grid,
46+
segy_headers: SegyHeaderArray,
47+
mdio_template: AbstractDatasetTemplate,
48+
) -> tuple[list[Dimension], dict[str, SegyHeaderArray]]:
49+
"""Get the data dim and non-dim coordinates from the SEG-Y headers and MDIO template.
50+
51+
Select a subset of the segy_dimensions that corresponds to the MDIO dimensions
52+
The dimensions are ordered as in the MDIO template.
53+
The last dimension is always the vertical domain dimension
54+
55+
Args:
56+
grid: Inferred MDIO grid for SEG-Y file.
57+
segy_headers: Headers read in from SEG-Y file.
58+
mdio_template: The MDIO template to use for the conversion.
59+
60+
Returns:
61+
A tuple containing:
62+
- A list of dimension coordinates (1-D arrays).
63+
- A dict of non-dimension coordinates (str: N-D arrays).
64+
"""
65+
dimensions_coords = [grid.select_dim(name) for name in mdio_template.dimension_names]
66+
non_dim_coords = {name: np.array(segy_headers[name]) for name in mdio_template.coordinate_names}
67+
return dimensions_coords, non_dim_coords
68+
69+
70+
def _populate_coordinates(
71+
dataset: xr_Dataset,
72+
grid: Grid,
73+
coords: dict[str, SegyHeaderArray],
74+
spatial_coordinate_scalar: int,
75+
) -> tuple[xr_Dataset, list[str]]:
76+
"""Populate dim and non-dim coordinates in the xarray dataset and write to Zarr.
77+
78+
This will write the xr Dataset with coords and dimensions, but empty traces and headers.
79+
80+
Args:
81+
dataset: The xarray dataset to populate.
82+
grid: The grid object containing the grid map.
83+
coords: The non-dim coordinates to populate.
84+
spatial_coordinate_scalar: The X/Y coordinate scalar from the SEG-Y file.
85+
86+
Returns:
87+
Xarray dataset with filled coordinates and updated variables to drop after writing
88+
"""
89+
drop_vars_delayed = []
90+
dataset, drop_vars_delayed = populate_dim_coordinates(dataset, grid, drop_vars_delayed=drop_vars_delayed)
91+
92+
dataset, drop_vars_delayed = populate_non_dim_coordinates(
93+
dataset,
94+
grid,
95+
coordinates=coords,
96+
drop_vars_delayed=drop_vars_delayed,
97+
spatial_coordinate_scalar=spatial_coordinate_scalar,
98+
)
99+
100+
return dataset, drop_vars_delayed
101+
102+
103+
def _get_spatial_coordinate_unit(segy_file_info: SegyFileInfo) -> LengthUnitModel | None:
104+
"""Get the coordinate unit from the SEG-Y headers."""
105+
measurement_system_code = int(segy_file_info.binary_header_dict[MEASUREMENT_SYSTEM_KEY])
106+
107+
if measurement_system_code not in {s.value for s in SegyMeasurementSystem}:
108+
logger.warning(
109+
"Unexpected value in coordinate unit (%s) header: %s. Can't extract coordinate unit and will "
110+
"ingest without coordinate units.",
111+
MEASUREMENT_SYSTEM_KEY,
112+
measurement_system_code,
113+
)
114+
return None
115+
116+
if measurement_system_code == SegyMeasurementSystem.METERS:
117+
unit = LengthUnitEnum.METER
118+
if measurement_system_code == SegyMeasurementSystem.FEET:
119+
unit = LengthUnitEnum.FOOT
120+
121+
return LengthUnitModel(length=unit)
122+
123+
124+
def _update_template_units(template: AbstractDatasetTemplate, unit: LengthUnitModel | None) -> AbstractDatasetTemplate:
125+
"""Update the template with dynamic and some pre-defined units."""
126+
new_units = {key: AngleUnitModel(angle=AngleUnitEnum.DEGREES) for key in ANGLE_UNIT_KEYS}
127+
128+
if unit is None:
129+
template.add_units(new_units)
130+
return template
131+
132+
for key in SPATIAL_UNIT_KEYS:
133+
current_value = template.get_unit_by_key(key)
134+
if current_value is not None:
135+
logger.warning("Unit for %s already in template. Will keep the original unit: %s", key, current_value)
136+
continue
137+
138+
new_units[key] = unit
139+
140+
template.add_units(new_units)
141+
return template
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Attach SEG-Y text and binary file headers to the dataset."""
2+
3+
from __future__ import annotations
4+
5+
import base64
6+
from typing import TYPE_CHECKING
7+
8+
from mdio.core.config import MDIOSettings
9+
10+
if TYPE_CHECKING:
11+
from xarray import Dataset as xr_Dataset
12+
13+
from mdio.segy.file import SegyFileInfo
14+
15+
16+
def _add_segy_file_headers(xr_dataset: xr_Dataset, segy_file_info: SegyFileInfo) -> xr_Dataset:
17+
"""Attach the SEG-Y text and binary file headers as attrs on a scalar variable."""
18+
settings = MDIOSettings()
19+
20+
if not settings.save_segy_file_header:
21+
return xr_dataset
22+
23+
expected_rows = 40
24+
expected_cols = 80
25+
26+
text_header_rows = segy_file_info.text_header.splitlines()
27+
text_header_cols_bad = [len(row) != expected_cols for row in text_header_rows]
28+
29+
if len(text_header_rows) != expected_rows:
30+
err = f"Invalid text header count: expected {expected_rows}, got {len(segy_file_info.text_header)}"
31+
raise ValueError(err)
32+
33+
if any(text_header_cols_bad):
34+
err = f"Invalid text header columns: expected {expected_cols} per line."
35+
raise ValueError(err)
36+
37+
xr_dataset["segy_file_header"] = ((), "")
38+
xr_dataset["segy_file_header"].attrs.update(
39+
{
40+
"textHeader": segy_file_info.text_header,
41+
"binaryHeader": segy_file_info.binary_header_dict,
42+
}
43+
)
44+
if settings.raw_headers:
45+
raw_binary_base64 = base64.b64encode(segy_file_info.raw_binary_headers).decode("ascii")
46+
xr_dataset["segy_file_header"].attrs.update({"rawBinaryHeader": raw_binary_base64})
47+
48+
return xr_dataset

0 commit comments

Comments
 (0)