Repository navigation
Binary arrays
The numbers in an mzML file, the m/z and intensity values that are the whole point of the file, are stored as Base64 text inside <binary> elements. This page explains the encodings, what zmz does with each, and how to use the codecs directly.
Every array goes through the same steps, some of them optional:
flowchart LR
values["typed values<br/>f32, f64, i32, i64"] --> np["MS-Numpress<br/>(optional)"]
np --> z["zlib<br/>(optional)"]
z --> b64[Base64]
b64 --> text["text of the binary element"]
Decoding runs it backwards. Two cvParams on the array say which steps were used: one data type and one compression term.
Data types:
| Accession | Name | zmz type |
|---|---|---|
MS:1000521 |
32-bit float | f32 |
MS:1000523 |
64-bit float | f64 |
MS:1000519 |
32-bit integer | i32 |
MS:1000522 |
64-bit integer | i64 |
Compression:
| Accession | Pipeline | Lossy |
|---|---|---|
MS:1000576 |
.none |
no |
MS:1000574 |
.zlib |
no |
MS:1002312 |
.linear |
yes |
MS:1002313 |
.pic |
yes |
MS:1002314 |
.slof |
yes |
MS:1002746 |
.linear_zlib |
yes |
MS:1002747 |
.pic_zlib |
yes |
MS:1002748 |
.slof_zlib |
yes |
- Primitive bytes are little-endian, and float bits are kept exactly: a
NaNpayload survives a round trip. - Every MS-Numpress array decodes to
f64. SetDecodedArrays.Options.numpress_precision = .declaredto getf32back for arrays declared as 32-bit floats. - A term repeated through a parameter group must agree with the array's own, or the read fails with
ConflictingPrimitiveorConflictingCompression. A missing one isMissingPrimitiveorMissingCompression.
Warning
Some encodings exist in the vocabulary that zmz does not implement: 16-bit floats, null-terminated text, Zstandard, byte shuffling, truncation, and coordinate grids. They fail with UnsupportedEncoding instead of being decoded as something they are not. Reading such an array's metadata, skipping it with a selector, or copying its text unchanged still works.
MS-Numpress is a family of three lossy transforms that make mass-spec arrays compress far better, at a precision cost you choose. zmz's encoder writes the same bytes as the C++ reference wherever the reference's input is valid.
All three end in the same integer code: each integer is written as a variable number of half-bytes, so small numbers take one or two half-bytes instead of four bytes.
For sorted, evenly growing values like m/z. Each value is scaled by a fixed point
The first two are stored as they are. Every later value is predicted from the two before it, and only the residual is stored:
For an m/z array the prediction is almost right, so the residuals are tiny, and the half-byte code stores most of them in a byte or less. Decoding reverses it exactly, so the only loss is the first rounding:
The largest numpress.optimalLinearFixedPoint computes it, as the reference does. To ask for a precision instead, linearFixedPointForAbsoluteError(values, max_abs_error) returns ToleranceUnattainable when that does not fit.
For counts, like ion intensities from a counting detector. Each value is rounded to the nearest integer and written with the half-byte code; no fixed point is involved. Values must lie in
For intensities, where relative precision matters and absolute precision does not. Each value is stored in 16 bits on a log scale:
Since
numpress.optimalSlofFixedPoint picks
Note
SLOF depends on exp and log, and different math libraries round them differently in the last bit, which changes a code now and then. zmz computes them exactly as glibc does, so its SLOF bytes and values match C++ ms-numpress and numpress-rs on Linux. glibc itself has two forms, with and without fused multiply-add, which disagree on a small fraction of inputs; zmz uses the fused form when the build target has FMA (x86-64-v3, aarch64) and the plain form otherwise, so a baseline x86-64 build matches glibc's plain form, and -Dcpu=native on an FMA machine matches the fused one.
Readers decode for you, but the codecs are public too: mzml.arrays for whole pipelines, plus numpress, base64, zlib, and sha1 on their own. This encodes four m/z values with Linear prediction and zlib, decodes them, and checks the bound above:
fn roundTrip(allocator: std.mem.Allocator) !void {
const values = [_]f64{ 400.1234, 400.2468, 400.3702, 401.0 };
const fixed_point = try mzml.numpress.optimalLinearFixedPoint(&values);
var encoder = mzml.arrays.Encoder.init(allocator, .fast);
defer encoder.deinit();
const wire = try encoder.encode(f64, &values, .{ .pipeline = .linear_zlib, .fixed_point = fixed_point });
var decoded: [values.len]f64 = undefined;
var transformed: [64]u8 = undefined;
try mzml.arrays.decodeWire(f64, &decoded, wire, .linear_zlib, &transformed);
for (values, decoded) |before, after| std.debug.assert(@abs(before - after) <= 0.5 / fixed_point);
}-
"Wire" bytes are everything before Base64.
arrays.encodeandarrays.decodeinclude the Base64 step;encodeWireanddecodeWirestop before it. -
Only
arrays.Encoderallocates. It owns the zlib compressor and one output buffer per encoded array until itsreset. Every other function works in buffers you pass, and refuses overlapping ones withOverlappingBuffers. -
zlib presets are
.fast,.even, and.dense, from fastest to smallest. Compressed bytes can differ between zlib implementations and versions; they always decode to the same values. -
Streaming.
DecodedArraysdecodes text as it arrives, andnumpress.Decodertakes a payload in fragments of any size, giving the same values and errors for every split.
Base64 decodes with AVX2, zlib inflates with a decoder built for x86-64-v3, and both are picked at run time on CPUs that have them, with portable code everywhere else. Performance explains how that works and how to turn it off.
zmz | Changelog | Contributing | Security | Apache License 2.0
Start here
Using zmz
- Reading
- Ownership and lifetimes
- Indexed access
- Writing
- Binary arrays
- Metadata and parameters
- Vocabulary
When things go wrong
Under the hood