-
Notifications
You must be signed in to change notification settings - Fork 3
Refactor/output direct to h5ad #250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev-0.4.7
Are you sure you want to change the base?
Changes from all commits
1c8212d
e1ff86e
69b4021
d7abd78
e0ad089
4f4d63e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # Changelog | ||
|
|
||
| All notable changes to this project will be documented in this file. | ||
|
|
||
| The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), | ||
| and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | ||
|
|
||
| ## [Unreleased] | ||
|
|
||
| ### Changed | ||
|
|
||
| - `cyto workflow gex`/`crispr` (and `cyto ibu count --h5ad`) now write `.h5ad` output | ||
| natively from Rust instead of staging through MTX and shelling out to `pycyto convert`. | ||
| `cyto-ibu-count` builds the sparse count matrix directly from its in-memory dedup | ||
| table and writes it via the `anndata`/`anndata-hdf5` crates, so h5ad output no longer | ||
| round-trips through on-disk MTX text files or spawns a Python subprocess. | ||
| - `cell-filter` (GEX droplet filtering) and `geomux` (CRISPR guide assignment) are | ||
| unchanged and still run as external Python tools via `uv`, invoked on the natively | ||
| written `.h5ad`. | ||
| - `pycyto` is no longer installed or invoked anywhere in `cyto workflow`. It remains | ||
| useful standalone for aggregating multiple `cyto` outputs into one `h5ad` | ||
| (`pycyto aggregate`), documented in the README. | ||
| - The h5ad `X` matrix is now stored as unsigned 32-bit integers rather than the | ||
| float32 that `pycyto convert` produced by default. Both `cell-filter` and `geomux` | ||
| already cast counts back to `int` internally, so this has no behavioral effect | ||
| downstream. | ||
|
|
||
| ### Added | ||
|
|
||
| - `cyto ibu count --h5ad`: writes a native AnnData `.h5ad` file directly (mutually | ||
| exclusive with `--mtx`). | ||
| - New `cyto-ibu-count` dependencies: `anndata`, `anndata-hdf5` (statically builds | ||
| `libhdf5` from source via `hdf5-metno-sys`, requires `cmake` at build time — no | ||
| system `libhdf5` needed at runtime), and `nalgebra-sparse`. | ||
|
|
||
| ### Fixed | ||
|
|
||
| - A file-locking race: `cyto-workflow` processes probes concurrently via Rayon, and | ||
| HDF5's advisory file locking could spuriously report a just-written, already-closed | ||
| `.h5ad` as locked while a sibling probe's `.h5ad` was still open elsewhere in the | ||
| same process. `cell-filter`/`geomux` subprocess invocations now set | ||
| `HDF5_USE_FILE_LOCKING=FALSE` to avoid this. | ||
|
|
||
| ### Removed | ||
|
|
||
| - `convert_to_h5ad()` and the `pycyto` external-tool install path | ||
| (`VERSION_PYCYTO`, the `uv tool install pycyto` step in | ||
| `ArgsWorkflow::validate_requirements()`). | ||
| - `Module::ConversionH5ad` timing entry (h5ad write time is now folded into the | ||
| `Counting` step) and `ArgsWorkflow::mtx()` (no longer needed now that h5ad output | ||
| doesn't stage through MTX). |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,86 @@ | ||||||||||||||||||||||||||
| use std::path::Path; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| use anndata::backend::{Compression, WriteConfig, set_default_write_config}; | ||||||||||||||||||||||||||
| use anndata::data::DataFrameIndex; | ||||||||||||||||||||||||||
| use anndata::{AnnData, AnnDataOp}; | ||||||||||||||||||||||||||
| use anndata_hdf5::H5; | ||||||||||||||||||||||||||
| use anyhow::Result; | ||||||||||||||||||||||||||
| use hashbrown::HashMap; | ||||||||||||||||||||||||||
| use ibu::Header; | ||||||||||||||||||||||||||
| use log::info; | ||||||||||||||||||||||||||
| use nalgebra_sparse::coo::CooMatrix; | ||||||||||||||||||||||||||
| use nalgebra_sparse::csr::CsrMatrix; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| use crate::dedup::BarcodeIndexCounts; | ||||||||||||||||||||||||||
| use crate::extend_suffix; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// Writes a barcode-by-feature count matrix directly to a `.h5ad` file. | ||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||
| /// Uses gzip rather than this crate's zstd default so the file is readable by | ||||||||||||||||||||||||||
| /// plain `h5py`/`anndata` without requiring the optional `hdf5plugin` package. | ||||||||||||||||||||||||||
| pub fn write_counts_h5ad<P: AsRef<Path>>( | ||||||||||||||||||||||||||
| path: P, | ||||||||||||||||||||||||||
| counts: &BarcodeIndexCounts, | ||||||||||||||||||||||||||
| features: &[String], | ||||||||||||||||||||||||||
| header: Header, | ||||||||||||||||||||||||||
| suffix: Option<&str>, | ||||||||||||||||||||||||||
| ) -> Result<()> { | ||||||||||||||||||||||||||
| set_default_write_config(WriteConfig { | ||||||||||||||||||||||||||
| compression: Some(Compression::Gzip(6)), | ||||||||||||||||||||||||||
| block_size: None, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let n_var = features.len(); | ||||||||||||||||||||||||||
| let n_obs = counts.get_num_barcodes(); | ||||||||||||||||||||||||||
| let nnz = counts.get_nnz(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let mut obs_names = Vec::with_capacity(n_obs); | ||||||||||||||||||||||||||
| let mut bc_idx_map = HashMap::with_capacity(n_obs); | ||||||||||||||||||||||||||
|
Comment on lines
+33
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using
Suggested change
|
||||||||||||||||||||||||||
| let mut row_idx = Vec::with_capacity(nnz); | ||||||||||||||||||||||||||
| let mut col_idx = Vec::with_capacity(nnz); | ||||||||||||||||||||||||||
| let mut vals: Vec<u32> = Vec::with_capacity(nnz); | ||||||||||||||||||||||||||
| let mut dbuf = Vec::default(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for record in counts.iter_counts() { | ||||||||||||||||||||||||||
| let obs_idx = if let Some(idx) = bc_idx_map.get(&record.barcode()) { | ||||||||||||||||||||||||||
| *idx | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| dbuf.clear(); | ||||||||||||||||||||||||||
| bitnuc::from_2bit(record.barcode(), header.bc_len as usize, &mut dbuf)?; | ||||||||||||||||||||||||||
| extend_suffix(&mut dbuf, suffix); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let obs_idx = obs_names.len(); | ||||||||||||||||||||||||||
| obs_names.push(std::str::from_utf8(&dbuf)?.to_string()); | ||||||||||||||||||||||||||
| bc_idx_map.insert(record.barcode(), obs_idx); | ||||||||||||||||||||||||||
| obs_idx | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| row_idx.push(obs_idx); | ||||||||||||||||||||||||||
| col_idx.push(record.index() as usize); | ||||||||||||||||||||||||||
| vals.push(record.count() as u32); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let coo = CooMatrix::try_from_triplets(n_obs, n_var, row_idx, col_idx, vals) | ||||||||||||||||||||||||||
| .map_err(|e| anyhow::anyhow!("Unable to build sparse count matrix: {e}"))?; | ||||||||||||||||||||||||||
|
Comment on lines
+63
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use
Suggested change
|
||||||||||||||||||||||||||
| let csr = CsrMatrix::from(&coo); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if let Some(parent) = path.as_ref().parent() { | ||||||||||||||||||||||||||
| std::fs::create_dir_all(parent)?; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if path.as_ref().exists() { | ||||||||||||||||||||||||||
| std::fs::remove_file(path.as_ref())?; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let adata = AnnData::<H5>::new(path.as_ref())?; | ||||||||||||||||||||||||||
| adata.set_x(csr)?; | ||||||||||||||||||||||||||
| adata.set_obs_names(DataFrameIndex::from(obs_names))?; | ||||||||||||||||||||||||||
| adata.set_var_names(DataFrameIndex::from(features.to_vec()))?; | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since
Suggested change
|
||||||||||||||||||||||||||
| adata.close()?; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| info!( | ||||||||||||||||||||||||||
| "Finished writing h5ad counts to {}", | ||||||||||||||||||||||||||
| path.as_ref().display() | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Ok(()) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,9 @@ use ibu::{Header, Reader}; | |
| use log::{debug, error, info}; | ||
|
|
||
| mod dedup; | ||
| mod h5ad; | ||
| pub use dedup::{BarcodeIndexCount, BarcodeIndexCounts, deduplicate_umis}; | ||
| use h5ad::write_counts_h5ad; | ||
|
|
||
| /// Extends a barcode buffer with an optional suffix | ||
| fn extend_suffix(buffer: &mut Vec<u8>, suffix: Option<&str>) { | ||
|
|
@@ -326,7 +328,19 @@ pub fn run(args: &ArgsCount) -> Result<()> { | |
| } | ||
| } | ||
|
|
||
| if args.mtx { | ||
| if args.h5ad { | ||
| write_counts_h5ad( | ||
| args.output | ||
| .as_ref() | ||
| .expect("Must provide an output path to write h5ad"), | ||
| &counts, | ||
| features | ||
| .expect("Must provide a feature file to write h5ad") | ||
| .as_slice(), | ||
|
Comment on lines
+337
to
+339
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| header, | ||
| args.suffix.as_deref(), | ||
| ) | ||
| } else if args.mtx { | ||
| write_counts_mtx( | ||
| args.output | ||
| .as_ref() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To avoid cloning the entire vector of feature strings (which can contain tens of thousands of elements), we can pass
featuresby value asVec<String>instead of a slice&[String]. Sincewrite_counts_h5adis the final step in the counting process and consumes the features, passing by value is highly efficient and avoids unnecessary heap allocations.