Skip to content

Tighten the stored format, open each file once, and read what ObsPy reads - #82

Merged
atrabattoni merged 22 commits into
devfrom
feature/datatree-io
Aug 11, 2026
Merged

Tighten the stored format, open each file once, and read what ObsPy reads#82
atrabattoni merged 22 commits into
devfrom
feature/datatree-io

Conversation

@atrabattoni

Copy link
Copy Markdown
Contributor

The stored format

One header per tiling. Everything about a tiling that is not a per-tile
column — the tile counts, the engine specification, the element type, the
common source directory, the axis arrangement — now travels in a single JSON
header attribute on the __tiles__ group, replacing the __tile_array__
placeholder attribute, the root / axes / source_ndim variables and the
per-column ntiles attributes. The manifest variables are now exactly the
per-tile columns, and the placeholder points at its group through a
CF-grid_mapping-style __tiling__ attribute instead of being tied to the
group name. The reader does not accept the earlier spelling — see the
migration note below.

Constant tile geometry costs one element, not one per tile. A sizes_k,
starts_k or steps_k column holding a single value — what an acquisition of
equal-length files gives, and always the case for the absent origin and stride
columns — is kept as a broadcast view in memory and a 0-d variable on disk, and
its tile boundaries as a closed form instead of a full cumsum. On a
23-million-tile archive: 1.67 GB → 1.11 GB resident, and locating a tile along
such an axis becomes a division instead of a binary search.

Coordinates are stored as CF-1.13 actually defines them. Interpolated
coordinates get the singular tie_point_mapping and the mandatory
computational_precision; sampled ones are stored as a deliberate variation on
the same grammar. Both still read the earlier spelling.

Dask virtualization is gone, reader and writer alike, along with the
xdas.dask module. No engine has emitted it since tiles landed. A Dask array
remains valid DataArray data — it is computed on write like any other eager
array — and this removes the FutureWarning deprecation that was in dev.

Opening and saving

One open per direction — fixes #81. Reading walks a single
xr.open_datatree; writing lands every group's metadata through one
DataTree.to_netcdf and the data variables through one writable h5netcdf
handle. That matters on write because every writable h5netcdf open walks the
whole file to find the next free dimension id, so one open per data array made
saving a collection quadratic in its size.

DataCollection.to_netcdf(virtual=True), the case #81 reports:

events dev this PR
50 7.7 s (154 ms/event) 1.2 s (23 ms/event) 6.6×
100 33.4 s (334 ms/event) 4.5 s (45 ms/event) 7.4×
200 126.2 s (631 ms/event) 7.8 s (39 ms/event) 16×
400 423.9 s (1060 ms/event) 17.4 s (44 ms/event) 24×

dev doubles its per-event cost every time the collection doubles; this branch
is flat, so the gap keeps widening with size.

The open path benefits from the same shared handle (595 s → 44 s at 400
groups) but is not linear yet — 35 → 110 ms/event over that range. The
residual has a different, independent cause: h5netcdf resolves
DIMENSION_LIST references through H5Iget_name, which is O(objects in file)
per group and sits outside xdas. Worth its own issue; this PR does not claim it.

Reading seismological files

The obspy engine, named for the library rather than for a format: decoding
is obspy.read, so miniSEED, SAC, GSE2, SEG-2 and everything else ObsPy
supports goes through it. Each contiguous Trace becomes one lazy DataArray
and the collection mirrors the Stream, nested
network / station / location / channel. Files the old engine rejected (two
sampling rates, duplicated ids, interleaved acquisitions) are readable, and each
tile points at an individual trace instead of the whole file. The "miniseed"
engine is kept unchanged beside it — views it wrote keep decoding, code written
against it keeps running, ignore_last_sample included — and auto-detection
reaches "obspy" first.

Supporting changes: xdas.trim_overlaps resolves overlaps at the manifest
level (keeping the later copy by default, ObsPy's merge(method=1));
xdas.concat opening a new dimension now checks the other coordinates agree
and promotes the scalar ones that vary, so xd.concat(traces, "channel") gives
back the stacked shape lazily; DataCollection.select (alias of query) and
a fields that reports the whole subtree give obspy.Stream.select semantics.

Breaking changes

  • Rewrite stored tile-backed views: open them with 0.2.8 still installed and
    write them back with this branch. Stored coordinates are unaffected — both
    spellings still read.
  • xd.open on a seismological file returns a nested collection, not a
    stacked array. xd.concat(traces, "channel") is the one-liner back;
    engine="miniseed" still gives the old shape.
  • xd.open now combines whether it opened one file or many, so the returned
    shape no longer depends on the file count; leaf sequences are named
    acquisition.
  • DataCollection.query raises KeyError on an indexer naming no level of the
    collection, and applies an indexer wherever its level sits in the tree.
  • TileArray carries no user attributes.
  • A blank SEED location code is stored as "--".

open_dataarray passed phony_dims="sort" to silence xarray's
transitional phony_dims warning (0f43721). But "sort" makes h5netcdf
run _determine_phony_dimensions(), an eager recursive walk of every
group in the file, on each open -- and open_datacollection calls
open_dataarray once per array.

On a 42-array collection that is 42 full-tree walks of 95 groups and
787 datasets: 11.6 s to open, 8.2 s of it in that scan.

"access" is xarray's own default, names phony dims lazily per group,
skips the walk, and silences the warning just the same. No xdas-written
file has unlabeled dimensions, so no phony dim is ever created either
way.
A geometry column holding a single value — the absent `starts_k` and
`steps_k`, and the `sizes_k` of an acquisition of equal-length files —
is detected at construction and kept as a zero-stride broadcast view
instead of a full-length array. Their tile boundaries follow: `_edges`
becomes a small per-axis object holding either the eager `cumsum` of a
varying column, exactly as before, or the constant tile width, from
which `total`, `at`, `searchsorted` and `span` are closed forms.

Detected, never assumed: `combine` starts a new acquisition whenever the
geometry changes, so the size column cannot vary inside a group, but a
truncated last file would give a second value and must stay on the eager
path. The manifest dataset is untouched, so a round trip rewrites
exactly what it read.

On a 23-million-tile archive, opening drops from 1.67 GB to 1.11 GB
resident and from 3.70 s to 3.31 s; locating a tile along a constant
axis is a division instead of a binary search (2.4 us to 0.3 us).
Reading walks a single xr.open_datatree of the whole file instead of
one targeted xr.open_dataset per group; writing lands every group's
metadata through a single DataTree.to_netcdf and every data variable
through one writable h5netcdf handle. One writable handle is the fix
for #81: h5netcdf walks the whole file on every writable open to find
the next free dimension id, so per-array opens made saving quadratic
in the collection size — now flat at ~23 ms per event at N=800.

The manifest of a tile-backed array becomes just another node of the
same tree, so VirtualBackend.finalize_save (a reopen per array) is
replaced by sibling_datasets, which returns the stored form instead of
writing it. _get_depth and its visit() walk retire with the reader:
a node holding variables is a data array, one holding only groups is a
nesting level.

Deviation from the plan sketch: eager variables are written in the
h5netcdf pass rather than through to_netcdf, which would have changed
the accepted encoding keys (h5netcdf's "chunks" and hdf5plugin filter
dicts vs xarray's "chunksizes").
A geometry column holding the same value for every tile was folded to a
zero-stride view in memory but still written, read and held per tile:
185 MB of `sizes_0` on a 23 M-tile archive, all of it the number 6.

Fold it in the stored manifest too. A column holding its kind's default
is left out, one holding a single value everywhere is stored 0-d, and
only a varying one stays 1-D.

Folding a column stops it sizing its axis, and nothing else is obliged
to: `concat([a, a])` on a single-file array folds every column, and
would reopen as one tile instead of two. So a folded `sizes_k` carries
an `ntiles` attribute — the manifest states the shape of an axis it no
longer measures, instead of leaving it to be inferred from whichever
variable happens to still carry the dimension. It rides on `sizes_k`
rather than on the dataset, whose every attribute is a user attribute,
and being an attribute it costs no HDF5 object, unlike a variable or a
dimension of its own.

Folding happens in the constructor, beside the recoding of strings to
fixed-width bytes: every entry point funnels through it, so scans,
slices, flips, concatenations and stored manifests all converge on the
same form, and a manifest written before this change folds on open
instead of needing a rescan.

On a 42-array, 23.07 M-tile archive: 765.0 -> 579.7 MB on disk, 1.112 ->
0.918 GB resident after open, 3.32 -> 2.94 s to open.
The stored manifest spread what a tiling needs over four spellings: an
`ntiles` attribute on each folded `sizes_k`, a `root` variable with its
own string-width dimension, an `axes`/`source_ndim` pair of variables
with their own dimension, and the engine on the placeholder in a
`__tile_array__` attribute -- while the dtype was nowhere, inferred from
the placeholder's HDF5 type.

Collect all of it into one JSON document, the `header` attribute of the
`__tiles__` group: `ntiles`, `engine`, `dtype`, and `root` and `axes`
absent at their defaults. One `json.loads`, natively typed inside, and
the manifest's variables become exactly the per-tile columns. `root`
rides through JSON rather than as a bytes attribute, which h5netcdf
rejects outright: `json.dumps(os.fsdecode(root))` is pure ASCII even for
a non-UTF-8 directory, and `os.fsencode` recovers the bytes exactly.

`axes` becomes the full virtual arrangement in numpy indexing-key
notation, `null` where an axis is inserted -- `[1, null, 0]` reads as
`transpose(a)[:, np.newaxis]` -- so the source rank stops being a
separate statement: it is the grid rank less the number of nulls. That
costs two invariants, both enforced in `_canonical`: synthetic axes stay
trailing and numbered in their virtual order, so a transpose that
reorders them renumbers the grid instead of the map; and an integer key
on a synthetic axis deletes it from the grid rather than hiding it, a
hidden synthetic axis being contentless once the pin has selected into
`paths`. Manifests are canonical either way, and `TileArray(dataset)`
now takes one argument -- `dtype` and `engine` stay as optional
arguments that must agree with the header when there is one.

The array itself stops carrying user attributes. It is a duck array;
numpy arrays have no `.attrs`, no scanner ever set them, and the
metadata belongs to the enclosing DataArray, which is where it already
was in practice.

The placeholder now points at its describing group with a
`__tiling__ = "__tiles__"` attribute, in the fashion of CF's
`grid_mapping`: dispatch is attribute-driven, the group name demotes to
a conventional default, and several tiled variables could share a file.
It is dunder-fenced because the placeholder's attributes are the
DataArray's user namespace -- the file has exactly two reservation
registries, CF's plain words and xdas's `__*__`, and the reader strips
the latter by pattern.

While the CF face is being rewritten, make it true. The old spelling was
CF-shaped but did not validate: `coordinate_interpolation` groups must
end with the interpolation variable, the mapping attribute is the
singular `tie_point_mapping` naming the interpolated dimension, the
index variable and the subsampled dimension, and
`computational_precision` is required. Files declare CF-1.13. The reader
accepts both grammars, telling them apart by whether a group's last word
names a variable carrying `interpolation_name`.

One deliberate break, so files change once: everything written before
still opens and upgrades in place, but files written now cannot be read
by earlier versions. On the 42-array, 23.07 M-tile archive the rewrite
is byte-for-byte equivalent -- same arrays, same geometry, same reads --
and opening goes from 3.22 s / 1.72 GB to 2.57 s / 0.94 GB, the drop
being the pre-fold columns the reader no longer has to fold on the way
in.
The sampled format mirrored CF-1.13 coordinate subsampling only
loosely: it carried the spacing as the sampling variable's own value,
with private `dtype` and `units` attributes to rebuild a timedelta,
and its `tie_point_mapping` listed the tie point coordinate variable
where the grammar expects the index variable.

Word it like the interpolated case instead. The sampling variable is
now a container, as CF's interpolation variable is, and its mapping
puts the segment length variable in the tie point index variable's
slot — the one deliberate departure, since a sampled axis describes
its segments by length rather than by end index. The spacing travels
as attributes through the shared `encode_delta` / `decode_delta`, the
way an interpolated coordinate already carries its regular metadata.

No file on disk uses the previous spelling, so nothing reads it.

Also stop the interpolated reader raising a `KeyError` on the oldest
files, which spelled the mapping without writing any interpolation
variable at all.
The header made the break one-way in intent, but the reader still
carried the whole earlier spelling: the `__tile_array__` placeholder
attribute, the `root` / `axes` / `source_ndim` variables, the per-column
`ntiles` attributes, and the canonicalization that turned them into a
header. That is a second format to keep correct for files that a single
rewrite with 0.2.8 converts.

The upgrade path goes; what remains is what a dataset with no header
says by itself: the tile counts its declared dimensions give, the
identity arrangement, the paths stored whole. That is a hand-assembled
manifest, which the scan-time assembly and the tests build, not a
stored one — every file this release writes carries its header.
No engine has emitted a dask graph since tiles landed: the two formats
that needed one, Silixa and MiniSEED, produce tile manifests, which
describe the same mapping as plain array data instead of as a
computation graph, and build in a fraction of the time. What was left
was a serializer, a deserializer and a deprecated write path, all of
them dead weight on the format and on the writer.

The `xdas.dask` module goes, with the `msgpack` dependency it alone
needed, and `__dask_array__` is no longer read. A dask array is still
valid data for a `DataArray` — it is now written like any other lazy
value, computed on the spot, and `virtual=True` rejects it as it does
any other non-virtual array.
`trim_overlaps` cuts a data array at its overlaps and keeps one copy of
each duplicated sample, lazily and on sample boundaries. Claims
accumulate as spans rather than a watermark, so a segment enveloped in a
lower-precedence one keeps a run on each side of it instead of losing
everything past the overlap.

`concat` opening a new dimension now checks that the inputs agree on
their other coordinates, and promotes the scalar ones that vary to a
coordinate along that dimension.

`DataCollection.query` reports fields it does not know instead of
silently returning everything, `fields` walks the whole subtree, and
`select` names the operation the way obspy does.
The miniseed engine described a whole file as one tile and classified it
at scan time as synchronized or unsynchronized, which rejected files
obspy reads fine, left no pointer to an individual trace, and turned
overlap handling into a destructive global flag baked into every stored
manifest.

The obspy engine emits one lazy data array per obspy Trace instead,
addressed by the data's own address — the four SEED identifiers plus
both time bounds — so a re-segmenting obspy version cannot silently
designate different samples. The collection nests on the SEED hierarchy,
giving Stream.select semantics through DataCollection.select. Merging
contiguous traces, moving gaps into the coordinate and separating
acquisition epochs are combine_by_coords' job, which `open` now runs
whether it opened one file or many.

Named for the library, not the format: engine="miniseed" stays as an
alias and pre-rename manifests keep decoding, but everything obspy reads
now goes through the same path. The engine is registered last so it does
not shadow the format-specific ones during auto-detection.

Along the way: a mapping keyed by a zero-padded code — a SEED location —
no longer reads back from netCDF as a sequence, and `query` applies an
indexer wherever its level sits rather than only at the root.
The miniSEED guide becomes the obspy guide, rewritten around what the
engine now returns: read a glob without describing the directory layout,
select by SEED level, recover the original segments from a gap, resolve
overlaps, then stack channels and stations into an N-dimensional array —
all of it lazy.
Restored verbatim under its own name, so views it wrote keep decoding and
code written against it keeps running unchanged — `ignore_last_sample`
and the `dim="station"` idiom included. It is no longer an alias for the
obspy engine: the two describe the same files with different shapes, so
one name cannot mean both.

Both take part in auto-detection, `obspy` registered first. `xd.open`
asks for a collection, which only the obspy engine provides, so it wins;
`xd.open_dataarray` asks for a single array, which the obspy engine
cannot give for a multi-trace file, and the legacy engine stacks it.
Each function keeps returning what it is named for.

The legacy decode moves out of `ObsPyEngine.load_tile` and back onto the
engine that wrote it, which loses its `method`/`ignore_last_sample`
branch. The stream converters and the band-code table were never
miniSEED-specific: they stay with the obspy engine and are re-exported.
Each entry is one or two sentences, as 0.2.8 reads: the rationale
belongs in the docstrings and the user guide, which already carry it.
Three substantive corrections along the way: the element type read from
the miniSEED encoding is a property of the new obspy engine, not a fix
to the legacy one, so it moves into that entry; sampled coordinates
stored by 0.2.8 are not read back, which the notes did not say; and the
collection I/O that is linear in its size again (#81) was missing
altogether.
Dask virtualization is gone; the only thing left was one isinstance in
the data array repr, holding a pinned `dask<2025.4.0` in the runtime
dependencies. The repr duck-types the module name instead, and dask
moves to the test group, where the two tests that build one live.
The sequence branch of `query` accepts an integer or a slice and told
the caller it wanted a string, which is the mapping branch's rule.
`headonly=True` leaves `tr.data` an empty float64 array whatever the
file holds, so a STEIM-compressed int32 file was scanned as float64.
The dask path never checked, and delivered int32 under a float64 label;
the tile array does check, so the read failed outright — STEIM being
the common case, `engine="miniseed"` was unusable on it.
Interpolated coordinates kept a compatibility branch across the format
change and sampled ones did not, so a 0.2.8 file with `ctype="sampled"`
failed with a bare KeyError naming a variable the reader had invented.
The two spellings are told apart the same way as for interpolation: by
whether the container carries the attribute the new grammar puts there.
The rule had three copies, and the fix for zero-padded keys reached two
of them: `xd.open_datacollection(path, engine="xdas")` still parsed the
keys as integers and raised KeyError on a collection keyed by a SEED
location. The nested reader was a single-use wrapper around the same
rule, so it folds into its caller.
The sweep discarded every exception, so a file an engine recognised and
then rejected for a stateable reason was reported as merely unreadable.
No single refusal is the reason — engines are tried in a cache-warmed
order, not in order of likelihood — so the message lists them all, and
leaves out the engines that only said they do not offer that shape.
`from_stream` built its start time from `.datetime`, truncating to
microseconds where the rest of the engine works in nanoseconds;
`_bare_header` was a single-use helper whose caller already explains
itself; and a doc code block was written so that `ruff format` moved a
comment onto its own line.
It was dropped when the obspy engine replaced the miniseed one, but
ffb95f6 restored the legacy engine with the parameter intact, and the
breaking-change entry promises it still works.
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (0e59e4f) to head (e0f6e9f).

Additional details and impacted files
@@            Coverage Diff             @@
##               dev       #82    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           47        45     -2     
  Lines         6017      6252   +235     
  Branches      1016      1067    +51     
==========================================
+ Hits          6017      6252   +235     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@atrabattoni atrabattoni linked an issue Aug 7, 2026 that may be closed by this pull request
@atrabattoni
atrabattoni merged commit aa928d3 into dev Aug 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DataCollection with large number of events

1 participant