Skip to content

Commit 8260464

Browse files
committed
Modularize segy ingestion code
1 parent 607406f commit 8260464

12 files changed

Lines changed: 702 additions & 621 deletions

File tree

src/mdio/converters/segy.py

Lines changed: 10 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: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
Raises:
61+
ValueError: If a dimension or coordinate name from the MDIO template is not found in
62+
the SEG-Y headers.
63+
64+
Returns:
65+
A tuple containing:
66+
- A list of dimension coordinates (1-D arrays).
67+
- A dict of non-dimension coordinates (str: N-D arrays).
68+
"""
69+
dimensions_coords = []
70+
for dim_name in mdio_template.dimension_names:
71+
if dim_name not in grid.dim_names:
72+
err = f"Dimension '{dim_name}' was not found in SEG-Y dimensions."
73+
raise ValueError(err)
74+
dimensions_coords.append(grid.select_dim(dim_name))
75+
76+
non_dim_coords: dict[str, SegyHeaderArray] = {}
77+
for coord_name in mdio_template.coordinate_names:
78+
if coord_name not in segy_headers.dtype.names:
79+
err = f"Coordinate '{coord_name}' not found in SEG-Y dimensions."
80+
raise ValueError(err)
81+
non_dim_coords[coord_name] = np.array(segy_headers[coord_name])
82+
83+
return dimensions_coords, non_dim_coords
84+
85+
86+
def _populate_coordinates(
87+
dataset: xr_Dataset,
88+
grid: Grid,
89+
coords: dict[str, SegyHeaderArray],
90+
spatial_coordinate_scalar: int,
91+
) -> tuple[xr_Dataset, list[str]]:
92+
"""Populate dim and non-dim coordinates in the xarray dataset and write to Zarr.
93+
94+
This will write the xr Dataset with coords and dimensions, but empty traces and headers.
95+
96+
Args:
97+
dataset: The xarray dataset to populate.
98+
grid: The grid object containing the grid map.
99+
coords: The non-dim coordinates to populate.
100+
spatial_coordinate_scalar: The X/Y coordinate scalar from the SEG-Y file.
101+
102+
Returns:
103+
Xarray dataset with filled coordinates and updated variables to drop after writing
104+
"""
105+
drop_vars_delayed = []
106+
dataset, drop_vars_delayed = populate_dim_coordinates(dataset, grid, drop_vars_delayed=drop_vars_delayed)
107+
108+
dataset, drop_vars_delayed = populate_non_dim_coordinates(
109+
dataset,
110+
grid,
111+
coordinates=coords,
112+
drop_vars_delayed=drop_vars_delayed,
113+
spatial_coordinate_scalar=spatial_coordinate_scalar,
114+
)
115+
116+
return dataset, drop_vars_delayed
117+
118+
119+
def _get_spatial_coordinate_unit(segy_file_info: SegyFileInfo) -> LengthUnitModel | None:
120+
"""Get the coordinate unit from the SEG-Y headers."""
121+
measurement_system_code = int(segy_file_info.binary_header_dict[MEASUREMENT_SYSTEM_KEY])
122+
123+
if measurement_system_code not in (1, 2):
124+
logger.warning(
125+
"Unexpected value in coordinate unit (%s) header: %s. Can't extract coordinate unit and will "
126+
"ingest without coordinate units.",
127+
MEASUREMENT_SYSTEM_KEY,
128+
measurement_system_code,
129+
)
130+
return None
131+
132+
if measurement_system_code == SegyMeasurementSystem.METERS:
133+
unit = LengthUnitEnum.METER
134+
if measurement_system_code == SegyMeasurementSystem.FEET:
135+
unit = LengthUnitEnum.FOOT
136+
137+
return LengthUnitModel(length=unit)
138+
139+
140+
def _update_template_units(template: AbstractDatasetTemplate, unit: LengthUnitModel | None) -> AbstractDatasetTemplate:
141+
"""Update the template with dynamic and some pre-defined units."""
142+
new_units = {key: AngleUnitModel(angle=AngleUnitEnum.DEGREES) for key in ANGLE_UNIT_KEYS}
143+
144+
if unit is None:
145+
template.add_units(new_units)
146+
return template
147+
148+
for key in SPATIAL_UNIT_KEYS:
149+
current_value = template.get_unit_by_key(key)
150+
if current_value is not None:
151+
logger.warning("Unit for %s already in template. Will keep the original unit: %s", key, current_value)
152+
continue
153+
154+
new_units[key] = unit
155+
156+
template.add_units(new_units)
157+
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)