Skip to content

Commit 12594b9

Browse files
committed
fix non post-stack detection
1 parent 421c6e8 commit 12594b9

2 files changed

Lines changed: 212 additions & 31 deletions

File tree

pysegy/scan.py

Lines changed: 142 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
)
2424
from .utils import detect_depth_keys, get_header, open_file
2525

26+
# Trace sorting code (binary file header) for horizontally stacked data, i.e.
27+
# data that holds no gathers, such as a stack or a property model
28+
STACKED_SORTING = 4
29+
2630

2731
@dataclass
2832
class ShotRecord:
@@ -58,7 +62,7 @@ def __str__(self) -> str:
5862
f"{self.coordinates[2]}"
5963
")"
6064
)
61-
lines.append(f" traces: {sum(c for _, c in self.segments)}")
65+
lines.append(f" traces: {self.ntraces}")
6266
lines.append(f" ns: {self.ns}, dt: {self.dt}")
6367
if self.summary:
6468
lines.append(" summary:")
@@ -68,23 +72,58 @@ def __str__(self) -> str:
6872

6973
__repr__ = __str__
7074

71-
def read_data(self, keys: Optional[Iterable[str]] = None) -> SeisBlock:
75+
@property
76+
def ntraces(self) -> int:
77+
"""
78+
Number of traces in this record.
7279
"""
73-
Load all traces for this shot.
80+
return sum(c for _, c in self.segments)
81+
82+
def read_data(
83+
self,
84+
keys: Optional[Iterable[str]] = None,
85+
traces: Optional[slice] = None,
86+
) -> SeisBlock:
87+
"""
88+
Load the traces of this shot.
89+
90+
Parameters
91+
----------
92+
keys : Iterable[str], optional
93+
Header fields to load with each trace.
94+
traces : slice, optional
95+
Range of this record's traces to load. Only the corresponding part
96+
of the file is read; by default all traces are.
97+
98+
Returns
99+
-------
100+
ndarray
101+
``ns`` x number of traces read.
74102
"""
103+
ns = self.fileheader.bfh.ns
104+
trace_size = 240 + ns * 4
105+
start, stop, _ = (traces or slice(None)).indices(self.ntraces)
106+
75107
data_parts = []
108+
first = 0
76109
for offset, count in self.segments:
77-
with open_file(self.path, "rb", self.fs) as f:
78-
f.seek(offset)
79-
_, d = read_traces(
80-
f,
81-
self.fileheader.bfh.ns,
82-
count,
83-
self.fileheader.bfh.DataSampleFormat,
84-
keys,
85-
)
86-
data_parts.append(d)
87-
return np.concatenate(data_parts, axis=1) if data_parts else []
110+
# Part of this segment that falls within the requested range
111+
low, high = max(start, first), min(stop, first + count)
112+
if high > low:
113+
with open_file(self.path, "rb", self.fs) as f:
114+
f.seek(offset + (low - first) * trace_size)
115+
_, d = read_traces(
116+
f,
117+
ns,
118+
high - low,
119+
self.fileheader.bfh.DataSampleFormat,
120+
keys,
121+
)
122+
data_parts.append(d)
123+
first += count
124+
if not data_parts:
125+
return np.empty((ns, 0), dtype=np.float32)
126+
return np.concatenate(data_parts, axis=1)
88127

89128
def read_headers(
90129
self, keys: Optional[Iterable[str]] = None
@@ -145,6 +184,59 @@ def rec_coordinates(self) -> np.ndarray:
145184
return self._rec_coords
146185

147186

187+
def _is_gathered(fh: FileHeader, records: List[ShotRecord]) -> bool:
188+
"""
189+
Whether the scanned traces form gathers at all.
190+
191+
Post-stack data, a velocity model or any other property cube has no shot
192+
structure: the binary file header says so when its trace sorting is
193+
``STACKED_SORTING``, and grouping such a file by source coordinate yields
194+
single-trace groups, since every trace then carries its own coordinate.
195+
"""
196+
if fh.bfh.TraceSorting == STACKED_SORTING:
197+
return False
198+
return any(r.ntraces > 1 for r in records)
199+
200+
201+
def _merge_records(
202+
records: List[ShotRecord], fh: FileHeader, fs=None
203+
) -> ShotRecord:
204+
"""
205+
Merge scanned records into a single one holding every trace, in file order.
206+
"""
207+
trace_size = 240 + fh.bfh.ns * 4
208+
first = min(records, key=lambda r: r.segments[0][0])
209+
210+
segments: List[Tuple[int, int]] = []
211+
for offset, count in sorted(s for r in records for s in r.segments):
212+
if segments and offset == segments[-1][0] + segments[-1][1]*trace_size:
213+
segments[-1] = (segments[-1][0], segments[-1][1] + count)
214+
else:
215+
segments.append((offset, count))
216+
217+
summary: Dict[str, Tuple[float, float]] = {}
218+
for rec in records:
219+
for k, (mn, mx) in rec.summary.items():
220+
if k in summary:
221+
summary[k] = (min(summary[k][0], mn), max(summary[k][1], mx))
222+
else:
223+
summary[k] = (mn, mx)
224+
225+
return ShotRecord(
226+
first.path,
227+
first.coordinates,
228+
fh,
229+
first.rec_depth_key,
230+
first.depth_key,
231+
first.by_receiver,
232+
segments,
233+
summary,
234+
first.ns,
235+
first.dt,
236+
fs,
237+
)
238+
239+
148240
def _parse_header(buf: bytes, keys: Iterable[str]) -> BinaryTraceHeader:
149241
"""
150242
Return a :class:`BinaryTraceHeader` parsed from ``buf``.
@@ -308,7 +400,7 @@ def counts(self) -> List[int]:
308400
"""
309401
Total number of traces for each shot.
310402
"""
311-
return [sum(c for _, c in r.segments) for r in self.records]
403+
return [r.ntraces for r in self.records]
312404

313405
def __getitem__(self, idx: int) -> ShotRecord:
314406
"""
@@ -332,43 +424,58 @@ def summary(self, idx: int) -> dict:
332424
return self.records[idx].summary
333425

334426
def read_data(
335-
self, idx: int, keys: Optional[Iterable[str]] = None
427+
self,
428+
idx: int,
429+
keys: Optional[Iterable[str]] = None,
430+
traces: Optional[slice] = None,
336431
) -> SeisBlock:
337432
"""
338-
Load all traces for a single shot.
433+
Load the traces of a single shot.
339434
340435
Parameters
341436
----------
342437
idx : int
343438
Index of the shot to read.
344439
keys : Iterable[str], optional
345440
Additional header fields to load with each trace.
441+
traces : slice, optional
442+
Range of the shot's traces to load. Only the corresponding part of
443+
the file is read; by default all traces are.
346444
347445
Returns
348446
-------
349447
SeisBlock
350-
In-memory representation of the selected shot.
448+
In-memory representation of the selected traces.
351449
"""
352450
rec = self.records[idx]
451+
ns = self.fileheader.bfh.ns
452+
trace_size = 240 + ns * 4
453+
start, stop, _ = (traces or slice(None)).indices(rec.ntraces)
454+
353455
headers: List[BinaryTraceHeader] = []
354456
data_parts = []
457+
first = 0
355458
for offset, count in rec.segments:
356-
fs_to_use = rec.fs if rec.fs is not None else getattr(self, "fs", None)
357-
with open_file(rec.path, "rb", fs_to_use) as f:
358-
f.seek(offset)
359-
h, d = read_traces(
360-
f,
361-
self.fileheader.bfh.ns,
362-
count,
363-
self.fileheader.bfh.DataSampleFormat,
364-
keys,
365-
)
366-
headers.extend(h)
367-
data_parts.append(d)
459+
# Part of this segment that falls within the requested range
460+
low, high = max(start, first), min(stop, first + count)
461+
if high > low:
462+
fs_to_use = rec.fs if rec.fs is not None else getattr(self, "fs", None)
463+
with open_file(rec.path, "rb", fs_to_use) as f:
464+
f.seek(offset + (low - first) * trace_size)
465+
h, d = read_traces(
466+
f,
467+
ns,
468+
high - low,
469+
self.fileheader.bfh.DataSampleFormat,
470+
keys,
471+
)
472+
headers.extend(h)
473+
data_parts.append(d)
474+
first += count
368475
if data_parts:
369476
data = np.concatenate(data_parts, axis=1)
370477
else:
371-
data = [] # pragma: no cover
478+
data = np.empty((ns, 0), dtype=np.float32)
372479
return SeisBlock(self.fileheader, headers, data)
373480

374481
def read_headers(
@@ -549,6 +656,10 @@ def _scan_file(
549656
records[previous].segments.append((seg_start, seg_count))
550657

551658
record_list = sorted(records.values(), key=lambda r: r.coordinates)
659+
if record_list and not _is_gathered(fh, record_list):
660+
vprint(f"{thread} {path} holds no gathers, scanned as a single record")
661+
record_list = [_merge_records(record_list, fh, fs)]
662+
552663
vprint(f"{thread} found {len(record_list)} shots in {path}")
553664
return SegyScan(fh, record_list, fs=fs)
554665

pysegy/tests/test_python.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,76 @@ def test_scan_by_receiver_gather(tmp_path):
331331
assert tuple(coords[0]) == (1.0, 1.0, 0.0)
332332

333333

334+
def _write_model(path, ntraces=8, ns=4):
335+
"""
336+
Write a stacked file, e.g. a velocity model: no gathers, and every trace
337+
carrying its own coordinate.
338+
"""
339+
fh = FileHeader()
340+
fh.bfh.ns = ns
341+
fh.bfh.DataSampleFormat = 5
342+
fh.bfh.TraceSorting = seg.scan.STACKED_SORTING
343+
344+
headers = []
345+
for i in range(ntraces):
346+
hdr = BinaryTraceHeader()
347+
hdr.ns = ns
348+
hdr.SourceX = hdr.GroupX = hdr.CDPX = 10 * i
349+
headers.append(hdr)
350+
351+
data = np.arange(ns * ntraces, dtype=np.float32).reshape(ns, ntraces)
352+
with open(path, "wb") as f:
353+
seg.write.write_block(f, SeisBlock(fh, headers, data))
354+
return data
355+
356+
357+
def test_scan_stacked_file(tmp_path):
358+
"""
359+
A file without gathers scans as a single record holding every trace.
360+
"""
361+
tmp = tmp_path / "model.segy"
362+
data = _write_model(tmp, ntraces=8, ns=4)
363+
364+
scan = seg.segy_scan(str(tmp), keys=["GroupX"])
365+
assert len(scan) == 1
366+
assert scan.counts == [8]
367+
assert scan[0].segments == [(3600, 8)]
368+
assert scan.summary(0)["GroupX"] == (0, 70)
369+
assert np.array_equal(scan[0].data, data)
370+
371+
372+
def test_read_trace_range(tmp_path):
373+
"""
374+
Reading a range of traces only reads that range, and matches a full read.
375+
"""
376+
tmp = tmp_path / "model.segy"
377+
data = _write_model(tmp, ntraces=8, ns=4)
378+
record = seg.segy_scan(str(tmp))[0]
379+
380+
assert np.array_equal(record.read_data(traces=slice(2, 5)), data[:, 2:5])
381+
assert np.array_equal(record.read_data(traces=slice(0, 1)), data[:, :1])
382+
assert np.array_equal(record.read_data(traces=slice(7, 8)), data[:, 7:])
383+
assert np.array_equal(record.read_data(), data)
384+
385+
# Same through the scan, which also returns the matching headers
386+
block = seg.segy_scan(str(tmp)).read_data(0, traces=slice(2, 5))
387+
assert np.array_equal(block.data, data[:, 2:5])
388+
assert len(block.traceheaders) == 3
389+
assert block.traceheaders[0].GroupX == 20
390+
391+
392+
def test_read_trace_range_segments(tmp_path):
393+
"""
394+
Trace ranges spanning several segments of a gather.
395+
"""
396+
scan = seg.segy_scan(DATAFILE)
397+
record = scan[0]
398+
full = record.read_data()
399+
400+
assert np.array_equal(record.read_data(traces=slice(3, 20)), full[:, 3:20])
401+
assert record.read_data(traces=slice(0, 0)).shape == (record.ns, 0)
402+
403+
334404
def test_save_and_load_scan(tmp_path):
335405
scan = seg.segy_scan(DATAFILE)
336406
dest = tmp_path / "scan.pkl"

0 commit comments

Comments
 (0)