Skip to main content

bixverse_sc_data/
h5ad_multifile_io.rs

1//! Multi-h5ad loading: scan multiple h5ad files, apply global gene QC,
2//! and write all cells into a single binary file.
3
4use hdf5::File;
5use ndarray::{Array2, s};
6use rayon::prelude::*;
7use std::path::Path;
8use std::time::Instant;
9use thousands::Separable;
10
11use super::data_io::*;
12use super::h5_filters::check_h5ad_filters;
13use super::h5ad_io::*;
14use crate::h5_chunked::H5ChunkedReader;
15use crate::prelude::*;
16
17//////////////////
18// H5File tasks //
19//////////////////
20
21/// Per-file task descriptor for multi-h5ad loading
22pub struct H5adFileTask {
23    /// Experimental identifier
24    pub exp_id: String,
25    /// Path to the h5ad file
26    pub h5_path: String,
27    /// Type of storage in this specific h5ad file
28    pub cs_type: H5ADFormat,
29    /// Number of cells/spots in this h5ad file
30    pub no_cells: usize,
31    /// Number of genes/features in this h5ad file
32    pub no_genes: usize,
33    /// file-local gene idx -> universe gene idx; None if gene not in universe
34    pub gene_local_to_universe: Vec<Option<usize>>,
35    /// Raw count storage
36    pub raw_slot: RawDataSlot,
37}
38
39//////////////////
40// H5File scans //
41//////////////////
42
43//////////////
44// Features //
45//////////////
46
47/// Scan per given task the number of NNZ for the features
48///
49/// For CSR-stored h5ad files.
50///
51/// ### Params
52///
53/// * `task` - The H5adFileTask
54/// * `universe_size` - Total number of features in this universe
55///
56/// ### Returns
57///
58/// Number of NNZ per given task/feature
59fn scan_gene_nnz_csr(
60    task: &H5adFileTask,
61    universe_size: usize,
62) -> Result<Vec<usize>, ScDataErrors> {
63    let file = File::open(&task.h5_path)?;
64
65    check_h5ad_is_raw(&file, &task.raw_slot, None)?;
66
67    let indptr = read_indptr(&file.dataset(task.raw_slot.get_indptr())?)?;
68    let indices_rd = H5ChunkedReader::new(file.dataset(task.raw_slot.get_indices())?);
69
70    let mut gene_nnz = vec![0usize; universe_size];
71
72    const CHUNK_SIZE: usize = 10_000;
73
74    for chunk_start in (0..task.no_cells).step_by(CHUNK_SIZE) {
75        let chunk_end = (chunk_start + CHUNK_SIZE).min(task.no_cells);
76        let data_start = indptr[chunk_start];
77        let data_end = indptr[chunk_end];
78
79        if data_start >= data_end {
80            continue;
81        }
82
83        let chunk_indices: Vec<u32> = indices_rd.read(data_start..data_end)?;
84
85        for cell_idx in chunk_start..chunk_end {
86            let start = indptr[cell_idx] - data_start;
87            let end = indptr[cell_idx + 1] - data_start;
88
89            for local_idx in start..end {
90                let gene_idx = chunk_indices[local_idx] as usize;
91                if let Some(&Some(u_idx)) = task.gene_local_to_universe.get(gene_idx) {
92                    gene_nnz[u_idx] += 1;
93                }
94            }
95        }
96    }
97
98    Ok(gene_nnz)
99}
100
101/// Scan per given task the number of NNZ for the features
102///
103/// For CSC-stored h5ad files.
104///
105/// ### Params
106///
107/// * `task` - The H5adFileTask
108/// * `universe_size` - Total number of features in this universe
109///
110/// ### Returns
111///
112/// Number of NNZ per given task/feature
113fn scan_gene_nnz_csc(
114    task: &H5adFileTask,
115    universe_size: usize,
116) -> Result<Vec<usize>, ScDataErrors> {
117    let file = File::open(&task.h5_path)?;
118
119    check_h5ad_is_raw(&file, &task.raw_slot, None)?;
120
121    let indptr = read_indptr(&file.dataset(task.raw_slot.get_indptr())?)?;
122
123    let mut gene_nnz = vec![0usize; universe_size];
124
125    for (local, opt) in task.gene_local_to_universe.iter().enumerate() {
126        if let Some(u_idx) = opt {
127            gene_nnz[*u_idx] = indptr[local + 1] - indptr[local];
128        }
129    }
130
131    Ok(gene_nnz)
132}
133
134/// Per-task gene nnz scan for `dense_row`.
135///
136/// ### Params
137///
138/// * `task` - The H5adFileTask
139/// * `universe_size` - Total number of features in this universe
140///
141/// ### Returns
142///
143/// Number of NNZ per given task/feature
144pub fn scan_gene_nnz_dense_row(
145    task: &H5adFileTask,
146    universe_size: usize,
147) -> Result<Vec<usize>, ScDataErrors> {
148    let file = File::open(&task.h5_path)?;
149    check_h5ad_dense_is_raw(&file, &task.raw_slot, None)?;
150
151    let ds = file.dataset(task.raw_slot.get_dense_path())?;
152    let mut gene_nnz = vec![0usize; universe_size];
153
154    const CHUNK_SIZE: usize = 10_000;
155    for chunk_start in (0..task.no_cells).step_by(CHUNK_SIZE) {
156        let chunk_end = (chunk_start + CHUNK_SIZE).min(task.no_cells);
157        let block: Array2<f32> = ds.read_slice_2d(s![chunk_start..chunk_end, 0..task.no_genes])?;
158        for row in block.outer_iter() {
159            for (gene_idx, &val) in row.iter().enumerate() {
160                if val != 0.0
161                    && let Some(&Some(u_idx)) = task.gene_local_to_universe.get(gene_idx)
162                {
163                    gene_nnz[u_idx] += 1;
164                }
165            }
166        }
167    }
168
169    Ok(gene_nnz)
170}
171
172/// Scan per given task the number of NNZ for the features
173///
174/// ### Params
175///
176/// * `task` - The H5adFileTask
177/// * `universe_size` - Total number of features in this universe
178///
179/// ### Returns
180///
181/// Number of NNZ per given task/feature
182fn scan_gene_nnz(task: &H5adFileTask, universe_size: usize) -> Result<Vec<usize>, ScDataErrors> {
183    match task.cs_type {
184        H5ADFormat::Csr => scan_gene_nnz_csr(task, universe_size),
185        H5ADFormat::Csc => scan_gene_nnz_csc(task, universe_size),
186        H5ADFormat::DenseRow => scan_gene_nnz_dense_row(task, universe_size),
187        H5ADFormat::DenseCol => dense_col_not_supported(),
188    }
189}
190
191///////////
192// Cells //
193///////////
194
195/// Scan per given task the cell/spot quality measures
196///
197/// For CSR-stored h5ad files.
198///
199/// ### Params
200///
201/// * `task` - The H5adFileTask
202/// * `gene_local_to_final` - Genes to include
203///
204/// ### Returns
205///
206/// Vector of (unique_genes, library_size) per cell
207fn scan_cell_stats_csr(
208    task: &H5adFileTask,
209    gene_local_to_final: &[Option<usize>],
210) -> Result<Vec<(usize, f32)>, ScDataErrors> {
211    let file = File::open(&task.h5_path)?;
212
213    check_h5ad_is_raw(&file, &task.raw_slot, None)?;
214
215    let indptr = read_indptr(&file.dataset(task.raw_slot.get_indptr())?)?;
216    let data_rd = H5ChunkedReader::new(file.dataset(task.raw_slot.get_data())?);
217    let indices_rd = H5ChunkedReader::new(file.dataset(task.raw_slot.get_indices())?);
218
219    let mut cell_stats = Vec::with_capacity(task.no_cells);
220
221    const CHUNK_SIZE: usize = 10_000;
222
223    for chunk_start in (0..task.no_cells).step_by(CHUNK_SIZE) {
224        let chunk_end = (chunk_start + CHUNK_SIZE).min(task.no_cells);
225        let data_start = indptr[chunk_start];
226        let data_end = indptr[chunk_end];
227
228        if data_start >= data_end {
229            cell_stats.extend((chunk_start..chunk_end).map(|_| (0usize, 0.0f32)));
230            continue;
231        }
232
233        let chunk_data: Vec<f32> = data_rd.read(data_start..data_end)?;
234        let chunk_indices: Vec<u32> = indices_rd.read(data_start..data_end)?;
235
236        for cell_idx in chunk_start..chunk_end {
237            let start = indptr[cell_idx] - data_start;
238            let end = indptr[cell_idx + 1] - data_start;
239
240            let mut unique = 0usize;
241            let mut lib_size = 0.0f32;
242
243            for local_idx in start..end {
244                let gene_idx = chunk_indices[local_idx] as usize;
245                if let Some(&Some(_)) = gene_local_to_final.get(gene_idx) {
246                    unique += 1;
247                    lib_size += chunk_data[local_idx];
248                }
249            }
250
251            cell_stats.push((unique, lib_size));
252        }
253    }
254
255    Ok(cell_stats)
256}
257
258/// Scan per given task the cell/spot quality measures
259///
260/// For CSC-stored h5ad files.
261///
262/// ### Params
263///
264/// * `task` - The H5adFileTask
265/// * `gene_local_to_final` - Genes to include
266///
267/// ### Returns
268///
269/// Vector of (unique_genes, library_size) per cell
270fn scan_cell_stats_csc(
271    task: &H5adFileTask,
272    gene_local_to_final: &[Option<usize>],
273) -> Result<Vec<(usize, f32)>, ScDataErrors> {
274    let file = File::open(&task.h5_path)?;
275
276    check_h5ad_is_raw(&file, &task.raw_slot, None)?;
277
278    let indptr = read_indptr(&file.dataset(task.raw_slot.get_indptr())?)?;
279    let data_rd = H5ChunkedReader::new(file.dataset(task.raw_slot.get_data())?);
280    let indices_rd = H5ChunkedReader::new(file.dataset(task.raw_slot.get_indices())?);
281
282    let mut cell_unique = vec![0usize; task.no_cells];
283    let mut cell_lib_size = vec![0.0f32; task.no_cells];
284
285    let genes_with_final: Vec<usize> = gene_local_to_final
286        .iter()
287        .enumerate()
288        .filter_map(|(local, opt)| opt.map(|_| local))
289        .collect();
290
291    const GENE_CHUNK_SIZE: usize = 5_000;
292
293    for gene_chunk in genes_with_final.chunks(GENE_CHUNK_SIZE) {
294        let start_pos = gene_chunk.iter().map(|&g| indptr[g]).min().unwrap_or(0);
295        let end_pos = gene_chunk.iter().map(|&g| indptr[g + 1]).max().unwrap_or(0);
296
297        if start_pos >= end_pos {
298            continue;
299        }
300
301        let chunk_data: Vec<f32> = data_rd.read(start_pos..end_pos)?;
302        let chunk_indices: Vec<u32> = indices_rd.read(start_pos..end_pos)?;
303
304        for &local_gene in gene_chunk {
305            let gene_start = indptr[local_gene] - start_pos;
306            let gene_end = indptr[local_gene + 1] - start_pos;
307
308            for idx in gene_start..gene_end {
309                let cell_idx = chunk_indices[idx] as usize;
310                if cell_idx < task.no_cells {
311                    cell_unique[cell_idx] += 1;
312                    cell_lib_size[cell_idx] += chunk_data[idx];
313                }
314            }
315        }
316    }
317
318    Ok(cell_unique.into_iter().zip(cell_lib_size).collect())
319}
320
321/// Per-task cell stats scan for `dense_row`.
322///
323/// ### Params
324///
325/// * `task` - The H5adFileTask
326/// * `gene_local_to_final` - Genes to include
327///
328/// ### Returns
329///
330/// Vector of (unique_genes, library_size) per cell
331pub fn scan_cell_stats_dense_row(
332    task: &H5adFileTask,
333    gene_local_to_final: &[Option<usize>],
334) -> Result<Vec<(usize, f32)>, ScDataErrors> {
335    let file = File::open(&task.h5_path)?;
336    check_h5ad_dense_is_raw(&file, &task.raw_slot, None)?;
337
338    let ds = file.dataset(task.raw_slot.get_dense_path())?;
339    let mut cell_stats: Vec<(usize, f32)> = Vec::with_capacity(task.no_cells);
340
341    const CHUNK_SIZE: usize = 10_000;
342    for chunk_start in (0..task.no_cells).step_by(CHUNK_SIZE) {
343        let chunk_end = (chunk_start + CHUNK_SIZE).min(task.no_cells);
344        let block: Array2<f32> = ds.read_slice_2d(s![chunk_start..chunk_end, 0..task.no_genes])?;
345        for row in block.outer_iter() {
346            let mut unique = 0usize;
347            let mut lib_size = 0.0f32;
348            for (gene_idx, &val) in row.iter().enumerate() {
349                if val == 0.0 {
350                    continue;
351                }
352                if let Some(&Some(_)) = gene_local_to_final.get(gene_idx) {
353                    unique += 1;
354                    lib_size += val;
355                }
356            }
357            cell_stats.push((unique, lib_size));
358        }
359    }
360
361    Ok(cell_stats)
362}
363
364/// Scan per given task the cell/spot quality measures
365///
366/// Dispatches to the respective helper
367///
368/// ### Params
369///
370/// * `task` - The H5adFileTask
371/// * `gene_local_to_final` - Genes to include
372///
373/// ### Returns
374///
375/// Vector of (unique_genes, library_size) per cell
376fn scan_cell_stats(
377    task: &H5adFileTask,
378    gene_local_to_final: &[Option<usize>],
379) -> Result<Vec<(usize, f32)>, ScDataErrors> {
380    match task.cs_type {
381        H5ADFormat::Csr => scan_cell_stats_csr(task, gene_local_to_final),
382        H5ADFormat::Csc => scan_cell_stats_csc(task, gene_local_to_final),
383        H5ADFormat::DenseRow => scan_cell_stats_dense_row(task, gene_local_to_final),
384        H5ADFormat::DenseCol => dense_col_not_supported(),
385    }
386}
387
388///////////////////
389// H5file writes //
390///////////////////
391
392/// Per-file QC output returned to caller
393pub struct H5FileQcResult {
394    /// Experimental identifier
395    pub exp_id: String,
396    /// Cells/spots to keep
397    pub cells_to_keep: Vec<usize>,
398    /// Library sizes of the cells
399    pub lib_size: Vec<usize>,
400    /// Number of cells/spots expressing the gene
401    pub nnz: Vec<usize>,
402}
403
404/// Final result from multi-h5ad loading
405pub struct MultiH5adResult {
406    /// Global gene indices
407    pub global_gene_indices: Vec<usize>,
408    /// Total cells/spots ingested
409    pub total_cells: usize,
410    /// Total genes/features/ingested
411    pub total_genes: usize,
412    /// Per file QC information as a vector
413    pub per_file: Vec<H5FileQcResult>,
414}
415
416/// Write the cells from an H5adFileTask (in CSR)
417///
418/// ### Params
419///
420/// * `task` - The H5adFileTask
421/// * `cells_to_keep` - Which cells to keep from this file
422/// * `gene_mapping` - Informations on the gene mapping
423/// * `target_size` - The target size to normalise to
424/// * `cell_offset` - Current offset in the binary file
425/// * `writer` - The CellGeneSparseWriter to write to the binary file
426///
427/// ### Returns
428///
429/// An H5FileQcResult
430fn write_h5_csr_cells(
431    task: &H5adFileTask,
432    cells_to_keep: &[usize],
433    gene_mapping: &[Option<usize>],
434    target_size: f32,
435    cell_offset: usize,
436    writer: &mut CellGeneSparseWriter,
437) -> Result<H5FileQcResult, ScDataErrors> {
438    let indptr = {
439        let file = hdf5::File::open(&task.h5_path)?;
440        check_h5ad_is_raw(&file, &task.raw_slot, None)?;
441        read_indptr(&file.dataset(task.raw_slot.get_indptr())?)?
442    };
443
444    let (nnz, lib_size) = write_h5_cell_major_cells(
445        Path::new(&task.h5_path),
446        task.raw_slot.get_data(),
447        task.raw_slot.get_indices(),
448        &indptr,
449        cells_to_keep,
450        &dense_gene_map_from_options(gene_mapping),
451        cell_offset,
452        target_size,
453        writer,
454        false,
455    )?;
456
457    Ok(H5FileQcResult {
458        exp_id: task.exp_id.clone(),
459        cells_to_keep: cells_to_keep.to_vec(),
460        lib_size,
461        nnz,
462    })
463}
464
465/// Write the cells from an H5adFileTask (in CSC)
466///
467/// ### Params
468///
469/// * `task` - The H5adFileTask
470/// * `cells_to_keep` - Which cells to keep from this file
471/// * `gene_mapping` - Informations on the gene mapping
472/// * `target_size` - The target size to normalise to
473/// * `cell_offset` - Current offset in the binary file
474/// * `writer` - The CellGeneSparseWriter to write to the binary file
475///
476/// ### Returns
477///
478/// An H5FileQcResult
479fn write_h5_csc_cells(
480    task: &H5adFileTask,
481    cells_to_keep: &[usize],
482    gene_mapping: &[Option<usize>],
483    target_size: f32,
484    cell_offset: usize,
485    writer: &mut CellGeneSparseWriter,
486) -> Result<H5FileQcResult, ScDataErrors> {
487    let file = hdf5::File::open(&task.h5_path)?;
488
489    check_h5ad_is_raw(&file, &task.raw_slot, None)?;
490
491    let data_rd = H5ChunkedReader::new(file.dataset(task.raw_slot.get_data())?);
492    let indices_rd = H5ChunkedReader::new(file.dataset(task.raw_slot.get_indices())?);
493    let indptr = read_indptr(&file.dataset(task.raw_slot.get_indptr())?)?;
494
495    let cell_map = dense_index_map(cells_to_keep);
496
497    // (gene_index, raw_count) - gene index as u32 to support >65k features
498    let mut cell_data: Vec<Vec<(u32, u32)>> = vec![Vec::new(); cells_to_keep.len()];
499
500    let genes_with_final: Vec<(usize, usize)> = gene_mapping
501        .iter()
502        .enumerate()
503        .filter_map(|(local, opt)| opt.map(|f| (local, f)))
504        .collect();
505
506    const GENE_CHUNK_SIZE: usize = 5_000;
507
508    for gene_chunk in genes_with_final.chunks(GENE_CHUNK_SIZE) {
509        let start_pos = gene_chunk
510            .iter()
511            .map(|&(local, _)| indptr[local])
512            .min()
513            .unwrap_or(0);
514        let end_pos = gene_chunk
515            .iter()
516            .map(|&(local, _)| indptr[local + 1])
517            .max()
518            .unwrap_or(0);
519
520        if start_pos >= end_pos {
521            continue;
522        }
523
524        let chunk_data: Vec<f32> = data_rd.read(start_pos..end_pos)?;
525        let chunk_indices: Vec<u32> = indices_rd.read(start_pos..end_pos)?;
526
527        for &(local_gene, final_gene) in gene_chunk {
528            let gene_start = indptr[local_gene] - start_pos;
529            let gene_end = indptr[local_gene + 1] - start_pos;
530
531            for idx in gene_start..gene_end {
532                let old_cell = chunk_indices[idx] as usize;
533                let new_cell = cell_map.get(old_cell).copied().unwrap_or(INDEX_DROPPED);
534                if new_cell != INDEX_DROPPED {
535                    cell_data[new_cell as usize].push((final_gene as u32, chunk_data[idx] as u32));
536                }
537            }
538        }
539    }
540
541    let (nnz, lib_size) = write_cell_rows(&mut cell_data, cell_offset, target_size, writer)?;
542
543    Ok(H5FileQcResult {
544        exp_id: task.exp_id.clone(),
545        cells_to_keep: cells_to_keep.to_vec(),
546        lib_size,
547        nnz,
548    })
549}
550
551/// Per-task writer for `dense_row`.
552///
553/// ### Params
554///
555/// * `task` - The H5adFileTask
556/// * `cells_to_keep` - Which cells to keep from this file
557/// * `gene_mapping` - Informations on the gene mapping
558/// * `target_size` - The target size to normalise to
559/// * `cell_offset` - Current offset in the binary file
560/// * `writer` - The CellGeneSparseWriter to write to the binary file
561///
562/// ### Returns
563///
564/// An H5FileQcResult
565pub fn write_h5_dense_row_cells(
566    task: &H5adFileTask,
567    cells_to_keep: &[usize],
568    gene_mapping: &[Option<usize>],
569    target_size: f32,
570    cell_offset: usize,
571    writer: &mut CellGeneSparseWriter,
572) -> Result<H5FileQcResult, ScDataErrors> {
573    let file = File::open(&task.h5_path)?;
574    check_h5ad_dense_is_raw(&file, &task.raw_slot, None)?;
575
576    let ds = file.dataset(task.raw_slot.get_dense_path())?;
577    let no_genes_total = ds.shape().get(1).copied().unwrap_or(0);
578
579    let gene_map = dense_gene_map_from_options(gene_mapping);
580    let mut lib_size = Vec::with_capacity(cells_to_keep.len());
581    let mut nnz = Vec::with_capacity(cells_to_keep.len());
582
583    const BATCH_SIZE: usize = 1_000;
584    let mut written = 0usize;
585
586    for cell_batch in cells_to_keep.chunks(BATCH_SIZE) {
587        let row_start = *cell_batch
588            .iter()
589            .min()
590            .expect("batch non-empty by construction");
591        let row_end = *cell_batch
592            .iter()
593            .max()
594            .expect("batch non-empty by construction")
595            + 1;
596        let block: Array2<f32> = ds.read_slice_2d(s![row_start..row_end, 0..no_genes_total])?;
597
598        let first = cell_offset + written;
599        let built = cell_batch
600            .par_iter()
601            .enumerate()
602            .map_init(Vec::<(u32, u32)>::new, |buf, (k, &old_cell)| {
603                buf.clear();
604                let row = block.row(old_cell - row_start);
605                for (gene_idx, &val) in row.iter().enumerate() {
606                    let final_gene = gene_map.get(gene_idx).copied().unwrap_or(INDEX_DROPPED);
607                    if val != 0.0 && final_gene != INDEX_DROPPED {
608                        buf.push((final_gene, val as u32));
609                    }
610                }
611                compress_cell_row(buf, first + k, target_size, writer.format())
612            })
613            .collect::<Result<Vec<_>, ScDataErrors>>()?;
614
615        let mut payloads = Vec::with_capacity(built.len());
616        for (nnz_i, payload) in built {
617            nnz.push(nnz_i);
618            lib_size.push(payload.library_size);
619            payloads.push(payload);
620        }
621        writer.write_compressed_cell_chunks(&payloads)?;
622        written += cell_batch.len();
623    }
624
625    Ok(H5FileQcResult {
626        exp_id: task.exp_id.clone(),
627        cells_to_keep: cells_to_keep.to_vec(),
628        lib_size,
629        nnz,
630    })
631}
632
633/// Write the cells from an H5adFileTask (dispatch to respective helper)
634///
635/// ### Params
636///
637/// * `task` - The H5adFileTask
638/// * `cells_to_keep` - Which cells to keep from this file
639/// * `gene_mapping` - Informations on the gene mapping
640/// * `target_size` - The target size to normalise to
641/// * `cell_offset` - Current offset in the binary file
642/// * `writer` - The CellGeneSparseWriter to write to the binary file
643/// * `verbose` - Controls verbosity of the function
644///
645/// ### Returns
646///
647/// An H5FileQcResult
648fn write_h5_file_cells(
649    task: &H5adFileTask,
650    cells_to_keep: &[usize],
651    gene_mapping: &[Option<usize>],
652    target_size: f32,
653    cell_offset: usize,
654    writer: &mut CellGeneSparseWriter,
655    verbose: bool,
656) -> Result<H5FileQcResult, ScDataErrors> {
657    if verbose {
658        println!(
659            "  Writing {} ({} cells)...",
660            task.exp_id,
661            cells_to_keep.len().separate_with_underscores()
662        );
663    }
664    match task.cs_type {
665        H5ADFormat::Csr => write_h5_csr_cells(
666            task,
667            cells_to_keep,
668            gene_mapping,
669            target_size,
670            cell_offset,
671            writer,
672        ),
673        H5ADFormat::Csc => write_h5_csc_cells(
674            task,
675            cells_to_keep,
676            gene_mapping,
677            target_size,
678            cell_offset,
679            writer,
680        ),
681        H5ADFormat::DenseRow => write_h5_dense_row_cells(
682            task,
683            cells_to_keep,
684            gene_mapping,
685            target_size,
686            cell_offset,
687            writer,
688        ),
689        H5ADFormat::DenseCol => dense_col_not_supported(),
690    }
691}
692
693//////////
694// Main //
695//////////
696
697/// Write multiple h5ad files to disk
698///
699/// ### Params
700///
701/// * `tasks` - Reference to the H5adFileTasks to ingest.
702/// * `bin_path` - Path to the cells.bin file to write all the h5ad files to.
703/// * `universe` - Size of the feature universe
704/// * `cell_qc` - The minimum quality filters to apply
705/// * `verbose` - Controls verbosity of the function
706///
707/// ### Returns
708///
709/// The MultiH5adResult results
710pub fn multi_h5ad_to_file<P: AsRef<Path>>(
711    tasks: &[H5adFileTask],
712    bin_path: P,
713    universe_size: usize,
714    cell_qc: &MinCellQuality,
715    verbose: bool,
716) -> Result<MultiH5adResult, ScDataErrors> {
717    let total_start = Instant::now();
718
719    for task in tasks {
720        check_h5ad_filters(&File::open(&task.h5_path)?, &task.raw_slot, &task.cs_type)?;
721    }
722
723    if verbose {
724        println!(
725            "Scan 1/2: Counting gene expression across {} files...",
726            tasks.len()
727        );
728    }
729
730    // first parallel scan to check features
731    let per_file_nnz: Vec<Vec<usize>> = tasks
732        .par_iter()
733        .map(|task| {
734            if verbose {
735                println!("  Scanning genes in {}...", task.exp_id);
736            }
737            scan_gene_nnz(task, universe_size)
738        })
739        .collect::<Result<Vec<_>, _>>()?;
740
741    // aggregate
742    let mut global_gene_nnz = vec![0usize; universe_size];
743    for file_nnz in &per_file_nnz {
744        for (i, &count) in file_nnz.iter().enumerate() {
745            global_gene_nnz[i] += count;
746        }
747    }
748
749    // determine final gene set
750    let mut universe_to_final = vec![None; universe_size];
751    let mut final_idx = 0usize;
752    for (u_idx, &nnz) in global_gene_nnz.iter().enumerate() {
753        if nnz >= cell_qc.min_cells {
754            universe_to_final[u_idx] = Some(final_idx);
755            final_idx += 1;
756        }
757    }
758
759    let global_gene_indices: Vec<usize> = (0..universe_size)
760        .filter(|&i| universe_to_final[i].is_some())
761        .collect();
762    let total_genes = global_gene_indices.len();
763
764    if verbose {
765        println!(
766            "  Genes passing global QC: {} / {}",
767            total_genes.separate_with_underscores(),
768            universe_size.separate_with_underscores()
769        );
770    }
771
772    // compose per-file local -> final mappings
773    let composed_mappings: Vec<Vec<Option<usize>>> = tasks
774        .iter()
775        .map(|task| {
776            task.gene_local_to_universe
777                .iter()
778                .map(|opt| opt.and_then(|u| universe_to_final[u]))
779                .collect()
780        })
781        .collect();
782
783    // second parallel scan across cells
784    if verbose {
785        println!("Scan 2/2: Computing cell QC with final gene set...");
786    }
787
788    let per_file_cell_stats: Vec<Vec<(usize, f32)>> = tasks
789        .par_iter()
790        .zip(composed_mappings.par_iter())
791        .map(|(task, mapping)| {
792            if verbose {
793                println!("  Scanning cells in {}...", task.exp_id);
794            }
795            scan_cell_stats(task, mapping)
796        })
797        .collect::<Result<Vec<_>, _>>()?;
798
799    let per_file_cells: Vec<Vec<usize>> = per_file_cell_stats
800        .iter()
801        .map(|stats| {
802            stats
803                .iter()
804                .enumerate()
805                .filter(|(_, (unique, lib))| {
806                    *unique >= cell_qc.min_unique_genes && *lib >= cell_qc.min_lib_size as f32
807                })
808                .map(|(idx, _)| idx)
809                .collect()
810        })
811        .collect();
812
813    let total_cells: usize = per_file_cells.iter().map(|v| v.len()).sum();
814
815    if verbose {
816        for (i, task) in tasks.iter().enumerate() {
817            println!(
818                "  {}: {} / {} cells passing QC",
819                task.exp_id,
820                per_file_cells[i].len().separate_with_underscores(),
821                task.no_cells.separate_with_underscores()
822            );
823        }
824        println!("  Total cells: {}", total_cells.separate_with_underscores());
825    }
826
827    // write all of the cells to file
828    if verbose {
829        println!("Writing cells to binary...");
830    }
831
832    let mut writer = CellGeneSparseWriter::new(
833        &bin_path,
834        true,
835        total_cells,
836        total_genes,
837        cell_qc.target_size,
838    )?;
839
840    let mut cell_offset = 0usize;
841    let mut per_file_results = Vec::with_capacity(tasks.len());
842
843    for (file_idx, task) in tasks.iter().enumerate() {
844        let qc_result = write_h5_file_cells(
845            task,
846            &per_file_cells[file_idx],
847            &composed_mappings[file_idx],
848            cell_qc.target_size,
849            cell_offset,
850            &mut writer,
851            verbose,
852        )?;
853
854        cell_offset += per_file_cells[file_idx].len();
855        per_file_results.push(qc_result);
856    }
857
858    writer.finalise()?;
859
860    let elapsed = total_start.elapsed();
861    if verbose {
862        println!("Multi-h5ad loading complete: {:.2?}", elapsed);
863    }
864
865    Ok(MultiH5adResult {
866        global_gene_indices,
867        total_cells,
868        total_genes,
869        per_file: per_file_results,
870    })
871}