Skip to content

Commit ae00a8c

Browse files
committed
Implement new header analysis utilities and enhance ingestion pipeline
- Introduced `header_analysis.py` for analyzing SEG-Y headers to determine geometry and create indices for various acquisition types. - Added functions to analyze streamer and gun geometries, improving the handling of multi-gun acquisitions. - Refactored the ingestion pipeline to utilize the new header analysis utilities, enhancing the schema resolution and index strategy phases. - Updated the `run_segy_ingestion` function to apply resolved dimensions and chunk shapes more effectively, ensuring compatibility with grid overrides.
1 parent 8e68d59 commit ae00a8c

15 files changed

Lines changed: 1097 additions & 564 deletions

src/mdio/builder/templates/base.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from mdio.builder.schemas.dtype import StructuredType
1818
from mdio.builder.schemas.v1.units import AllUnitModel
1919
from mdio.builder.schemas.v1.variable import VariableMetadata
20-
from mdio.builder.schemas.v1.variable import VariableMetadata
2120
from mdio.core.utils_write import MAX_COORDINATES_BYTES
2221
from mdio.core.utils_write import MAX_SIZE_LIVE_MASK
2322
from mdio.core.utils_write import get_constrained_chunksize
@@ -144,6 +143,30 @@ def add_units(self, units: dict[str, AllUnitModel]) -> None:
144143
raise ValueError(msg)
145144
self._units |= units
146145

146+
def apply_resolved_dimensions(
147+
self,
148+
dim_names: tuple[str, ...],
149+
chunk_shape: tuple[int, ...],
150+
) -> None:
151+
"""Update the template's dimension layout from a resolved schema.
152+
153+
Supported entry point for the ingestion pipeline to push back dimension names
154+
and chunk shape after the SchemaResolver has applied grid overrides
155+
(e.g. NonBinned, HasDuplicates), instead of mutating private attributes.
156+
157+
Args:
158+
dim_names: Final ordered dimension names.
159+
chunk_shape: Chunk shape matching ``dim_names`` length.
160+
161+
Raises:
162+
ValueError: If ``len(chunk_shape) != len(dim_names)``.
163+
"""
164+
if len(chunk_shape) != len(dim_names):
165+
msg = f"chunk_shape length {len(chunk_shape)} does not match dim_names length {len(dim_names)}"
166+
raise ValueError(msg)
167+
self._dim_names = tuple(dim_names)
168+
self._var_chunk_shape = tuple(chunk_shape)
169+
147170
@property
148171
def name(self) -> str:
149172
"""Returns the name of the template."""
@@ -191,25 +214,22 @@ def coordinate_names(self) -> tuple[str, ...]:
191214

192215
@property
193216
def full_chunk_shape(self) -> tuple[int, ...]:
194-
"""Returns the chunk shape for the variables."""
195-
# If dimension sizes are not set yet, return the stored shape as-is
217+
"""Returns the chunk shape for the variables, expanding -1 to the full dim size."""
196218
if len(self._dim_sizes) != len(self._dim_names):
197219
return self._var_chunk_shape
198220

199-
# Expand -1 values to full dimension sizes
200221
return tuple(
201222
dim_size if chunk_size == -1 else chunk_size
202223
for chunk_size, dim_size in zip(self._var_chunk_shape, self._dim_sizes, strict=False)
203224
)
204225

205226
@full_chunk_shape.setter
206227
def full_chunk_shape(self, shape: tuple[int, ...]) -> None:
207-
"""Sets the chunk shape for the variables."""
228+
"""Sets the chunk shape; values must be positive ints or -1 (means full dim size)."""
208229
if len(shape) != len(self._dim_names):
209230
msg = f"Chunk shape {shape} has {len(shape)} dimensions, expected {len(self._dim_names)}"
210231
raise ValueError(msg)
211232

212-
# Validate that all values are positive integers or -1
213233
for chunk_size in shape:
214234
if chunk_size != -1 and chunk_size <= 0:
215235
msg = f"Chunk size must be positive integer or -1, got {chunk_size}"

src/mdio/converters/segy.py

Lines changed: 18 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,80 +2,24 @@
22

33
from __future__ import annotations
44

5-
import base64
6-
import logging
5+
import warnings
76
from typing import TYPE_CHECKING
87

9-
import numpy as np
10-
import zarr
11-
from segy.config import SegyFileSettings
12-
from segy.config import SegyHeaderOverrides
13-
from segy.standards.codes import MeasurementSystem as SegyMeasurementSystem
14-
from segy.standards.fields import binary as binary_header_fields
15-
16-
from mdio.api.io import _normalize_path
17-
from mdio.api.io import to_mdio
18-
from mdio.builder.schemas.chunk_grid import RegularChunkGrid
19-
from mdio.builder.schemas.chunk_grid import RegularChunkShape
20-
from mdio.builder.schemas.compressors import Blosc
21-
from mdio.builder.schemas.compressors import BloscCname
22-
from mdio.builder.schemas.dtype import ScalarType
23-
from mdio.builder.schemas.v1.units import AngleUnitEnum
24-
from mdio.builder.schemas.v1.units import AngleUnitModel
25-
from mdio.builder.schemas.v1.units import LengthUnitEnum
26-
from mdio.builder.schemas.v1.units import LengthUnitModel
27-
from mdio.builder.schemas.v1.variable import VariableMetadata
28-
from mdio.builder.xarray_builder import to_xarray_dataset
29-
from mdio.constants import ZarrFormat
30-
from mdio.converters.exceptions import GridTraceCountError
31-
from mdio.converters.exceptions import GridTraceSparsityError
32-
from mdio.converters.type_converter import to_structured_type
33-
from mdio.core.config import MDIOSettings
34-
from mdio.core.grid import Grid
35-
from mdio.core.utils_write import MAX_COORDINATES_BYTES
36-
from mdio.core.utils_write import MAX_SIZE_LIVE_MASK
37-
from mdio.core.utils_write import get_constrained_chunksize
38-
from mdio.segy import blocked_io
39-
from mdio.segy.file import get_segy_file_info
8+
from mdio.ingestion.pipeline import run_segy_ingestion
409
from mdio.segy.geometry import GridOverrides
41-
from mdio.segy.scalar import SCALE_COORDINATE_KEYS
42-
from mdio.segy.scalar import _apply_coordinate_scalar
43-
from mdio.segy.utilities import get_grid_plan
4410

4511
if TYPE_CHECKING:
4612
from pathlib import Path
4713
from typing import Any
4814

49-
from segy.arrays import HeaderArray as SegyHeaderArray
15+
from segy.config import SegyHeaderOverrides
5016
from segy.schema import SegySpec
5117
from upath import UPath
52-
from xarray import Dataset as xr_Dataset
5318

54-
from mdio.builder.schemas import Dataset
5519
from mdio.builder.templates.base import AbstractDatasetTemplate
56-
from mdio.core.dimension import Dimension
57-
from mdio.segy.file import SegyFileArguments
58-
from mdio.segy.file import SegyFileInfo
59-
60-
logger = logging.getLogger(__name__)
61-
62-
63-
MEASUREMENT_SYSTEM_KEY = binary_header_fields.Rev0.MEASUREMENT_SYSTEM_CODE.model.name
64-
ANGLE_UNIT_KEYS = ["angle", "azimuth"]
65-
SPATIAL_UNIT_KEYS = [
66-
"cdp_x",
67-
"cdp_y",
68-
"source_coord_x",
69-
"source_coord_y",
70-
"group_coord_x",
71-
"group_coord_y",
72-
"offset",
73-
]
7420

7521

76-
77-
78-
def segy_to_mdio( # noqa PLR0913
22+
def segy_to_mdio( # noqa: PLR0913
7923
segy_spec: SegySpec,
8024
mdio_template: AbstractDatasetTemplate,
8125
input_path: UPath | Path | str,
@@ -84,30 +28,33 @@ def segy_to_mdio( # noqa PLR0913
8428
grid_overrides: GridOverrides | dict[str, Any] | None = None,
8529
segy_header_overrides: SegyHeaderOverrides | None = None,
8630
) -> None:
87-
"""A function that converts a SEG-Y file to an MDIO v1 file.
31+
"""Convert a SEG-Y file to an MDIO v1 file.
8832
89-
Ingest a SEG-Y file according to the segy_spec. This could be a spec from registry or custom.
33+
This is the v1.x public entry point. Internally it dispatches to the v1.2
34+
refactored ingestion pipeline (:func:`mdio.ingestion.pipeline.run_segy_ingestion`).
9035
9136
Args:
9237
segy_spec: The SEG-Y specification to use for the conversion.
9338
mdio_template: The MDIO template to use for the conversion.
9439
input_path: The universal path of the input SEG-Y file.
9540
output_path: The universal path for the output MDIO v1 file.
9641
overwrite: Whether to overwrite the output file if it already exists. Defaults to False.
97-
grid_overrides: Grid override configuration. Can be a GridOverrides instance for type
98-
safety, or a dict for backward compatibility. See GridOverrides class for available
99-
options.
42+
grid_overrides: Grid override configuration. Prefer a typed
43+
:class:`mdio.GridOverrides` instance. A ``dict`` is still accepted for backward
44+
compatibility but will emit a :class:`DeprecationWarning` and be coerced.
10045
segy_header_overrides: Option to override specific SEG-Y headers during ingestion.
10146
10247
Raises:
103-
FileExistsError: If the output location already exists and overwrite is False.
48+
FileExistsError: If the output location already exists and ``overwrite`` is False.
10449
"""
105-
# Convert dict to GridOverrides if needed for type safety
10650
if isinstance(grid_overrides, dict):
107-
grid_overrides = GridOverrides.model_validate(grid_overrides) if grid_overrides else None
108-
109-
# Use ingestion pipeline
110-
from mdio.ingestion.pipeline import run_segy_ingestion
51+
warnings.warn(
52+
"Passing 'grid_overrides' as a dict is deprecated and will be removed in a future "
53+
"release. Use 'mdio.GridOverrides(...)' (snake_case fields or legacy aliases both work).",
54+
DeprecationWarning,
55+
stacklevel=2,
56+
)
57+
grid_overrides = GridOverrides.from_legacy_dict(grid_overrides) if grid_overrides else None
11158

11259
return run_segy_ingestion(
11360
segy_spec=segy_spec,

src/mdio/ingestion/coordinate_utils.py

Lines changed: 11 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
"""Coordinate handling utilities for MDIO ingestion.
2-
3-
This module contains functions for extracting, populating, and managing
4-
coordinates during SEG-Y to MDIO conversion.
5-
"""
1+
"""Coordinate handling utilities for MDIO ingestion."""
62

73
from __future__ import annotations
84

@@ -32,7 +28,6 @@
3228
logger = logging.getLogger(__name__)
3329

3430

35-
# Constants for unit handling
3631
MEASUREMENT_SYSTEM_KEY = binary_header_fields.Rev0.MEASUREMENT_SYSTEM_CODE.model.name
3732
ANGLE_UNIT_KEYS = ["angle", "azimuth"]
3833
SPATIAL_UNIT_KEYS = [
@@ -53,7 +48,6 @@ def get_coordinates(
5348
) -> tuple[list[Dimension], dict[str, SegyHeaderArray]]:
5449
"""Get the data dim and non-dim coordinates from the SEG-Y headers and MDIO template.
5550
56-
Select a subset of the segy_dimensions that corresponds to the MDIO dimensions.
5751
Uses the grid's actual dimensions (which may have been transformed by grid overrides).
5852
The last dimension is always the vertical domain dimension.
5953
@@ -67,20 +61,16 @@ def get_coordinates(
6761
the SEG-Y headers.
6862
6963
Returns:
70-
A tuple containing:
71-
- A list of dimension coordinates (1-D arrays).
72-
- A dict of non-dimension coordinates (str: N-D arrays).
64+
A tuple of (dimension coordinates as 1-D arrays, non-dimension coordinates as N-D arrays).
7365
"""
74-
# Use grid's actual dimensions (may differ from template after grid overrides)
7566
dimensions_coords = list(grid.dims)
7667

77-
# Extract non-dimension coordinates from headers
7868
non_dim_coords: dict[str, SegyHeaderArray] = {}
7969
for coord_name in mdio_template.coordinate_names:
8070
if coord_name not in segy_headers.dtype.names:
8171
err = f"Coordinate '{coord_name}' not found in SEG-Y headers."
8272
raise ValueError(err)
83-
# Copy the data to allow segy_headers to be garbage collected
73+
# Copy so segy_headers can be garbage collected.
8474
non_dim_coords[coord_name] = np.array(segy_headers[coord_name])
8575

8676
return dimensions_coords, non_dim_coords
@@ -103,52 +93,37 @@ def populate_non_dim_coordinates(
10393
drop_vars_delayed: list[str],
10494
spatial_coordinate_scalar: int,
10595
) -> tuple[xr_Dataset, list[str]]:
106-
"""Populate the xarray dataset with coordinate variables.
96+
"""Populate the xarray dataset with non-dimension coordinate variables.
10797
108-
Memory optimization: Processes coordinates one at a time and explicitly
109-
releases intermediate arrays to reduce peak memory usage.
98+
Coordinates are processed one at a time and intermediate arrays are released
99+
explicitly to keep peak memory low for large surveys.
110100
"""
111-
non_data_domain_dims = grid.dim_names[:-1] # minus the data domain dimension
101+
non_data_domain_dims = grid.dim_names[:-1]
112102

113-
# Process coordinates one at a time to minimize peak memory
114-
coord_names = list(coordinates.keys())
115-
for coord_name in coord_names:
116-
coord_values = coordinates.pop(coord_name) # Remove from dict to free memory
103+
for coord_name in list(coordinates.keys()):
104+
coord_values = coordinates.pop(coord_name)
117105
da_coord = dataset[coord_name]
118106

119-
# Get coordinate shape from dataset (uses dask shape, no memory allocation)
120-
coord_shape = da_coord.shape
121-
122-
# Create output array with fill value
123107
fill_value = da_coord.encoding.get("_FillValue") or da_coord.encoding.get("fill_value")
124108
if fill_value is None:
125109
fill_value = np.nan
126-
tmp_coord_values = np.full(coord_shape, fill_value, dtype=da_coord.dtype)
110+
tmp_coord_values = np.full(da_coord.shape, fill_value, dtype=da_coord.dtype)
127111

128-
# Compute slices for this coordinate's dimensions
129112
coord_axes = tuple(non_data_domain_dims.index(coord_dim) for coord_dim in da_coord.dims)
130113
coord_slices = tuple(slice(None) if idx in coord_axes else 0 for idx in range(len(non_data_domain_dims)))
131114

132-
# Read only the required slice from grid map
133115
coord_trace_indices = np.asarray(grid.map[coord_slices])
134-
135-
# Find valid (non-null) indices
136116
not_null = coord_trace_indices != grid.map.fill_value
137117

138-
# Populate values efficiently
139118
if not_null.any():
140-
valid_indices = coord_trace_indices[not_null]
141-
tmp_coord_values[not_null] = coord_values[valid_indices]
119+
tmp_coord_values[not_null] = coord_values[coord_trace_indices[not_null]]
142120

143-
# Apply scalar if needed
144121
if coord_name in SCALE_COORDINATE_KEYS:
145122
tmp_coord_values = _apply_coordinate_scalar(tmp_coord_values, spatial_coordinate_scalar)
146123

147-
# Assign to dataset
148124
dataset[coord_name][:] = tmp_coord_values
149125
drop_vars_delayed.append(coord_name)
150126

151-
# Explicitly release intermediate arrays
152127
del tmp_coord_values, coord_trace_indices, not_null, coord_values
153128

154129
return dataset, drop_vars_delayed
@@ -178,22 +153,17 @@ def get_spatial_coordinate_unit(segy_file_info: SegyFileInfo) -> LengthUnitModel
178153
def apply_runtime_units(template: AbstractDatasetTemplate, segy_file_info: SegyFileInfo) -> AbstractDatasetTemplate:
179154
"""Update the template with dynamic and some pre-defined units."""
180155
unit = get_spatial_coordinate_unit(segy_file_info)
181-
182-
# Add units for pre-defined: angle and azimuth etc.
183156
new_units = {key: AngleUnitModel(angle=AngleUnitEnum.DEGREES) for key in ANGLE_UNIT_KEYS}
184157

185-
# If a spatial unit is not provided, we return as is
186158
if unit is None:
187159
template.add_units(new_units)
188160
return template
189161

190-
# Dynamically add units based on the spatial coordinate unit
191162
for key in SPATIAL_UNIT_KEYS:
192163
current_value = template.get_unit_by_key(key)
193164
if current_value is not None:
194165
logger.warning("Unit for %s already in template. Will keep the original unit: %s", key, current_value)
195166
continue
196-
197167
new_units[key] = unit
198168

199169
template.add_units(new_units)

0 commit comments

Comments
 (0)