Fast, schema-aware extxyz reading for atomistic machine-learning workflows
Project description
oxyz
Fast, schema-aware extxyz reading for
atomistic machine learning. A Rust parser behind a small, typed Python API:
numpy arrays out, ase.Atoms on request, and a one-pass schema report that
tells you whether a training file is what you think it is.
import oxyz
frames = oxyz.read_frames("train.extxyz") # all cores, one pass
frames[0].columns["pos"] # float64 ndarray, shape (n_atoms, 3)
frames[0].metadata["energy"] # float
schema = oxyz.infer_schema("train.extxyz")
schema.is_consistent # False — now you know before training
print(schema) # which keys drift, and in how many frames
oxyz exists for the gap between "extxyz is the lingua franca of atomistic
ML datasets" and "every Python extxyz reader is slow enough to matter".
Reading a dataset into numpy is 16–27× faster than ase.io.read on the
benchmarks below; reading it into ase.Atoms objects is 3.5–6× faster.
The same single pass can also tell you the dataset's schema — which columns
and metadata keys appear, with what types and shapes, and how consistently
— which is the part of dataset ingestion that usually goes unchecked.
Pre-1.0: minor versions may change the API.
Install
pip install oxyz # numpy is the only dependency
pip install "oxyz[ase]" # adds ASE conversion (ase >=3.23,<4)
Wheels cover CPython ≥3.11 on Linux (x86_64, aarch64), macOS (arm64, x86_64), and Windows (x64).
Installing puts an oxyz command on the path; oxyz scan train.extxyz
summarises a file without writing any Python. It also runs without
installing, via uvx oxyz scan train.extxyz.
In place of ASE
oxyz.ase.read and oxyz.ase.iread are drop-ins for ase.io.read /
ase.io.iread on extxyz files, including ASE's full index grammar
(-1, "::2", slices):
import oxyz.ase
atoms = oxyz.ase.read("train.extxyz") # last frame, like ase.io.read
images = oxyz.ase.read("train.extxyz", ":") # every frame
for atoms in oxyz.ase.iread("train.extxyz", "::10"):
...
The conversion reuses ase.io.extxyz's own routing tables and
set_calc_and_arrays, so key handling (which results go to the
calculator, which to arrays) agrees with ASE by construction; golden
tests hold the two readers equal on the test corpus apart from the
divergences below. Reads are lazy:
read(path, 3) parses four frames and stops, and negative or reverse
selections resolve through a structural scan and seek rather than a full
parse — read(path) on a long trajectory does not parse the whole file
to return the last frame.
Divergences from ASE
oxyz.ase.read matches ase.io.read field for field on the test corpus
except for the cases below — two deliberate, two that follow from
honouring the extxyz grammar and oxyz's typed model where ASE's parser
does not.
Deliberate — an error or an acceptance, never a silently different value:
- Voigt stress. 6-component
stressis accepted and routed to the calculator; ASE's comment parser rejects the file. - Non-symbol species. A species that is not a chemical symbol raises
an error; ASE builds a nonsense
Atoms.
Grammar and typing — a different value, no error:
- New-style string arrays.
tags=["a","b"]is typed aslist[str]; ASE keeps the one raw string'"a","b"'. - Single-quoted values. The grammar makes
"the only quote character, solabel='hello'keeps its quotes andnote=it'skeeps its apostrophe; ASE strips the single quotes (and readsit'sasits).
What you get beyond ASE
Array-native frames. A Frame is a frozen dataclass holding the file's
columns as numpy arrays and its comment-line metadata as typed Python
values — no per-atom Python objects, no calculator indirection. Names and
values are kept exactly as written: no force/forces aliasing, no
reordering, Lattice stays the flat 9-value array from the file.
Normalisation is the ASE layer's job (or yours).
Batches in the PyG layout. Batch concatenates frames atom-major,
CSR-style: every per-atom column is one dense array of total_atoms rows,
frame i occupying rows offsets[i]:offsets[i+1]; per-frame metadata
stacks into arrays of n_frames rows. batch.ptr and batch.batch carry
their PyTorch Geometric names, and torch.from_numpy(batch.columns["pos"])
is zero-copy, so the path into a training loop is short.
for batch in oxyz.iter_batches("bulk.extxyz", atoms_per_batch=4096,
shuffle=True, seed=0):
batch.columns["forces"] # (total_atoms, 3)
batch.metadata["energy"] # (n_frames,)
batch.frame_indices # which file frames these are — provenance
iter_batches packs by frame count or by a total-atom budget, in file
order or seeded-shuffled. Batch composition depends only on the file, the
knobs, and the seed — never on threads.
Schema inference. infer_schema folds the whole file into a Schema:
per-column and per-metadata-key observed variants (kind, width or shape,
and how many frames used each), presence counts, a strict is_consistent,
and per-entry unified — the single type an Int/Real drift can be
promoted to, or None when the conflict is genuine. The classic failure
it catches: a generator script that writes isolated-atom frames with
integer forces and no Lattice into an otherwise uniform bulk dataset.
The same pass keeps the per-frame atom counts, so a Schema also reports
the atom-count distribution (mean_atoms, median_atoms, std_atoms,
alongside the min/max above) without a second read of the file.
>>> print(oxyz.infer_schema("train.extxyz"))
1000 frames, 63841 atoms (min 1, max 96)
per-atom columns:
species: S:1 (1000/1000 frames)
pos: R:3 (1000/1000 frames)
forces: I:3 (5/1000 frames), R:3 (995/1000 frames) (unifies to R:3)
metadata:
energy: Real (1000/1000 frames)
Lattice: RealArray[9] (995/1000 frames)
Structural scanning. oxyz.scan reads only the frame skeleton — byte
offsets and declared atom counts — without parsing any contents. It is the
cheap first question to ask of an unfamiliar file (5 ms for a 22 MiB file
below) and the machinery behind random access, shuffled batching, and
lazy negative indexing. The same statistics, alongside the inferred
schema, are a terminal away with oxyz scan (see Command line).
Parallelism as a knob, not a mode. Readers take threads: None
parses on every core, 1 is the exact serial streaming path. Results and
errors are identical either way — the parallel path is held to the serial
path's behaviour by parity tests, not by intention.
Command line
Installing oxyz provides an oxyz command for inspecting files from the
shell; uvx oxyz runs it without installing anything.
oxyz scan train.extxyz
scan prints per-frame atom-count statistics followed by the inferred
schema. Unlike the oxyz.scan primitive, which parses nothing, the command
reads the whole file to infer the schema; --no-schema drops back to the
cheap structural pass and reports only the statistics. --json emits a
single {"stats": ..., "schema": ...} object for piping into other tools.
$ oxyz scan train.extxyz
frames: 3
atoms total: 6
atoms/frame: min 1 max 3 mean 2.00 median 2.00 std 0.82
3 frames, 6 atoms (min 1, max 3)
per-atom columns:
species: S:1 (3/3 frames)
pos: R:3 (3/3 frames)
forces: R:3 (3/3 frames)
metadata:
Lattice: IntArray[9] (3/3 frames)
energy: Real (3/3 frames)
Performance
Timings below are means over repeated rounds — each case gets a one-second budget over at least five rounds — on an Apple M3 Pro under CPython 3.13. Full tables with standard deviations, the environment, and the fixture definitions are in benchmarks/RESULTS.md; benchmarks/run.py reproduces them.
Whole-file reads to numpy (oxyz.read_frames vs cextxyz, the libAtoms C
parser, via its read_dicts):
| workload | oxyz | oxyz threads=1 |
cextxyz |
|---|---|---|---|
| 2 000 small frames | 9.2 ms | 18.6 ms | 215 ms |
| 4 × 100 000 atoms | 26.6 ms | 60.2 ms | 92.7 ms |
| 2 000 frames, heavy metadata | 12.7 ms | 25.6 ms | 356 ms |
| MACE-style mixed file | 6.4 ms | 13.1 ms | 135 ms |
Whole-file reads to ase.Atoms (oxyz.ase.read vs the ase-extxyz plugin
wrapping the same C parser, vs ase.io.read):
| workload | oxyz.ase | ase-extxyz | ase |
|---|---|---|---|
| 2 000 small frames | 60 ms | 101 ms | 209 ms |
| 4 × 100 000 atoms | 71 ms | 90 ms | 426 ms |
| 2 000 frames, heavy metadata | 74 ms | 241 ms | 339 ms |
| MACE-style mixed file | 37 ms | 75 ms | 152 ms |
Beyond whole-file reads: on selective reads (every 20th frame of the
small-frames file) oxyz.read_batch takes 1.6 ms against 21 ms for ASE;
on peak memory, streaming iter_frames through the small-frames file
grows RSS by 12 MiB where ase.io.iread grows it by 56 MiB
(benchmarks/MEMORY.md).
The one place a text parser is predictably slower is against binary
stores (LMDB, SQLite, mmap-backed formats); see
benchmarks/RESULTS.md
for those comparisons.
API
oxyz.read_frames(path, *, threads=None) -> list[Frame]
oxyz.iter_frames(path) -> Iterator[Frame] # constant memory
oxyz.read_first(path) -> Frame
oxyz.read_batch(path, indices, *, threads=None) -> Batch
oxyz.iter_batches(path, *, frames_per_batch=None, atoms_per_batch=None,
shuffle=False, seed=None, threads=None) -> Iterator[Batch]
oxyz.scan(path) -> FrameIndex
oxyz.infer_schema(path) -> Schema
oxyz.ase.read(path, index=None, *, format=None) -> Atoms | list[Atoms] # index=None: last frame
oxyz.ase.iread(path, index=":", *, format=None) -> Iterator[Atoms]
oxyz.ase.to_atoms(frame) -> Atoms # also Frame.to_ase()
Frame, Batch, FrameIndex, Schema and its parts (ColumnSchema,
MetadataSchema, the variant records, the Kind enum) are frozen
dataclasses; everything ships with type stubs.
The command line mirrors a subset:
oxyz scan <path> [--no-schema] [--json] # stats + inferred schema
The fine print
Contracts worth knowing before relying on them:
- Mixed-schema files read per-frame, but do not batch.
read_framesanditer_frameshandle files whose frames disagree (the MACE isolated-atom-plus-bulk pattern) without complaint — eachFramestands alone.Batchassembly currently requires every gathered frame to share a schema;infer_schematells you in advance whether a file qualifies. A missing-key policy (NaN-fill plus presence mask) is planned. - Duplicate metadata keys collapse.
Frame.metadatais a dict; if a comment line repeats a key, the last occurrence wins. Batch.batchis computed per access (np.repeatover the atom counts); hoist it out of a hot loop.- Errors carry frame context. Malformed input raises
oxyz.ParseError(aValueErrorsubclass) with the frame index and the offending line or value in the message, and the same location on the exception as attributes —frame_index,line_number,column, eachNonewhere the parser cannot pin it down — so you can find the bad frame without parsing the message. Out-of-range frame requests raiseIndexError; I/O problems raiseOSError. After a parse error, streaming iterators stop rather than guess at a resynchronisation point. - Partial reads only promise the prefix.
read_batchand indexed reads inspect the file no further than the last requested frame; damage past that point goes unreported. Whole-file validation isinfer_schema's job.
Supported extxyz
The parser accepts and preserves; it does not interpret. Accepted: the
count line; a comment line of key=value pairs with bare or
double-quoted values, [1, 2.0, 3]-style or quoted whitespace-separated
arrays, T/TRUE/True/true booleans (a bare 1 stays an integer in
metadata, but is a boolean in an L-kind atom column, following the
spec); a Properties descriptor with S/R/I/L columns of any name
and width; any species strings. Metadata values are typed by shape, and
anything that fits no narrower type falls back to a string rather than
rejecting the file. Not supported: writing (reading only, for now),
compressed input, comment lines that are not key=value metadata, and
single-quoted values.
How it is put together
Three layers, with the boundary chosen so that each is testable on its own:
crates/oxyz-core— the Rust core: parser, the columnar losslessFramemodel, the structural scanner and byte-offset index, batch assembly, and the schema fold. No Python anywhere in the crate; it builds and tests standalone. Errors are structured (thiserror) and wrapped with the frame they occurred in.crates/oxyz-py— the PyO3 binding, a cdylib namedoxyz._rust. Parsing runs with the interpreter detached (the GIL released), so threads parse in parallel; conversion to numpy happens once at the boundary, column buffers passing across as whole arrays rather than element-wise. Built as a single abi3 wheel per platform covering CPython ≥3.11.src/oxyz— thin typed Python: frozen dataclasses over the binding's dicts, batch planning (the pure-Python part ofiter_batches), and the index grammar. All ASE knowledge lives inoxyz.ase, which imports ASE lazily; the core never depends on it.
Testing follows the shape of the promises: Rust unit and corpus tests for
the parser; parity tests holding parallel reads byte-identical to serial,
including which error wins when several frames are bad; golden tests
holding oxyz.ase.read equal to ase.io.read frame-by-frame; and
malformed-file tests asserting the frame index in the error message, not
just that an error occurred.
Roadmap
In rough order of intent, shaped by what removes the most reasons to fall back to other tools:
- Write support — lossless
Frameround-tripping, removing the most common reason to keep ASE in a read → filter → write workflow. - Field selection and a missing-key batching policy — request only the columns and metadata you need; NaN-fill or error on absent keys, so mixed-schema training files batch directly.
- Normalisation accessors —
positions,cell,numbers,pbc,forces,energyas conventional views over the untouched raw data, for training loops that want neither ASE nor the raw spelling. - Additional inputs and outputs — compressed input (
.xyz.gz,.xz),torch.Tensoroutput, and a public lazy dataset object (len, indexing, slicing over an open file).
Licence
MIT or Apache-2.0, at your option.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file oxyz-0.2.0.tar.gz.
File metadata
- Download URL: oxyz-0.2.0.tar.gz
- Upload date:
- Size: 79.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f9809937c00a7487614b02d10828606925439304a2c7bb785e208d82c8980e60
|
|
| MD5 |
d7bd68f781003055afeaf665ac26a505
|
|
| BLAKE2b-256 |
b100289bfeedfb2676485efbe11759c60ee301de3b279d041aa4c48c0d95a1f0
|
File details
Details for the file oxyz-0.2.0-cp311-abi3-win_amd64.whl.
File metadata
- Download URL: oxyz-0.2.0-cp311-abi3-win_amd64.whl
- Upload date:
- Size: 379.7 kB
- Tags: CPython 3.11+, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a284d8206177968e81db28bbde2f25d33878a869a12e8c064bf4e887deb79cd6
|
|
| MD5 |
112ddce3559ab1b99eb6778c4cf44ed3
|
|
| BLAKE2b-256 |
de818f11b2b838525ef2bdd7b5ff509504c51789d3bafbde15f1c2439b6ee324
|
File details
Details for the file oxyz-0.2.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: oxyz-0.2.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 2.5 MB
- Tags: CPython 3.11+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d4d1dc41745aad1cea67c051d47664bd83020283c96b4017cfbadf29295f5219
|
|
| MD5 |
5e9c6d69ebfb36320ef706c259080bdf
|
|
| BLAKE2b-256 |
6ec6be712a090b6832832f7a7e9b95efb65f7bb6eda88f084d39093d848a2135
|
File details
Details for the file oxyz-0.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: oxyz-0.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 2.2 MB
- Tags: CPython 3.11+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
80984a4bce837ce9f490684dec4d79d458bf7d44cb99d8b792e0f38f9962c717
|
|
| MD5 |
bf8403f96e379dc58fd14bd660aca28e
|
|
| BLAKE2b-256 |
e30a5bed70e08eeddea3cd703583e72d305329ef36f5a2d4c08393dc0d35ac6a
|
File details
Details for the file oxyz-0.2.0-cp311-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: oxyz-0.2.0-cp311-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 518.4 kB
- Tags: CPython 3.11+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13ba105a9131f21dca8140443573eac40ccbe63b3a42169723bcf6789f26b7dc
|
|
| MD5 |
09e2683fb072767f67ebedad5bd6fa42
|
|
| BLAKE2b-256 |
b1b5790fcbf577e61b43bb4669f9c539b0b3977450111484e9307bacc47b5143
|
File details
Details for the file oxyz-0.2.0-cp311-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: oxyz-0.2.0-cp311-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 531.5 kB
- Tags: CPython 3.11+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
456c0d14c3495d6578dacd97de494370adc6a9e56d7bbb1a6ab6f59e796da743
|
|
| MD5 |
b30f8d0379e66454a4412567b7950d16
|
|
| BLAKE2b-256 |
8afd7154749e1e4bb8692f7acc8f6b27f72cdaf09de51e7c315b23f7b56afb17
|