Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ members = ["crates/parser", "crates/cli", "crates/xtask"]
resolver = "3"

[workspace.package]
version = "0.1.3"
version = "0.2.0"
license = "MIT"
authors = [
"Javier Osorio M <javier.osorio.mosquera@gmail.com>",
"Julien Wist <julien.wist@correounivalle.edu.co>",
"Jorge Osorio M <joheosmo@gmail.com>"
]
edition = "2024"
rust-version = "1.85"
rust-version = "1.88"
repository = "https://github.com/phenological/ionic"
homepage = "https://github.com/phenological/ionic"

Expand All @@ -22,8 +22,8 @@ once_cell = "1.21.4"
quick-xml = "0.40.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0"
clap = "4.6.1"
cosmoz = { version = "0.1.1", features = ["std", "compression"] }
regex = "1.12.4"
clap = "4.6.7"
cosmoz = { version = "0.2.0", features = ["std", "compression"] }
regex = "1.13.1"
rayon = "1.12.0"
itoa = "1.0"
177 changes: 119 additions & 58 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,91 +46,152 @@ A command-line tool for converting mzML files to Ionic. See the [CLI](crates/cli

## Usage

### Convert

`ionic::convert` takes a path or a byte buffer. With `output` set it writes the file and returns `None`. With `output` empty it returns the result as `Some(bytes)`. `kind` defaults to `ConvertKind::Auto`, which reads the direction from the `.mzML` or `.ion` extension, and from the file signature for a buffer or a file without one of those extensions.
| Item | What it does |
|---|---|
| `ionic::read` / `ionic::write` | Whole `.ion` file to/from an `MzML`, in one call. (std) |
| `ionic::convert` | Convert an in-memory buffer between mzML and Ionic. |
| `ionic::convert_file` | Convert between two paths, streaming to disk. (std) |
| `IonReader::open` | Open a `.ion` file by path (memory-mapped). (std) |
| `IonReader::from_bytes` | Open a `.ion` file already in memory. |
| `IonReader::new` | Open from any `ionic::source::ReadBytes` (partial/remote reads). |
| `IonWriter::create` | Write a new `.ion` file by path. (std) |
| `IonWriter::to` | Write into any `ionic::source::WriteBytes` sink, such as a `Vec<u8>`. |
| `ionic::mzml` | mzML types and parser/serializer: `MzML`, `Spectrum`, `Chromatogram`, `NumericArray`, `parse_mzml`, `bin_to_mzml`, ... |
| `ionic::source` | Partial/remote-read building blocks: `ReadBytes`, `WriteBytes`, `ByteRange`, `CallbackSource`, `header_ranges`, `merge_ranges`. |
| `ionic::format` | File-format constants used by tooling: `CURRENT_VERSION`, `HEADER_SIZE`, `FILE_SIGNATURE`, `is_supported`, ... |

"std" means the item needs a real filesystem and is not available on `wasm32-unknown-unknown`; "everywhere" means it also works there.

### Read

```rust
use ionic::{ConvertKind, ConvertOptions, WriteOptions};

ionic::convert(Path::new("run.mzML"), ConvertOptions { output: Some("run.ion".into()), ..Default::default() })?;
ionic::convert(Path::new("run.ion"), ConvertOptions { output: Some("run.mzML".into()), ..Default::default() })?;

let ion_bytes = ionic::convert(Path::new("run.mzML"), ConvertOptions::default())?.unwrap();
let mzml_bytes = ionic::convert(&ion_bytes, ConvertOptions::default())?.unwrap();

ionic::convert(
&mzml_bytes,
ConvertOptions {
output: Some("run.ion".into()),
kind: ConvertKind::MzmlToIon,
write: WriteOptions { compression_level: 0, ..Default::default() },
..Default::default()
},
)?;
use ionic::{ArrayKind, IonReader, ReadOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let options = ReadOptions {
parallel: false,
..ReadOptions::default()
};
let mut reader = IonReader::open("run.ion", &options)?;
let mz = reader.spectrum_array(0, ArrayKind::Mz)?;
let intensity = reader.spectrum_array(0, ArrayKind::Intensity)?;
println!("{} points", mz.len().min(intensity.len()));
Ok(())
}
```

### Read an .ion file
### Streaming reader

```rust
use ionic::{ArrayKind, IonReader, ReadOptions};
Read one spectrum at a time. Memory stays small, even for large files.

let mut reader = IonReader::open_file(Path::new("run.ion"), ReadOptions::default())?;
let mz = reader.get_spectrum_array(0, ArrayKind::Mz)?;
let intensity = reader.get_spectrum_array(0, ArrayKind::Intensity)?;
```rust
use ionic::{IonReader, ReadOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut reader = IonReader::open("run.ion", &ReadOptions::default())?;
for index in 0..reader.spectrum_count() as usize {
let spectrum = reader.spectrum_metadata_at(index)?;
println!("{}", spectrum.id);
}
Ok(())
}
```

### Reading options — ReadOptions
`ReadOptions::default()`:

| Field | Default |
|---|---|
| `max_cached_bytes` | `256 * 1024 * 1024` (256 MiB decoded-block cache) |
| `verify_checksums` | `true` |
| `parallel` | `true` |
| `decompression_limit` | `DecompressionLimit::default()` (2 GiB uncompressed cap) |

### Partial reads — ionic::source

`byte_ranges` and `eic_byte_ranges` turn a query into the exact byte ranges a remote source
would need to fetch, without reading them; that is what lets a viewer such as
[ion-beam](https://github.com/phenological/ion-beam) show only the bytes it downloaded.

```rust
pub struct ReadOptions {
pub max_cached_bytes: usize, // decoded-block cache cap; default 256 MiB
pub verify_checksums: bool, // check CRCs + layout on open; default true
pub parallel: bool, // decode blocks in parallel; default true
pub decompression_limit: DecompressionLimit, // zip-bomb guard; type from ionic::ion::
use ionic::{IonReader, Range, ReadOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut reader = IonReader::open("run.ion", &ReadOptions::default())?;
let ranges = reader.byte_ranges(0, Range { from: 200.0, to: 400.0 })?;
for range in ranges {
println!("fetch bytes {}..{}", range.offset, range.offset + range.length);
}
Ok(())
}
```

### Write an .ion file

Memory
### Write

```rust
use ionic::{IonWriter, MemoryReader, MzML, Spectrum, WriteOptions};
use ionic::WriteOptions;

let mzml = MzML::from_spectra(vec![Spectrum::new("scan=1", vec![100.0, 200.0], vec![10.0, 20.0])]);
let mut source = MemoryReader::new(mzml);
let mut out: Vec<u8> = Vec::new();
let mut writer = IonWriter::create(&mut out, WriteOptions::default())?;
writer.write_stream(&mut source)?;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mzml = ionic::read("run.ion")?;
ionic::write("out.ion", &mzml, &WriteOptions::default())?;
Ok(())
}
```

File path
### Streaming writer

The metadata passed to `create`/`to` holds run-level data only. Add spectra and chromatograms
with `write_spectrum`/`write_chromatogram`, then call `finish`. `Drop` does not finish the file.
`write_stream` writes a whole stream and finishes the file in one call.

```rust
use ionic::{FileWriter, IonWriter, MemoryReader, MzML, Spectrum, WriteOptions};

let mzml = MzML::from_spectra(vec![Spectrum::new("scan=1", vec![100.0, 200.0], vec![10.0, 20.0])]);
let mut source = MemoryReader::new(mzml);
let mut output = FileWriter::open_path(Path::new("out.ion"))?; // or FileWriter::open("out.ion")
let mut writer = IonWriter::create(&mut output, WriteOptions::default())?;
writer.write_stream(&mut source)?;
output.flush()?;
use ionic::{IonWriter, WriteOptions, mzml::parse_mzml};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mzml = parse_mzml(&std::fs::read("run.mzML")?)?;

let mut writer = IonWriter::create("out.ion", &mzml, &WriteOptions::default())?;
for spectrum in mzml.run.spectrum_list.iter().flat_map(|list| &list.spectra) {
writer.write_spectrum(spectrum)?;
}
writer.finish()?;
Ok(())
}
```

### WriteOptions
`WriteOptions::default()`:

| Field | Default |
|---|---|
| `compression_level` | `12` (0 = off, 1..=22 zstd) |
| `force_f32` | `false` |
| `block_size` | `1024 * 1024` (1 MiB target uncompressed block) |
| `parallel` | `true` |
| `section_storage` | `SectionStorage::Disk` |
| `mz_window` | `20.0` |

### Convert

```rust
pub struct WriteOptions {
pub compression_level: u8, // 0 = off, 1..=22 zstd; default 12
pub force_f32: bool, // narrow f64 arrays to f32 (lossy); default false
pub block_size: usize, // target uncompressed block bytes; default 1 MiB
pub parallel: bool, // default true
pub section_storage: SectionStorage, // Memory or Disk; default Disk
pub mz_window: f64, // m/z window width for range-read indexing; default 100.0
use ionic::ConvertOptions;

fn main() -> Result<(), Box<dyn std::error::Error>> {
ionic::convert_file("run.mzML", "run.ion", &ConvertOptions::default())?;

let ion_bytes = std::fs::read("run.ion")?;
let mzml_bytes = ionic::convert(&ion_bytes, &ConvertOptions::default())?;
assert!(mzml_bytes.starts_with(b"<?xml"));
Ok(())
}
```

`ConvertOptions::default()`:

| Field | Default |
|---|---|
| `kind` | `ConvertKind::Auto` (sniffs the extension, then the file signature) |
| `read` | `ReadOptions::default()` |
| `write` | `WriteOptions::default()` |

## How the format works

A fixed 1024-byte header stores the byte offset of every section. A reader uses these offsets to go directly to the index, then decompresses only the blocks a query needs. [spec/README.MD](spec/README.MD) specifies the design; [spec/v1.md](spec/v1.md) gives the exact byte layout.
Expand Down
8 changes: 8 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
[
{
"package": "0.2.0",
"format": {
"current": 1,
"min_supported": 0,
"max_supported": 1
}
},
{
"package": "0.1.3",
"format": {
Expand Down
7 changes: 4 additions & 3 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ name = "cli"
version.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false

[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
clap = { workspace = true, features = ["derive"] }
ionic = { package = "ionic-rs", path = "../parser", version = "0.1.2" }
ionic = { package = "ionic-rs", path = "../parser", version = "0.2.0" }
regex = { workspace = true }
rayon = { workspace = true }
mimalloc = { version = "0.1.48" }
crc32fast = "1.5.0"
mimalloc = { version = "0.1.52" }
crc32fast = "1.5.2"
memmap2 = "0.9"
cosmoz = { workspace = true }

Expand Down
Loading
Loading