Skip to content

Commit 66c3adc

Browse files
committed
Resolve ibm float roundtrip segy issues
1 parent 89f72ee commit 66c3adc

6 files changed

Lines changed: 273 additions & 8 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ dependencies = [
2626
"pydantic>=2.12.0",
2727
"pydantic-settings>=2.6.1",
2828
"rich>=14.1.0",
29-
"segy>=0.5.4",
29+
"segy>=0.6.0",
3030
"tqdm>=4.67.1",
3131
"universal-pathlib>=0.3.3",
3232
"xarray>=2025.10.1",

src/mdio/ingestion/segy/pipeline.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from mdio.api.io import _normalize_path
1212
from mdio.converters.exceptions import GridTraceCountError
13-
from mdio.converters.type_converter import to_structured_type
1413
from mdio.core.grid import Grid
1514
from mdio.ingestion.dataset_factory import build_mdio_dataset
1615
from mdio.ingestion.grid_qc import grid_density_qc
@@ -25,6 +24,7 @@
2524
from mdio.ingestion.segy.validation import validate_spec_in_template
2625
from mdio.segy.file import get_segy_file_info
2726
from mdio.segy.geometry import validate_overrides_for_template
27+
from mdio.segy.utilities import build_mdio_header_type
2828

2929
if TYPE_CHECKING:
3030
from pathlib import Path
@@ -173,7 +173,7 @@ def segy_to_mdio( # noqa: PLR0913
173173

174174
grid = _build_grid(dimensions, indexed_headers, segy_file_info.num_traces)
175175

176-
header_dtype = to_structured_type(segy_spec.trace.header.dtype)
176+
header_dtype = build_mdio_header_type(segy_spec)
177177
extra_variables = build_raw_header_variables(schema)
178178
mdio_ds = build_mdio_dataset(
179179
schema=schema,

src/mdio/segy/utilities.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99
import numpy as np
1010
from dask.array.core import normalize_chunks
11+
from segy.schema import ScalarType as SegyScalarType
12+
13+
from mdio.builder.schemas.dtype import ScalarType as MdioScalarType
14+
from mdio.builder.schemas.dtype import StructuredType
15+
from mdio.converters.type_converter import to_numpy_dtype
16+
from mdio.converters.type_converter import to_structured_type
1117

1218
if TYPE_CHECKING:
1319
from dask.array import Array as DaskArray
@@ -19,6 +25,46 @@
1925
logger = logging.getLogger(__name__)
2026

2127

28+
def ibm32_header_field_names(segy_spec: SegySpec) -> set[str]:
29+
"""Return the names of trace-header fields declared as IBM 32-bit floats.
30+
31+
The segy schema maps an ``ibm32`` header field to a raw ``uint32`` slot (the 4-byte
32+
IBM word) but decodes it to ``float32`` on read. Callers use these names to promote
33+
the affected fields to ``float32`` so the decoded value is stored and projected
34+
without truncating decimals or wrapping the sign of negative values.
35+
36+
Args:
37+
segy_spec: SEG-Y specification whose trace header fields are inspected.
38+
39+
Returns:
40+
Set of header field names whose declared format is ``ibm32``.
41+
"""
42+
return {field.name for field in segy_spec.trace.header.fields if field.format == SegyScalarType.IBM32}
43+
44+
45+
def build_mdio_header_type(segy_spec: SegySpec) -> StructuredType:
46+
"""Build the MDIO ``headers`` variable type from a SegySpec.
47+
48+
``ibm32`` header fields are promoted from their raw ``uint32`` slot to ``float32`` so
49+
the persisted header matches the decoded array the ingestion worker writes. Without
50+
this promotion the decoded float would be cast down to an integer on write, truncating
51+
decimals (``118.625`` -> ``118``) and wrapping signed values (``-50.25`` -> a large
52+
unsigned integer).
53+
54+
Args:
55+
segy_spec: SEG-Y specification describing the trace header layout.
56+
57+
Returns:
58+
The MDIO structured type for the persisted ``headers`` variable.
59+
"""
60+
structured = to_structured_type(segy_spec.trace.header.dtype)
61+
ibm32_names = ibm32_header_field_names(segy_spec)
62+
for field in structured.fields:
63+
if field.name in ibm32_names:
64+
field.format = MdioScalarType.FLOAT32
65+
return structured
66+
67+
2268
def project_headers_to_segy_spec(headers: DaskArray, segy_spec: SegySpec) -> DaskArray:
2369
"""Project stored MDIO trace headers onto the SegySpec trace header layout.
2470
@@ -49,7 +95,10 @@ def project_headers_to_segy_spec(headers: DaskArray, segy_spec: SegySpec) -> Das
4995
)
5096
raise ValueError(msg)
5197

52-
target_dtype = np.dtype([(name, spec_header_dtype.fields[name][0].newbyteorder("=")) for name in target_names])
98+
# The export target must equal the dtype the headers were stored with at ingest, so route
99+
# through the same builder. That keeps ibm32 fields as float32, which flows into
100+
# SegyFactory.create_traces for IBM encoding instead of re-truncating to the raw uint32 slot.
101+
target_dtype = to_numpy_dtype(build_mdio_header_type(segy_spec))
53102

54103
# Don't actually project if the dtype is already the same as the target dtype.
55104
if headers.dtype == target_dtype:
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Round-trip tests for SEG-Y files that declare IBM float (``ibm32``) trace-header fields.
2+
3+
These guard the IBM-real header data path end-to-end through real file I/O:
4+
5+
* Ingestion (Defect A): mdio used to persist an ``ibm32`` header in its raw ``uint32`` slot,
6+
casting the decoded float down to an integer. That truncated decimals (``118.625`` ->
7+
``118``) and wrapped the sign of negatives (``-50.25`` -> a huge unsigned int). The fix
8+
promotes ``ibm32`` header fields to ``float32`` so the decoded value is stored faithfully.
9+
* Export (Defect B): SegyFactory IBM-encodes ``ibm32`` *header* fields from ``segy`` 0.6.0
10+
onward (the minimum mdio requires), so the full round-trip exercises that encode/decode.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import TYPE_CHECKING
16+
17+
import fsspec
18+
import numpy as np
19+
from segy.factory import SegyFactory
20+
from segy.schema import HeaderField
21+
from segy.standards import get_segy_standard
22+
23+
from mdio import mdio_to_segy
24+
from mdio.api.io import open_mdio
25+
from mdio.builder.template_registry import TemplateRegistry
26+
from mdio.converters.segy import segy_to_mdio
27+
from mdio.segy.file import SegyFileWrapper
28+
29+
if TYPE_CHECKING:
30+
from pathlib import Path
31+
32+
import pytest
33+
from segy.schema import SegySpec
34+
35+
# Dyadic rationals chosen so they are exactly representable in IBM hex float, letting us assert
36+
# exact equality. They cover the original failure modes: decimal truncation and sign wrapping.
37+
IBM_HEADER_VALUES = np.array([118.625, -50.25, 0.5, -1.5], dtype="float32")
38+
39+
GRID_INLINES = 3
40+
GRID_CROSSLINES = 4
41+
NUM_SAMPLES = 8
42+
IBM_FIELD_NAME = "ibm_attr"
43+
44+
45+
def _ibm32_segy_spec() -> SegySpec:
46+
"""Build a rev1 SegySpec with an ``ibm32`` trace-header field alongside index/coord fields."""
47+
fields = [
48+
HeaderField(name="inline", byte=189, format="int32"),
49+
HeaderField(name="crossline", byte=193, format="int32"),
50+
HeaderField(name="coordinate_scalar", byte=71, format="int16"),
51+
HeaderField(name="cdp_x", byte=181, format="int32"),
52+
HeaderField(name="cdp_y", byte=185, format="int32"),
53+
HeaderField(name="samples_per_trace", byte=115, format="int16"),
54+
HeaderField(name="sample_interval", byte=117, format="int16"),
55+
# Unassigned rev1 bytes 233-240: safe spot for a custom IBM-float attribute.
56+
HeaderField(name=IBM_FIELD_NAME, byte=233, format="ibm32"),
57+
]
58+
spec = get_segy_standard(1).customize(trace_header_fields=fields)
59+
spec.segy_standard = 1
60+
return spec
61+
62+
63+
def _ibm_values_per_trace(num_traces: int) -> np.ndarray:
64+
"""Tile the sample IBM values to cover every trace."""
65+
reps = int(np.ceil(num_traces / IBM_HEADER_VALUES.size))
66+
return np.tile(IBM_HEADER_VALUES, reps)[:num_traces].astype("float32")
67+
68+
69+
def _write_ibm32_segy(path: Path, spec: SegySpec) -> np.ndarray:
70+
"""Write a small 3D SEG-Y whose ``ibm32`` header holds real IBM-float values.
71+
72+
``segy`` >= 0.6.0 exposes ``ibm32`` header fields as ``float32`` in the factory template and
73+
IBM-encodes them on write, so real float values are assigned directly.
74+
75+
Args:
76+
path: Destination path for the generated SEG-Y file.
77+
spec: SegySpec describing the trace header layout (must declare the ibm32 field).
78+
79+
Returns:
80+
The IBM header value assigned to each trace, in trace order.
81+
"""
82+
num_traces = GRID_INLINES * GRID_CROSSLINES
83+
factory = SegyFactory(spec=spec, samples_per_trace=NUM_SAMPLES)
84+
samples = factory.create_trace_sample_template(num_traces)
85+
headers = factory.create_trace_header_template(num_traces)
86+
87+
inlines, crosslines = np.mgrid[0:GRID_INLINES, 0:GRID_CROSSLINES]
88+
headers["inline"] = (10 + inlines).ravel()
89+
headers["crossline"] = (100 + crosslines * 2).ravel()
90+
headers["coordinate_scalar"] = -100
91+
headers["cdp_x"] = (700_000 + inlines * 100).ravel()
92+
headers["cdp_y"] = (4_000_000 + crosslines * 100).ravel()
93+
headers["samples_per_trace"] = NUM_SAMPLES
94+
headers["sample_interval"] = 4000
95+
96+
ibm_values = _ibm_values_per_trace(num_traces)
97+
headers[IBM_FIELD_NAME] = ibm_values
98+
99+
samples[:] = (np.arange(num_traces) + 1)[:, None]
100+
101+
with fsspec.open(path.as_posix(), mode="wb") as fp:
102+
fp.write(factory.create_textual_header())
103+
fp.write(factory.create_binary_header())
104+
fp.write(factory.create_traces(headers, samples))
105+
106+
return ibm_values
107+
108+
109+
def test_ingested_ibm32_header_preserves_value(tmp_path: Path) -> None:
110+
"""SEG-Y -> MDIO must store ibm32 headers as float32 without truncating or wrapping (Defect A)."""
111+
spec = _ibm32_segy_spec()
112+
segy_path = tmp_path / "ibm32.sgy"
113+
mdio_path = tmp_path / "ibm32.mdio"
114+
115+
expected = _write_ibm32_segy(segy_path, spec)
116+
117+
segy_to_mdio(
118+
segy_spec=spec,
119+
mdio_template=TemplateRegistry().get("PostStack3DTime"),
120+
input_path=segy_path,
121+
output_path=mdio_path,
122+
overwrite=True,
123+
)
124+
125+
headers = open_mdio(mdio_path)["headers"].values
126+
stored = headers[IBM_FIELD_NAME].ravel()
127+
128+
assert stored.dtype == np.float32
129+
np.testing.assert_array_equal(stored, expected)
130+
131+
132+
def test_ibm32_header_full_roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
133+
"""SEG-Y -> MDIO -> SEG-Y must preserve ibm32 header values end-to-end."""
134+
# The SEG-Y file headers must be persisted at ingest so they can be re-emitted on export.
135+
monkeypatch.setenv("MDIO__IMPORT__SAVE_SEGY_FILE_HEADER", "true")
136+
137+
spec = _ibm32_segy_spec()
138+
segy_path = tmp_path / "ibm32.sgy"
139+
mdio_path = tmp_path / "ibm32.mdio"
140+
segy_rt_path = tmp_path / "ibm32_rt.sgy"
141+
142+
expected = _write_ibm32_segy(segy_path, spec)
143+
144+
segy_to_mdio(
145+
segy_spec=spec,
146+
mdio_template=TemplateRegistry().get("PostStack3DTime"),
147+
input_path=segy_path,
148+
output_path=mdio_path,
149+
overwrite=True,
150+
)
151+
mdio_to_segy(segy_spec=spec, input_path=mdio_path, output_path=segy_rt_path)
152+
153+
roundtripped = SegyFileWrapper(segy_rt_path.as_posix(), spec=spec).trace[:].header[IBM_FIELD_NAME]
154+
np.testing.assert_array_equal(roundtripped, expected)

tests/unit/test_segy_header_projection.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
from segy.schema import TraceSpec
1313
from segy.standards import get_segy_standard
1414

15+
from mdio.builder.schemas.dtype import ScalarType as MdioScalarType
16+
from mdio.segy.utilities import build_mdio_header_type
17+
from mdio.segy.utilities import ibm32_header_field_names
1518
from mdio.segy.utilities import project_headers_to_segy_spec
1619

1720

@@ -162,6 +165,31 @@ def test_projection_independent_of_mdio_field_order(self) -> None:
162165
for name in spec.trace.header.names:
163166
np.testing.assert_array_equal(a[name], b[name])
164167

168+
def test_ibm32_field_projected_as_float32_preserves_decimals_and_sign(self) -> None:
169+
"""An ibm32 header stored as float32 must project to float32, not the raw uint32 slot.
170+
171+
Casting to the raw uint32 slot would truncate decimals and wrap negatives, which is the
172+
ingestion/export corruption (Defect A) this guards against.
173+
"""
174+
num = 4
175+
mdio_dtype = np.dtype([("inline", "<i4"), ("elevation", "<f4")])
176+
headers_np = np.zeros(num, dtype=mdio_dtype)
177+
headers_np["inline"] = np.arange(num, dtype=np.int32)
178+
headers_np["elevation"] = np.array([118.625, -50.25, 0.5, -1.5], dtype=np.float32)
179+
headers = da.from_array(headers_np, chunks=2)
180+
181+
spec = _make_segy_spec(
182+
[
183+
HeaderField(name="inline", byte=189, format="int32"),
184+
HeaderField(name="elevation", byte=193, format="ibm32"),
185+
]
186+
)
187+
188+
projected = project_headers_to_segy_spec(headers, spec).compute()
189+
190+
assert projected.dtype.fields["elevation"][0] == np.dtype("float32")
191+
np.testing.assert_array_equal(projected["elevation"], np.array([118.625, -50.25, 0.5, -1.5], dtype=np.float32))
192+
165193
def test_short_circuit_on_matching_dtype(self) -> None:
166194
"""If source headers dtype exactly matches target dtype, return array unchanged."""
167195
spec = _make_segy_spec(
@@ -188,3 +216,37 @@ def test_short_circuit_on_matching_dtype(self) -> None:
188216

189217
# The exact same dask array object should be returned (no map_blocks overhead)
190218
assert projected_da is headers_da
219+
220+
221+
class TestBuildMdioHeaderType:
222+
"""Cases covering the ingestion-side MDIO header type derived from a SegySpec."""
223+
224+
def test_ibm32_header_promoted_to_float32(self) -> None:
225+
"""An ibm32 header field must be stored as float32, not the raw uint32 slot."""
226+
spec = _make_segy_spec(
227+
[
228+
HeaderField(name="inline", byte=189, format="int32"),
229+
HeaderField(name="elevation", byte=193, format="ibm32"),
230+
]
231+
)
232+
233+
header_type = build_mdio_header_type(spec)
234+
formats = {field.name: field.format for field in header_type.fields}
235+
236+
assert formats["inline"] == MdioScalarType.INT32
237+
assert formats["elevation"] == MdioScalarType.FLOAT32
238+
239+
def test_non_ibm32_spec_unchanged(self) -> None:
240+
"""Specs without ibm32 fields keep their original scalar types."""
241+
spec = _make_segy_spec(
242+
[
243+
HeaderField(name="inline", byte=189, format="int32"),
244+
HeaderField(name="crossline", byte=193, format="int16"),
245+
]
246+
)
247+
248+
header_type = build_mdio_header_type(spec)
249+
formats = {field.name: field.format for field in header_type.fields}
250+
251+
assert formats == {"inline": MdioScalarType.INT32, "crossline": MdioScalarType.INT16}
252+
assert ibm32_header_field_names(spec) == set()

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)