Skip to content

Commit 5b7e8ce

Browse files
committed
Re-implement base checksum requirements
1 parent 88d178d commit 5b7e8ce

7 files changed

Lines changed: 346 additions & 22 deletions

File tree

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ classifiers = [
2020
dependencies = [
2121
"click>=8.3.0",
2222
"click-params>=0.5.0",
23+
"crc32c_dist_rs",
2324
"dask>=2025.9.1",
2425
"fsspec>=2025.9.0",
26+
"google-crc32c>=1.5.0",
2527
"pint>=0.25.0",
2628
"psutil>=7.1.0",
2729
"pydantic>=2.12.0",
@@ -37,6 +39,7 @@ dependencies = [
3739
cloud = ["s3fs>=2025.9.0", "gcsfs>=2025.9.0", "adlfs>=2025.8.0"]
3840
distributed = ["distributed>=2025.9.1", "bokeh>=3.8.0"]
3941
lossy = ["zfpy>=1.0.1"]
42+
performance = ["numba>=0.60.0"]
4043

4144
[project.urls]
4245
homepage = "https://mdio.dev/"
@@ -77,6 +80,9 @@ docs = [
7780
[tool.uv]
7881
required-version = ">=0.8.17"
7982

83+
[tool.uv.sources]
84+
crc32c_dist_rs = { path = "/home/brian_michell_tgs_com/source/crc32c_dist_rs/target/wheels/crc32c_dist_rs-0.1.4-cp313-cp313-manylinux_2_34_x86_64.whl" }
85+
8086
[tool.ruff]
8187
target-version = "py311"
8288
src = ["src"]
@@ -196,4 +202,4 @@ module-name = "mdio"
196202

197203
[build-system]
198204
requires = ["uv_build>=0.8.17,<0.9.0"]
199-
build-backend = "uv_build"
205+
build-backend = "uv_build"

src/mdio/converters/segy.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
from segy.config import SegyHeaderOverrides
1414
from segy.standards.codes import MeasurementSystem as SegyMeasurementSystem
1515
from segy.standards.fields import binary as binary_header_fields
16+
from zarr import open_group as zarr_open_group
1617

1718
from mdio.api.io import _normalize_path
19+
from mdio.api.io import _normalize_storage_options
1820
from mdio.api.io import to_mdio
1921
from mdio.builder.schemas.chunk_grid import RegularChunkGrid
2022
from mdio.builder.schemas.chunk_grid import RegularChunkShape
@@ -140,29 +142,38 @@ def _scan_for_headers(
140142
segy_file_info: SegyFileInfo,
141143
template: AbstractDatasetTemplate,
142144
grid_overrides: dict[str, Any] | None = None,
143-
) -> tuple[list[Dimension], SegyHeaderArray]:
145+
calculate_checksum: bool = False,
146+
) -> tuple[list[Dimension], SegyHeaderArray, int | None]:
144147
"""Extract trace dimensions and index headers from the SEG-Y file.
145148
146149
This is an expensive operation.
147150
It scans the SEG-Y file in chunks by using ProcessPoolExecutor
151+
Optionally calculates CRC32C checksum for all trace data during scanning.
148152
"""
149153
full_chunk_size = template.full_chunk_size
150-
segy_dimensions, chunk_size, segy_headers = get_grid_plan(
154+
grid_plan_result = get_grid_plan(
151155
segy_file_kwargs=segy_file_kwargs,
152156
segy_file_info=segy_file_info,
153157
return_headers=True,
154158
template=template,
155159
chunksize=full_chunk_size,
156160
grid_overrides=grid_overrides,
161+
calculate_checksum=calculate_checksum,
157162
)
163+
164+
if calculate_checksum:
165+
segy_dimensions, chunk_size, segy_headers, trace_data_crc32c = grid_plan_result
166+
else:
167+
segy_dimensions, chunk_size, segy_headers = grid_plan_result
168+
trace_data_crc32c = None
158169
if full_chunk_size != chunk_size:
159170
# TODO(Dmitriy): implement grid overrides
160171
# https://github.com/TGSAI/mdio-python/issues/585
161172
# The returned 'chunksize' is used only for grid_overrides. We will need to use it when full
162173
# support for grid overrides is implemented
163174
err = "Support for changing full_chunk_size in grid overrides is not yet implemented"
164175
raise NotImplementedError(err)
165-
return segy_dimensions, segy_headers
176+
return segy_dimensions, segy_headers, trace_data_crc32c
166177

167178

168179
def _build_and_check_grid(
@@ -518,11 +529,15 @@ def segy_to_mdio( # noqa PLR0913
518529
}
519530
segy_file_info = get_segy_file_info(segy_file_kwargs)
520531

521-
segy_dimensions, segy_headers = _scan_for_headers(
532+
# Only calculate CRC32C when raw headers are enabled
533+
calculate_checksum = os.getenv("MDIO__IMPORT__RAW_HEADERS") in ("1", "true", "yes", "on")
534+
535+
segy_dimensions, segy_headers, trace_data_crc32c = _scan_for_headers(
522536
segy_file_kwargs,
523537
segy_file_info,
524538
template=mdio_template,
525539
grid_overrides=grid_overrides,
540+
calculate_checksum=calculate_checksum,
526541
)
527542
grid = _build_and_check_grid(segy_dimensions, segy_file_info, segy_headers)
528543

@@ -580,10 +595,27 @@ def segy_to_mdio( # noqa PLR0913
580595
default_variable_name = mdio_template.default_variable_name
581596
# This is an memory-expensive and time-consuming read-write operation
582597
# performed in chunks to save the memory
598+
# NOTE: trace_data_crc32c was already calculated during header scanning phase
583599
blocked_io.to_zarr(
584600
segy_file_kwargs=segy_file_kwargs,
585601
output_path=output_path,
586602
grid_map=grid.map,
587603
dataset=xr_dataset,
588604
data_variable_name=default_variable_name,
589605
)
606+
607+
# Store the final CRC32C checksum in the Zarr store attributes only when calculated
608+
if trace_data_crc32c is not None:
609+
# The trace_data_crc32c is now the full file CRC32C from DistributedCRC32C
610+
final_crc32c = trace_data_crc32c
611+
612+
storage_options = _normalize_storage_options(output_path)
613+
zarr_group = zarr_open_group(output_path.as_posix(), mode="a", storage_options=storage_options)
614+
zarr_group.attrs.update(
615+
{
616+
"segy_input_crc32c": final_crc32c, # Store as integer, not hex string
617+
"crc32c_algorithm": "CRC32C",
618+
"checksum_scope": "full_file",
619+
"checksum_library": "google-crc32c",
620+
}
621+
)

src/mdio/segy/_workers.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ def header_scan_worker(
3232
segy_file_kwargs: SegyFileArguments,
3333
trace_range: tuple[int, int],
3434
subset: list[str] | None = None,
35-
) -> HeaderArray:
36-
"""Header scan worker.
35+
calculate_checksum: bool = False,
36+
) -> HeaderArray | tuple[HeaderArray, tuple[int, int, int]]:
37+
"""Header scan worker with optional checksum calculation.
3738
3839
If SegyFile is not open, it can either accept a path string or a handle that was opened in
3940
a different context manager.
@@ -42,9 +43,11 @@ def header_scan_worker(
4243
segy_file_kwargs: Arguments to open SegyFile instance.
4344
trace_range: Tuple consisting of the trace ranges to read.
4445
subset: List of header names to filter and keep.
46+
calculate_checksum: If True, also calculate CRC32C for this trace range.
4547
4648
Returns:
47-
HeaderArray parsed from SEG-Y library.
49+
HeaderArray if calculate_checksum is False, otherwise tuple of (HeaderArray, checksum_info)
50+
where checksum_info is (byte_offset, crc32c, byte_length).
4851
"""
4952
segy_file = SegyFileWrapper(**segy_file_kwargs)
5053

@@ -70,7 +73,27 @@ def header_scan_worker(
7073
# (singleton) so we can concat and assign stuff later.
7174
trace_header = np.array(trace_header, dtype=new_dtype, ndmin=1)
7275

73-
return HeaderArray(trace_header) # wrap back so we can use aliases
76+
if not calculate_checksum:
77+
return HeaderArray(trace_header) # wrap back so we can use aliases
78+
79+
# Calculate checksum from the raw bytes ALREADY IN MEMORY (NO ADDITIONAL I/O!)
80+
raw_bytes = traces.trace_buffer_array.tobytes()
81+
crc = google_crc32c.Checksum(raw_bytes)
82+
partial_crc32c = int.from_bytes(crc.digest(), byteorder="big")
83+
84+
# Calculate byte offset and length
85+
trace_header_size = segy_file.spec.trace.header.itemsize
86+
# sample_size = segy_file.spec.trace.sample.itemsize
87+
sample_size = 4 # This will always be a 4-byte float
88+
num_samples = len(segy_file.sample_labels)
89+
trace_size = trace_header_size + (num_samples * sample_size)
90+
91+
byte_offset = 3600 + start_trace * trace_size
92+
byte_length = len(raw_bytes)
93+
94+
checksum_info = (byte_offset, partial_crc32c, byte_length)
95+
96+
return HeaderArray(trace_header), checksum_info
7497

7598

7699
def trace_worker( # noqa: PLR0913

src/mdio/segy/parsers.py

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
from typing import TYPE_CHECKING
1111

1212
import numpy as np
13+
from crc32c_dist_rs import DistributedCRC32C
1314
from psutil import cpu_count
15+
from segy import SegyFile
1416
from tqdm.auto import tqdm
17+
from upath import UPath
1518

1619
from mdio.segy._workers import header_scan_worker
1720

@@ -23,13 +26,32 @@
2326
default_cpus = cpu_count(logical=True)
2427

2528

26-
def parse_headers(
29+
def _finalize_checksum(
30+
results: list[tuple[HeaderArray, tuple[int, int, int]]], combiner: DistributedCRC32C
31+
) -> tuple[HeaderArray, int]:
32+
"""Finalize the checksum from the results of the header scan."""
33+
headers: list[HeaderArray] = []
34+
for result in results:
35+
headers.append(result[0])
36+
byte_offset, partial_crc, byte_length = result[1]
37+
combiner.add_fragment(byte_offset, byte_length, partial_crc)
38+
combined_crc = combiner.try_finalize()
39+
if combined_crc is None:
40+
msg = "Failed to finalize CRC32C - file may not be fully covered"
41+
raise ValueError(msg)
42+
43+
# Merge headers and return with checksum
44+
return np.concatenate(headers), combined_crc
45+
46+
47+
def parse_headers( # noqa: PLR0913
2748
segy_file_kwargs: SegyFileArguments,
2849
num_traces: int,
2950
subset: list[str] | None = None,
3051
block_size: int = 10000,
3152
progress_bar: bool = True,
32-
) -> HeaderArray:
53+
calculate_checksum: bool = True,
54+
) -> HeaderArray | tuple[HeaderArray, int]:
3355
"""Read and parse given `byte_locations` from SEG-Y file.
3456
3557
Args:
@@ -38,11 +60,32 @@ def parse_headers(
3860
subset: List of header names to filter and keep.
3961
block_size: Number of traces to read for each block.
4062
progress_bar: Enable or disable progress bar. Default is True.
63+
calculate_checksum: If True, also calculate CRC32C checksum for all trace data.
4164
4265
Returns:
43-
HeaderArray. Keys are the index names, values are numpy arrays of parsed headers for the
44-
current block. Array is of type byte_type except IBM32 which is mapped to FLOAT32.
66+
HeaderArray if calculate_checksum is False.
67+
Tuple of (HeaderArray, combined_crc32c) if calculate_checksum is True.
4568
"""
69+
# Use UPath for cloud/filesystem agnostic reading
70+
path = UPath(segy_file_kwargs["url"])
71+
raw_bytes = path.fs.read_block(
72+
fn=str(path),
73+
offset=0,
74+
length=3600,
75+
)
76+
77+
# Calculate trace size to determine total data length
78+
# We need to open the file briefly to get the spec
79+
sf = SegyFile(**segy_file_kwargs)
80+
trace_header_size = sf.spec.trace.header.itemsize
81+
sample_size = 4 # This will always be a 4-byte float
82+
num_samples = len(sf.sample_labels)
83+
trace_size = trace_header_size + (num_samples * sample_size)
84+
85+
# Total length is header (3600) + trace data
86+
total_len = 3600 + num_traces * trace_size
87+
combiner = DistributedCRC32C(raw_bytes, total_len)
88+
4689
trace_count = num_traces
4790
n_blocks = int(ceil(trace_count / block_size))
4891

@@ -61,18 +104,32 @@ def parse_headers(
61104
# 'fork' to avoid deadlocks on cloud stores. Slower but necessary. Default on Windows.
62105
context = mp.get_context("spawn")
63106
with ProcessPoolExecutor(num_workers, mp_context=context) as executor:
64-
lazy_work = executor.map(header_scan_worker, repeat(segy_file_kwargs), trace_ranges, repeat(subset))
107+
lazy_work = executor.map(
108+
header_scan_worker,
109+
repeat(segy_file_kwargs),
110+
trace_ranges,
111+
repeat(subset),
112+
repeat(calculate_checksum),
113+
)
65114

66115
if progress_bar is True:
116+
desc = (
117+
"Scanning SEG-Y & calculating checksum"
118+
if calculate_checksum
119+
else "Scanning SEG-Y for geometry attributes"
120+
)
67121
lazy_work = tqdm(
68122
iterable=lazy_work,
69123
total=n_blocks,
70-
desc="Scanning SEG-Y for geometry attributes",
124+
desc=desc,
71125
**tqdm_kw,
72126
)
73127

74128
# This executes the lazy work.
75-
headers: list[HeaderArray] = list(lazy_work)
129+
results = list(lazy_work)
130+
131+
if not calculate_checksum:
132+
# Merge headers and return
133+
return np.concatenate(results)
76134

77-
# Merge blocks before return
78-
return np.concatenate(headers)
135+
return _finalize_checksum(results, combiner)

src/mdio/segy/utilities.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ def get_grid_plan( # noqa: C901, PLR0913
3232
template: AbstractDatasetTemplate,
3333
return_headers: bool = False,
3434
grid_overrides: dict[str, Any] | None = None,
35-
) -> tuple[list[Dimension], tuple[int, ...]] | tuple[list[Dimension], tuple[int, ...], HeaderArray]:
35+
calculate_checksum: bool = False,
36+
) -> (
37+
tuple[list[Dimension], tuple[int, ...]]
38+
| tuple[list[Dimension], tuple[int, ...], HeaderArray]
39+
| tuple[list[Dimension], tuple[int, ...], HeaderArray, int]
40+
):
3641
"""Infer dimension ranges, and increments.
3742
3843
Generates multiple dimensions with the following steps:
@@ -48,22 +53,31 @@ def get_grid_plan( # noqa: C901, PLR0913
4853
template: MDIO template where coordinate names and domain will be taken.
4954
return_headers: Option to return parsed headers with `Dimension` objects. Default is False.
5055
grid_overrides: Option to add grid overrides. See main documentation.
56+
calculate_checksum: Option to calculate CRC32C checksum during scanning. Default is False.
5157
5258
Returns:
53-
All index dimensions and chunksize or dimensions and chunksize together with header values.
59+
All index dimensions and chunksize, optionally with header values and/or checksum.
5460
"""
5561
if grid_overrides is None:
5662
grid_overrides = {}
5763

5864
# Keep only dimension and non-dimension coordinates excluding the vertical axis
5965
horizontal_dimensions = template.dimension_names[:-1]
6066
horizontal_coordinates = horizontal_dimensions + template.coordinate_names
61-
headers_subset = parse_headers(
67+
parse_result = parse_headers(
6268
segy_file_kwargs=segy_file_kwargs,
6369
num_traces=segy_file_info.num_traces,
6470
subset=horizontal_coordinates,
71+
calculate_checksum=calculate_checksum,
6572
)
6673

74+
# Unpack result based on whether checksum was calculated
75+
if calculate_checksum:
76+
headers_subset, trace_data_crc32c = parse_result
77+
else:
78+
headers_subset = parse_result
79+
trace_data_crc32c = None
80+
6781
# Handle grid overrides.
6882
override_handler = GridOverrider()
6983
headers_subset, horizontal_coordinates, chunksize = override_handler.run(
@@ -86,8 +100,12 @@ def get_grid_plan( # noqa: C901, PLR0913
86100
vertical_dim = Dimension(coords=sample_labels, name=template.trace_domain)
87101
dimensions.append(vertical_dim)
88102

103+
if return_headers and calculate_checksum:
104+
return dimensions, chunksize, headers_subset, trace_data_crc32c
89105
if return_headers:
90106
return dimensions, chunksize, headers_subset
107+
if calculate_checksum:
108+
return dimensions, chunksize, None, trace_data_crc32c
91109

92110
return dimensions, chunksize
93111

0 commit comments

Comments
 (0)